Fix composer selection formatting and drop overlay (#3172)

## Summary
- scope code blocks and list formatting to the selected composer text
- use the Buzz primary color for the selection formatter
- extend the channel drop overlay over the composer with matching
corners, blur, and accessible contrast across themes

## Validation
- `just ci`
- composer selection formatting E2E tests
- file attachment and all-theme drop contrast E2E tests

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
This commit is contained in:
klopez4212
2026-07-27 20:39:44 +00:00
committed by GitHub
co-authored by Wes Carl
parent 75588eaff2
commit 99da5b7ebb
9 changed files with 598 additions and 24 deletions
+1
View File
@@ -59,6 +59,7 @@ export default defineConfig({
"**/video-attachment.spec.ts",
"**/spoiler.spec.ts",
"**/composer-link-shortcut.spec.ts",
"**/composer-selection-formatting.spec.ts",
"**/composer-tooltip-dismiss.spec.ts",
"**/mentions.spec.ts",
"**/team-mentions.spec.ts",
@@ -820,7 +820,7 @@ export const ChannelPane = React.memo(function ChannelPane({
</div>
)}
{canDropInMainColumn && mainComposerMedia.isDragOver ? (
<DropZoneOverlay className="z-30 rounded-none" />
<DropZoneOverlay className="z-50 rounded-2xl bg-primary/20 backdrop-blur-sm" />
) : null}
</section>
) : null}
@@ -0,0 +1,86 @@
import { TextSelection, type Transaction } from "@tiptap/pm/state";
import { canSplit } from "@tiptap/pm/transform";
function canSplitInsideTextblock(
transaction: Transaction,
position: number,
): boolean {
const $position = transaction.doc.resolve(position);
return (
$position.parent.inlineContent &&
$position.parentOffset > 0 &&
$position.parentOffset < $position.parent.content.size &&
canSplit(transaction.doc, position)
);
}
function mapRangeThroughLatestStep(
transaction: Transaction,
from: number,
to: number,
): { from: number; to: number } {
const stepMap = transaction.steps.at(-1)?.getMap();
return stepMap
? {
from: stepMap.map(from, 1),
to: stepMap.map(to, -1),
}
: { from, to };
}
/**
* Isolate a non-empty text selection at exact block boundaries.
*
* ProseMirror's block commands operate on whole textblocks. The composer can
* hold an entire draft in one paragraph, so toggling a list or code block for
* a substring otherwise formats the whole draft. Splitting at the selection
* end and start first gives the selected text its own block while preserving
* the surrounding content as sibling paragraphs.
*
* This mutates the transaction supplied by a Tiptap command chain so the
* isolation and the following block toggle remain one undoable edit.
*/
export function isolateSelectionForBlockFormatting(
transaction: Transaction,
): boolean {
if (
!(transaction.selection instanceof TextSelection) ||
transaction.selection.empty
) {
return false;
}
const isBackward = transaction.selection.anchor > transaction.selection.head;
let { from, to } = transaction.selection;
const nodeAfterSelection = transaction.doc.resolve(to).nodeAfter;
if (nodeAfterSelection?.type.name === "hardBreak") {
transaction.delete(to, to + nodeAfterSelection.nodeSize);
({ from, to } = mapRangeThroughLatestStep(transaction, from, to));
}
const nodeBeforeSelection = transaction.doc.resolve(from).nodeBefore;
if (nodeBeforeSelection?.type.name === "hardBreak") {
transaction.delete(from - nodeBeforeSelection.nodeSize, from);
({ from, to } = mapRangeThroughLatestStep(transaction, from, to));
}
if (canSplitInsideTextblock(transaction, to)) {
transaction.split(to);
({ from, to } = mapRangeThroughLatestStep(transaction, from, to));
}
if (canSplitInsideTextblock(transaction, from)) {
transaction.split(from);
({ from, to } = mapRangeThroughLatestStep(transaction, from, to));
}
transaction.setSelection(
TextSelection.create(
transaction.doc,
isBackward ? to : from,
isBackward ? from : to,
),
);
return true;
}
@@ -7,6 +7,7 @@ import {
HatGlasses,
Pencil,
Play,
UploadCloud,
Users,
X,
} from "lucide-react";
@@ -39,13 +40,18 @@ import { ComposerImageEditor } from "./ComposerImageEditor";
export function DropZoneOverlay({ className }: { className?: string }) {
return (
<div
data-testid="drop-zone-overlay"
className={cn(
"pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-2xl border-2 border-dashed border-primary bg-primary/10",
className,
)}
>
<span className="text-sm font-medium text-primary">
Drop files to upload
<span
className="flex items-center gap-2 rounded-full bg-foreground px-4 py-2 text-sm font-semibold text-background shadow-sm ring-1 ring-background/15"
data-testid="drop-zone-label"
>
<UploadCloud aria-hidden="true" className="size-4" />
<span>Drop files to upload</span>
</span>
</div>
);
@@ -1,4 +1,5 @@
import * as React from "react";
import { TextSelection } from "@tiptap/pm/state";
import type { Editor } from "@tiptap/react";
import {
Bold,
@@ -15,6 +16,7 @@ import {
import { cn } from "@/shared/lib/cn";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { isolateSelectionForBlockFormatting } from "@/features/messages/lib/selectionBlockFormatting";
import { getEditorSpoilerRangeState } from "@/features/messages/lib/spoilerFormatting";
import { SPOILER_MARK_NAME } from "@/features/messages/lib/spoilerMark";
@@ -29,6 +31,11 @@ type FormattingToolbarProps = {
onLinkButton?: () => void;
};
type FormattingSelectionRange = {
anchor: number;
head: number;
};
type ActiveStates = {
bold: boolean;
italic: boolean;
@@ -117,6 +124,9 @@ export const FormattingToolbar = React.memo(function FormattingToolbar({
disabled = false,
onLinkButton,
}: FormattingToolbarProps) {
const pendingSelectionRef = React.useRef<FormattingSelectionRange | null>(
null,
);
const [activeStates, setActiveStates] = React.useState<ActiveStates | null>(
() => (editor ? getActiveStates(editor) : null),
);
@@ -137,36 +147,83 @@ export const FormattingToolbar = React.memo(function FormattingToolbar({
};
}, [editor]);
const toggleBold = React.useCallback(() => {
editor?.chain().focus().toggleBold().run();
const captureSelection = React.useCallback(() => {
if (!editor || editor.state.selection.empty) {
pendingSelectionRef.current = null;
return;
}
const { anchor, head } = editor.state.selection;
pendingSelectionRef.current = { anchor, head };
}, [editor]);
const formattingChain = React.useCallback(() => {
if (!editor) return null;
const range = pendingSelectionRef.current;
pendingSelectionRef.current = null;
const chain = editor.chain();
if (
range &&
range.anchor !== range.head &&
range.anchor <= editor.state.doc.content.size &&
range.head <= editor.state.doc.content.size
) {
chain.command(({ tr }) => {
tr.setSelection(TextSelection.create(tr.doc, range.anchor, range.head));
return true;
});
}
return chain.focus();
}, [editor]);
const toggleBold = React.useCallback(() => {
formattingChain()?.toggleBold().run();
}, [formattingChain]);
const toggleItalic = React.useCallback(() => {
editor?.chain().focus().toggleItalic().run();
}, [editor]);
formattingChain()?.toggleItalic().run();
}, [formattingChain]);
const toggleStrike = React.useCallback(() => {
editor?.chain().focus().toggleStrike().run();
}, [editor]);
formattingChain()?.toggleStrike().run();
}, [formattingChain]);
const toggleCode = React.useCallback(() => {
editor?.chain().focus().toggleCode().run();
}, [editor]);
formattingChain()?.toggleCode().run();
}, [formattingChain]);
const toggleCodeBlock = React.useCallback(() => {
editor?.chain().focus().toggleCodeBlock().run();
}, [editor]);
formattingChain()
?.command(({ tr }) => {
isolateSelectionForBlockFormatting(tr);
return true;
})
.toggleCodeBlock()
.run();
}, [formattingChain]);
const restorePendingSelection = React.useCallback(() => {
formattingChain()?.run();
}, [formattingChain]);
const toggleLink = React.useCallback(() => {
if (!editor) return;
// Preferred path: open the link-edit modal, which handles add, edit, and
// remove with proper display-text + URL fields.
// Restore the range captured on pointer-down before opening the modal.
// WKWebView may otherwise collapse it as focus moves to the toolbar.
if (onLinkButton) {
restorePendingSelection();
onLinkButton();
return;
}
const chain = formattingChain();
if (!chain) return;
chain.run();
// Legacy fallback (no modal wired): the native prompts below are a no-op
// in the Tauri WebView, so this path effectively does nothing there.
if (editor.isActive("link")) {
@@ -189,24 +246,37 @@ export const FormattingToolbar = React.memo(function FormattingToolbar({
editor.chain().focus().insertContent(`[${label}](${url})`).run();
}
}
}, [editor, onLinkButton]);
}, [editor, formattingChain, onLinkButton, restorePendingSelection]);
const toggleBulletList = React.useCallback(() => {
editor?.chain().focus().toggleBulletList().run();
}, [editor]);
formattingChain()
?.command(({ tr }) => {
isolateSelectionForBlockFormatting(tr);
return true;
})
.toggleBulletList()
.run();
}, [formattingChain]);
const toggleOrderedList = React.useCallback(() => {
editor?.chain().focus().toggleOrderedList().run();
}, [editor]);
formattingChain()
?.command(({ tr }) => {
isolateSelectionForBlockFormatting(tr);
return true;
})
.toggleOrderedList()
.run();
}, [formattingChain]);
const toggleBlockquote = React.useCallback(() => {
editor?.chain().focus().toggleBlockquote().run();
}, [editor]);
formattingChain()?.toggleBlockquote().run();
}, [formattingChain]);
const toggleSpoiler = React.useCallback(() => {
if (!editor) return;
restorePendingSelection();
toggleSpoilerFormatting(editor);
}, [editor]);
}, [editor, restorePendingSelection]);
if (!editor || !activeStates) return null;
@@ -289,6 +359,7 @@ export const FormattingToolbar = React.memo(function FormattingToolbar({
aria-pressed={item.active}
disabled={disabled}
onClick={() => item.action()}
onMouseDown={captureSelection}
className={cn(
"inline-flex h-7 w-7 min-w-7 items-center justify-center rounded-md text-sm font-medium transition-colors",
"hover:bg-muted hover:text-foreground",
@@ -195,6 +195,7 @@ export function SelectionFormattingTray({
? "-translate-x-1/2 -translate-y-full"
: "-translate-x-1/2",
)}
data-buzz-selection-formatting-tray
data-testid="selection-formatting-tray"
onMouseDown={(event) => event.preventDefault()}
role="toolbar"
@@ -245,6 +245,28 @@
--buzz-active-foreground: 0 0% 100%;
}
/*
* Use the Buzz primary color for the floating selection-formatting tray.
* Other themes retain the standard popover treatment.
*/
:root[data-buzz-sidebar] [data-buzz-selection-formatting-tray] {
border-color: hsl(var(--primary-foreground) / 0.18);
background-color: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
:root[data-buzz-sidebar] [data-buzz-selection-formatting-tray] button {
color: hsl(var(--primary-foreground));
}
:root[data-buzz-sidebar] [data-buzz-selection-formatting-tray] button:hover,
:root[data-buzz-sidebar]
[data-buzz-selection-formatting-tray]
button[aria-pressed="true"] {
background-color: hsl(var(--primary-foreground) / 0.16);
color: hsl(var(--primary-foreground));
}
/* Keep the subtle content-card lift in Buzz Light only. */
:root[data-buzz-sidebar]:not(.dark) [data-buzz-content-surface] {
box-shadow:
@@ -0,0 +1,287 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
async function openGeneral(page: Page) {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
}
async function selectText(input: Locator, selectedText: string) {
await input.evaluate((element, text) => {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
let offset = 0;
while (walker.nextNode()) {
const node = walker.currentNode;
const value = node.textContent ?? "";
const index = value.indexOf(text);
if (index >= 0) {
const range = document.createRange();
range.setStart(node, index);
range.setEnd(node, index + text.length);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
(element as HTMLElement).focus();
document.dispatchEvent(new Event("selectionchange"));
return;
}
offset += value.length;
}
throw new Error(
`Could not select "${text}" in composer after ${offset} characters`,
);
}, selectedText);
}
async function dragSelectText(
page: Page,
input: Locator,
selectedText: string,
backward = false,
) {
const points = await input.evaluate((element, text) => {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
while (walker.nextNode()) {
const node = walker.currentNode;
const value = node.textContent ?? "";
const index = value.indexOf(text);
if (index < 0) continue;
const startRange = document.createRange();
startRange.setStart(node, index);
startRange.setEnd(node, index + 1);
const startRect = startRange.getBoundingClientRect();
const endRange = document.createRange();
endRange.setStart(node, index + text.length - 1);
endRange.setEnd(node, index + text.length);
const endRect = endRange.getBoundingClientRect();
return {
start: {
x: startRect.left + 1,
y: startRect.top + startRect.height / 2,
},
end: {
x: endRect.right - 1,
y: endRect.top + endRect.height / 2,
},
};
}
throw new Error(`Could not locate "${text}" for mouse selection`);
}, selectedText);
const dragStart = backward ? points.end : points.start;
const dragEnd = backward ? points.start : points.end;
await page.mouse.move(dragStart.x, dragStart.y);
await page.mouse.down();
await page.mouse.move(dragEnd.x, dragEnd.y, { steps: 12 });
await page.mouse.up();
await expect
.poll(() => page.evaluate(() => window.getSelection()?.toString()))
.toBe(selectedText);
}
async function applySelectionFormat(
page: Page,
input: Locator,
label: "Bullet list" | "Code block" | "Ordered list",
collapseAfterMouseDown = false,
useMouseSelection = false,
) {
if (useMouseSelection) {
await dragSelectText(page, input, "selected");
} else {
await selectText(input, "selected");
}
const tray = page.getByTestId("selection-formatting-tray");
await expect(tray).toBeVisible();
const button = tray.getByRole("button", { name: label });
if (collapseAfterMouseDown) {
await button.evaluate((element, inputTestId) => {
element.addEventListener(
"mouseup",
() => {
const input = document.querySelector(
`[data-testid="${inputTestId}"]`,
);
if (!input) throw new Error("Composer input not found");
const range = document.createRange();
range.selectNodeContents(input);
range.collapse(false);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
document.dispatchEvent(new Event("selectionchange"));
},
{ once: true },
);
}, "message-input");
}
await button.click();
}
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
for (const format of [
{ label: "Code block", selector: "pre" },
{ label: "Bullet list", selector: "ul" },
{ label: "Ordered list", selector: "ol" },
] as const) {
test(`${format.label} applies only to the selected composer text`, async ({
page,
}) => {
await openGeneral(page);
const input = page.getByTestId("message-input");
await input.fill("before selected after");
await applySelectionFormat(page, input, format.label);
await expect(input.locator(":scope > p").first()).toHaveText("before ");
await expect(input.locator(`:scope > ${format.selector}`)).toHaveText(
"selected",
);
await expect(input.locator(":scope > p").last()).toHaveText(" after");
await expect(input).toHaveText("before selected after");
});
}
test("block formatting preserves the lines around a selected composer line", async ({
page,
}) => {
await openGeneral(page);
const input = page.getByTestId("message-input");
await input.click();
await input.pressSequentially("before");
await input.press("Shift+Enter");
await input.pressSequentially("selected");
await input.press("Shift+Enter");
await input.pressSequentially("after");
await applySelectionFormat(page, input, "Bullet list");
await expect(input.locator(":scope > p").first()).toHaveText("before");
await expect(input.locator(":scope > ul")).toHaveText("selected");
await expect(input.locator(":scope > p").last()).toHaveText("after");
await page.getByTestId("send-message").click();
await expect
.poll(() =>
page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_SIGNED_EVENTS__?: Array<{ content: string }>;
}
).__BUZZ_E2E_SIGNED_EVENTS__?.at(-1)?.content,
),
)
.toBe("before\n\n- selected\n\nafter");
});
test("block formatting restores a selection collapsed by the toolbar interaction", async ({
page,
}) => {
await openGeneral(page);
const input = page.getByTestId("message-input");
await input.fill("before selected after");
await applySelectionFormat(page, input, "Bullet list", true);
await expect(input.locator(":scope > p").first()).toHaveText("before ");
await expect(input.locator(":scope > ul")).toHaveText("selected");
await expect(input.locator(":scope > p").last()).toHaveText(" after");
});
test("block formatting only changes text selected with a native mouse drag", async ({
page,
}) => {
await openGeneral(page);
const input = page.getByTestId("message-input");
await input.fill("before selected after");
await applySelectionFormat(page, input, "Bullet list", false, true);
await expect(input.locator(":scope > p").first()).toHaveText("before ");
await expect(input.locator(":scope > ul")).toHaveText("selected");
await expect(input.locator(":scope > p").last()).toHaveText(" after");
});
test("block formatting preserves a backward native selection", async ({
page,
}) => {
await openGeneral(page);
const input = page.getByTestId("message-input");
await input.fill("before selected after");
await dragSelectText(page, input, "selected", true);
const tray = page.getByTestId("selection-formatting-tray");
await expect(tray).toBeVisible();
await tray.getByRole("button", { name: "Bullet list" }).click();
await expect(input.locator(":scope > ul")).toHaveText("selected");
await expect
.poll(() =>
page.evaluate(() => {
const selection = window.getSelection();
if (!(selection?.anchorNode && selection.focusNode)) return false;
const anchorRange = document.createRange();
anchorRange.setStart(selection.anchorNode, selection.anchorOffset);
anchorRange.collapse(true);
const focusRange = document.createRange();
focusRange.setStart(selection.focusNode, selection.focusOffset);
focusRange.collapse(true);
return (
anchorRange.compareBoundaryPoints(Range.START_TO_START, focusRange) >
0
);
}),
)
.toBe(true);
});
test("Buzz theme uses the primary color for the selection formatter", async ({
page,
}) => {
await openGeneral(page);
const input = page.getByTestId("message-input");
await input.fill("before selected after");
await selectText(input, "selected");
const tray = page.getByTestId("selection-formatting-tray");
await expect(tray).toBeVisible();
const colors = await tray.evaluate((element) => {
const probe = document.createElement("span");
probe.style.backgroundColor = "hsl(var(--primary))";
probe.style.color = "hsl(var(--primary-foreground))";
document.body.appendChild(probe);
const probeStyles = getComputedStyle(probe);
const trayStyles = getComputedStyle(element);
const result = {
primaryBackground: probeStyles.backgroundColor,
primaryForeground: probeStyles.color,
trayBackground: trayStyles.backgroundColor,
trayForeground: trayStyles.color,
};
probe.remove();
return result;
});
expect(colors.trayBackground).toBe(colors.primaryBackground);
expect(colors.trayForeground).toBe(colors.primaryForeground);
});
+101 -1
View File
@@ -82,7 +82,53 @@ test("dropping a file on the channel column attaches it to the composer", async
const dropZone = page.getByTestId("channel-drop-zone");
await dropZone.dispatchEvent("dragenter", { dataTransfer });
await expect(dropZone.getByText("Drop files to upload")).toBeVisible();
const overlay = dropZone.getByTestId("drop-zone-overlay");
const label = dropZone.getByTestId("drop-zone-label");
await expect(overlay).toBeVisible();
await expect(label).toContainText("Drop files to upload");
const [dropZoneBox, overlayBox, overlayStyles, stacking] = await Promise.all([
dropZone.boundingBox(),
overlay.boundingBox(),
page.evaluate(() => {
const overlayElement = document.querySelector<HTMLElement>(
'[data-testid="drop-zone-overlay"]',
);
const contentSurface = document.querySelector<HTMLElement>(
"[data-buzz-content-surface]",
);
if (!(overlayElement && contentSurface)) return null;
const overlayStyle = getComputedStyle(overlayElement);
return {
backdropFilter: overlayStyle.backdropFilter,
containerRadius: getComputedStyle(contentSurface).borderRadius,
overlayRadius: overlayStyle.borderRadius,
};
}),
page.evaluate(() => {
const overlayElement = document.querySelector<HTMLElement>(
'[data-testid="drop-zone-overlay"]',
);
const composerOverlayElement = document.querySelector<HTMLElement>(
'[data-testid="channel-composer-overlay"]',
);
if (!(overlayElement && composerOverlayElement)) return null;
return {
composer: Number.parseInt(
getComputedStyle(composerOverlayElement).zIndex,
10,
),
dropZone: Number.parseInt(getComputedStyle(overlayElement).zIndex, 10),
};
}),
]);
expect(overlayBox).toEqual(dropZoneBox);
expect(overlayStyles).not.toBeNull();
expect(overlayStyles?.overlayRadius).toBe(overlayStyles?.containerRadius);
expect(overlayStyles?.backdropFilter).toContain("blur");
expect(stacking).not.toBeNull();
expect(stacking?.dropZone).toBeGreaterThan(stacking?.composer ?? 0);
await dropZone.dispatchEvent("drop", { dataTransfer });
await expect(page.getByTestId("message-composer")).toContainText(
@@ -90,6 +136,60 @@ test("dropping a file on the channel column attaches it to the composer", async
);
});
for (const theme of ["buzz", "buzz-dark", "github-light", "github-dark"]) {
test(`drop prompt has accessible text contrast in ${theme}`, async ({
page,
}) => {
await page.goto("/");
await page.evaluate((selectedTheme) => {
window.localStorage.setItem("buzz-theme", selectedTheme);
}, theme);
await page.reload();
await page.getByTestId("channel-general").click();
const dataTransfer = await page.evaluateHandle(() => {
const transfer = new DataTransfer();
transfer.items.add(
new File(["contrast check"], "contrast-check.txt", {
type: "text/plain",
}),
);
return transfer;
});
const dropZone = page.getByTestId("channel-drop-zone");
await dropZone.dispatchEvent("dragenter", { dataTransfer });
const contrastRatio = await dropZone
.getByTestId("drop-zone-label")
.evaluate((element) => {
const parseRgb = (value: string) =>
(value.match(/[\d.]+/g) ?? []).slice(0, 3).map(Number);
const luminance = (color: number[]) =>
color
.map((channel) => {
const value = channel / 255;
return value <= 0.04045
? value / 12.92
: ((value + 0.055) / 1.055) ** 2.4;
})
.reduce(
(sum, channel, index) =>
sum + channel * [0.2126, 0.7152, 0.0722][index],
0,
);
const style = getComputedStyle(element);
const foreground = luminance(parseRgb(style.color));
const background = luminance(parseRgb(style.backgroundColor));
return (
(Math.max(foreground, background) + 0.05) /
(Math.min(foreground, background) + 0.05)
);
});
expect(contrastRatio).toBeGreaterThanOrEqual(4.5);
});
}
test("forum posts emit a FileCard for generic attachments, not a broken image", async ({
page,
}) => {