mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user