From bd61fccd07df788597eb154ef5b52f58893a189e Mon Sep 17 00:00:00 2001 From: khoinguyenpham04 <137921741+khoinguyenpham04@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:33:08 +0100 Subject: [PATCH 01/23] feat(editor): add admin code block controls --- e2e/tests/code-block-highlighting.spec.ts | 112 ++++++++ .../src/components/editor/CodeBlockNode.tsx | 260 +++++++++++------- packages/admin/src/styles.css | 27 ++ .../tests/editor/PortableTextEditor.test.tsx | 70 +++++ 4 files changed, 364 insertions(+), 105 deletions(-) diff --git a/e2e/tests/code-block-highlighting.spec.ts b/e2e/tests/code-block-highlighting.spec.ts index 8b161be883..c1ad25d9a5 100644 --- a/e2e/tests/code-block-highlighting.spec.ts +++ b/e2e/tests/code-block-highlighting.spec.ts @@ -29,6 +29,118 @@ test.describe("Admin code block highlighting", () => { await expect(codeBlock).toHaveCSS("background-color", "rgb(32, 32, 32)"); await expect(codeBlock).toHaveCSS("border-top-width", "0px"); }); + + test("shows aligned two-action controls and applies their actions", async ({ admin }) => { + const codeBlockNode = admin.page.locator(".emdash-code-block-node").first(); + const controls = codeBlockNode.getByRole("toolbar", { name: "Code block actions" }); + const languageButton = controls.getByRole("button", { name: /^Set language/ }); + const copyButton = controls.getByRole("button", { name: "Copy code" }); + let updateRequests = 0; + admin.page.on("request", (request) => { + if (request.method() === "PUT" && request.url().includes("/_emdash/api/content/")) { + updateRequests += 1; + } + }); + + await admin.page.mouse.move(0, 0); + await expect(controls).toHaveCSS("opacity", "0"); + await languageButton.focus(); + await expect(controls).toHaveCSS("opacity", "1"); + await codeBlockNode.hover(); + await expect(controls).toHaveCSS("opacity", "1"); + await expect(controls.getByRole("button")).toHaveCount(2); + await expect(languageButton).toHaveCSS("font-size", "14px"); + await expect(copyButton).toHaveCSS("font-size", "14px"); + + const nodeBox = await codeBlockNode.boundingBox(); + const controlsBox = await controls.boundingBox(); + const languageBox = await languageButton.boundingBox(); + const codeTextTop = await codeBlockNode.locator("code").evaluate((element) => { + const range = document.createRange(); + range.selectNodeContents(element); + return range.getClientRects()[0]?.top; + }); + expect(nodeBox).not.toBeNull(); + expect(controlsBox).not.toBeNull(); + expect(languageBox?.height).toBe(32); + expect(controlsBox?.y).toBeCloseTo((nodeBox?.y ?? 0) + 8, 0); + expect(controlsBox?.x).toBeCloseTo( + (nodeBox?.x ?? 0) + (nodeBox?.width ?? 0) - (controlsBox?.width ?? 0) - 8, + 0, + ); + expect(codeTextTop ?? 0).toBeGreaterThanOrEqual( + (controlsBox?.y ?? 0) + (controlsBox?.height ?? 0) + 8, + ); + + await languageButton.click(); + await admin.page.mouse.move(0, 0); + await expect(controls).toHaveCSS("opacity", "1"); + const input = admin.page.getByPlaceholder("Search for a language…"); + const popup = admin.page.locator(".kumo-popover-popup"); + await expect(input).toBeVisible(); + await expect(input).toHaveCSS("font-size", "14px"); + await expect(admin.page.getByRole("option", { name: "Plain text" })).toHaveCSS( + "font-size", + "14px", + ); + const placeholderColor = await input.evaluate( + (element) => getComputedStyle(element, "::placeholder").color, + ); + const inputColor = await input.evaluate((element) => getComputedStyle(element).color); + expect(placeholderColor).not.toBe(inputColor); + + const popupBox = await popup.boundingBox(); + const openControlsBox = await controls.boundingBox(); + expect(popupBox?.x).toBeCloseTo(openControlsBox?.x ?? 0, 0); + const popupBelowGap = + (popupBox?.y ?? 0) - ((openControlsBox?.y ?? 0) + (openControlsBox?.height ?? 0)); + const popupAboveGap = + (openControlsBox?.y ?? 0) - ((popupBox?.y ?? 0) + (popupBox?.height ?? 0)); + expect(Math.max(popupBelowGap, popupAboveGap)).toBeCloseTo(8, 0); + + await admin.page.keyboard.press("Escape"); + await expect(input).toBeHidden(); + await admin.page.context().grantPermissions(["clipboard-read", "clipboard-write"]); + await copyButton.click(); + await expect(controls.getByRole("button", { name: "Copied" })).toBeVisible(); + await admin.page.waitForTimeout(2200); + expect(updateRequests).toBe(0); + + await languageButton.click(); + const autosaveResponse = admin.page.waitForResponse( + (response) => + response.request().method() === "PUT" && response.url().includes("/_emdash/api/content/"), + { timeout: 5000 }, + ); + await admin.page.getByRole("option", { name: "Python" }).click(); + await expect( + controls.getByRole("button", { name: "Set language (current: Python)" }), + ).toBeVisible(); + await autosaveResponse; + expect(updateRequests).toBe(1); + }); + + test("keeps controls and language search inside a narrow RTL editor", async ({ admin }) => { + await admin.page.setViewportSize({ width: 320, height: 800 }); + await admin.page.evaluate(() => { + document.querySelector(".emdash-code-block-node")?.setAttribute("dir", "rtl"); + }); + const codeBlockNode = admin.page.locator(".emdash-code-block-node").first(); + const controls = codeBlockNode.getByRole("toolbar", { name: "Code block actions" }); + await codeBlockNode.hover(); + await expect(controls).toBeVisible(); + + const nodeBox = await codeBlockNode.boundingBox(); + const controlsBox = await controls.boundingBox(); + expect(controlsBox?.x).toBeCloseTo((nodeBox?.x ?? 0) + 8, 0); + + await controls.getByRole("button", { name: /^Set language/ }).click(); + const popupBox = await admin.page.locator(".kumo-popover-popup").boundingBox(); + expect(popupBox).not.toBeNull(); + expect(popupBox?.x ?? -1).toBeGreaterThanOrEqual(0); + expect((popupBox?.x ?? 0) + (popupBox?.width ?? 0)).toBeLessThanOrEqual(320); + expect(popupBox?.width ?? 0).toBeLessThanOrEqual(288); + }); }); test("keeps public code block rendering unchanged", async ({ page }) => { diff --git a/packages/admin/src/components/editor/CodeBlockNode.tsx b/packages/admin/src/components/editor/CodeBlockNode.tsx index b6159e9e8d..e597eafcb4 100644 --- a/packages/admin/src/components/editor/CodeBlockNode.tsx +++ b/packages/admin/src/components/editor/CodeBlockNode.tsx @@ -2,31 +2,23 @@ * Code block node with language picker. * * Wraps the Lowlight code block with a React node view that - * overlays a small language chip in the top-right corner. Clicking the chip - * opens a popover with a Kumo Autocomplete: a free-form text input plus a - * filtered list of curated language suggestions. The value is persisted on - * the node's `language` attribute and round-trips through Portable Text as - * `block.language`. + * overlays a Kumo action toolbar at the logical end of the block. The toolbar + * opens a searchable language popover and copies the raw code. The selected + * language is persisted on the node's `language` attribute and round-trips + * through Portable Text as `block.language`. * * The picker accepts arbitrary strings (not restricted to the curated list) * so that less common languages can still be used. Free-form input is * sanitized to a single safe CSS class token via `normalizeLanguage` so the * frontend's `language-{id}` class stays well-formed. * - * The popover content is rendered through Kumo's `Popover`, which portals it - * out of the editor's contentEditable DOM. That portal is load-bearing, not - * cosmetic: a code block is a non-atom ProseMirror node with live editable - * content, so if the picker's text input lived inside the node view, typing - * would move the DOM selection into it. ProseMirror reads that selection, - * dispatches a selection-correcting transaction, and the resulting node-view - * redraw recreates this React component mid-edit, tearing the picker down -- - * the "language picker loses focus and closes when you type" bug (issue - * #1200). Keeping the input outside the editor DOM avoids it entirely. + * Kumo's `Popover` portals the search input out of the contentEditable DOM so + * ProseMirror does not interpret input typing as an editor selection change. */ -import { Autocomplete, Button, Popover } from "@cloudflare/kumo"; +import { CommandPalette, Popover, Toolbar } from "@cloudflare/kumo"; import { useLingui } from "@lingui/react/macro"; -import { Check, X } from "@phosphor-icons/react"; +import { CaretDown, Check, Copy } from "@phosphor-icons/react"; import { CodeBlockLowlight } from "@tiptap/extension-code-block-lowlight"; import type { NodeViewProps } from "@tiptap/react"; import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer } from "@tiptap/react"; @@ -67,9 +59,19 @@ const editorLowlight = { }, }; -function CodeBlockNodeView({ node, updateAttributes, selected }: NodeViewProps) { +interface LanguageItem { + id: string; + label: string; + aliases?: string[]; +} + +function CodeBlockNodeView({ node, updateAttributes }: NodeViewProps) { const { t } = useLingui(); const [isEditing, setIsEditing] = React.useState(false); + const [copied, setCopied] = React.useState(false); + const [keyboardHighlightedLanguage, setKeyboardHighlightedLanguage] = + React.useState(null); + const copyResetTimer = React.useRef | null>(null); const storedLanguage = typeof node.attrs.language === "string" ? node.attrs.language : ""; const labelText = React.useCallback( @@ -81,27 +83,33 @@ function CodeBlockNodeView({ node, updateAttributes, selected }: NodeViewProps) ); const languageItems = React.useMemo( - () => CODE_BLOCK_LANGUAGES.map((language) => t(language.label)), + () => + CODE_BLOCK_LANGUAGES.map((language) => ({ + id: language.id, + label: t(language.label), + aliases: language.aliases, + })), [t], ); const findLanguageByDisplayLabel = React.useCallback( - (label: string) => CODE_BLOCK_LANGUAGES.find((language) => t(language.label) === label), - [t], + (label: string) => languageItems.find((language) => language.label === label), + [languageItems], ); - const filterLanguages = React.useCallback( - (item: string, query: string) => { - if (!query) return true; - const searchText = query.toLowerCase(); - const lang = findLanguageByDisplayLabel(item); - if (!lang) return false; + const filterLanguages = React.useCallback((item: LanguageItem, query: string) => { + if (!query) return true; + const searchText = query.toLowerCase(); + if (item.label.toLowerCase().includes(searchText)) return true; + if (item.id.toLowerCase().includes(searchText)) return true; + return item.aliases?.some((alias) => alias.toLowerCase().includes(searchText)) ?? false; + }, []); - if (t(lang.label).toLowerCase().includes(searchText)) return true; - if (lang.id.toLowerCase().includes(searchText)) return true; - return lang.aliases?.some((alias) => alias.toLowerCase().includes(searchText)) ?? false; + React.useEffect( + () => () => { + if (copyResetTimer.current) clearTimeout(copyResetTimer.current); }, - [findLanguageByDisplayLabel, t], + [], ); const [draft, setDraft] = React.useState(() => labelText(storedLanguage)); @@ -116,12 +124,14 @@ function CodeBlockNodeView({ node, updateAttributes, selected }: NodeViewProps) }, [storedLanguage, isEditing, labelText]); const openPicker = React.useCallback(() => { - setDraft(storedLanguage ? labelText(storedLanguage) : ""); + setDraft(""); + setKeyboardHighlightedLanguage(null); setIsEditing(true); - }, [storedLanguage, labelText]); + }, []); const closePicker = React.useCallback(() => { setIsEditing(false); + setKeyboardHighlightedLanguage(null); setDraft(labelText(storedLanguage)); }, [storedLanguage, labelText]); @@ -132,99 +142,139 @@ function CodeBlockNodeView({ node, updateAttributes, selected }: NodeViewProps) const next = selectedLanguage?.id ?? normalizeLanguage(raw); updateAttributes({ language: next ?? null }); setIsEditing(false); + setKeyboardHighlightedLanguage(null); }, [draft, findLanguageByDisplayLabel, updateAttributes], ); - // Enter commits the current draft. Escape is handled by the Popover itself - // (it calls onOpenChange(false) -> closePicker). - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + closePicker(); + return; + } + if (e.key === "Enter" && !keyboardHighlightedLanguage) { e.preventDefault(); commit(); } }; + const copyCode = React.useCallback(async () => { + try { + await navigator.clipboard.writeText(node.textContent); + setCopied(true); + if (copyResetTimer.current) clearTimeout(copyResetTimer.current); + copyResetTimer.current = setTimeout(setCopied, 2500, false); + } catch { + setCopied(false); + } + }, [node.textContent]); + const label = labelText(storedLanguage); - // The chip is always rendered (so it can be discovered via hover) but its - // opacity is controlled by CSS: invisible by default, visible on hover, - // when this block is selected, when the picker is open, or when the - // block already has a language set. When hidden, also remove it from the - // tab order so it doesn't trap keyboard focus. - const chipPersistent = isEditing || Boolean(storedLanguage) || selected; + const currentLanguageId = normalizeLanguage(storedLanguage); + const controlsPersistent = isEditing || copied; return ( - +
 				 as="code" />
 			
-
+
(open ? openPicker() : closePicker())} > - e.preventDefault()} - className="rounded-md border bg-kumo-overlay/90 px-2 py-1 text-xs text-kumo-subtle opacity-0 transition-opacity hover:text-kumo-strong focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-kumo-brand group-hover:opacity-100 data-[persistent=true]:opacity-100" - data-persistent={chipPersistent ? "true" : "false"} - title={t`Set language`} - aria-label={t`Set language (current: ${label})`} - aria-hidden={chipPersistent ? undefined : true} - > - {storedLanguage ? label : t`Set language`} - - } - /> - -
- setDraft(next)} - filter={filterLanguages} - > - - - - {(item: string) => ( - - {item} - - )} - - {t`No matches`} - - - - -
+ + event.preventDefault()} + title={t`Set language`} + aria-label={t`Set language (current: ${label})`} + > + {label} + + + {copied ? t`Copied` : ""} + + + + items={languageItems} + value={draft} + onValueChange={(next: string) => { + setDraft(next); + setKeyboardHighlightedLanguage(null); + }} + onItemHighlighted={(item, details) => + setKeyboardHighlightedLanguage( + details.reason === "keyboard" ? (item ?? null) : null, + ) + } + itemToStringValue={(item) => item.label} + filter={filterLanguages} + open={isEditing} + className="[&>div:first-child]:gap-0 [&>div:first-child]:px-3 [&>div:first-child]:py-3 [&>div:first-child]:focus-within:ring-0" + > +
diff --git a/packages/admin/src/styles.css b/packages/admin/src/styles.css index 307539487d..833b081e32 100644 --- a/packages/admin/src/styles.css +++ b/packages/admin/src/styles.css @@ -255,6 +255,7 @@ body { background: var(--emdash-code-background); color: var(--emdash-code-foreground); caret-color: var(--emdash-code-foreground); + padding-block-start: 3.25rem; } [data-mode="dark"] .emdash-code-block { @@ -302,6 +303,32 @@ body { color: var(--emdash-code-title); } +.emdash-code-block-controls { + opacity: 0; + pointer-events: none; + transition: opacity 120ms ease-out; +} + +.emdash-code-block-node:hover .emdash-code-block-controls, +.emdash-code-block-node:focus-within .emdash-code-block-controls, +.emdash-code-block-controls[data-persistent="true"] { + opacity: 1; + pointer-events: auto; +} + +@media (hover: none), (pointer: coarse) { + .emdash-code-block-controls { + opacity: 1; + pointer-events: auto; + } +} + +@media (prefers-reduced-motion: reduce) { + .emdash-code-block-controls { + transition: none; + } +} + /** * TipTap placeholder styles */ diff --git a/packages/admin/tests/editor/PortableTextEditor.test.tsx b/packages/admin/tests/editor/PortableTextEditor.test.tsx index 149833a7a7..b93ea97030 100644 --- a/packages/admin/tests/editor/PortableTextEditor.test.tsx +++ b/packages/admin/tests/editor/PortableTextEditor.test.tsx @@ -9,6 +9,7 @@ import type { Editor } from "@tiptap/react"; import * as React from "react"; import { describe, it, expect, vi } from "vitest"; +import { userEvent } from "vitest/browser"; import type { PluginBlockDef } from "../../src/components/PortableTextEditor"; import { @@ -1272,3 +1273,72 @@ describe("onChange output shape", () => { expect(listNode).toBeTruthy(); }); }); + +describe("Code block controls", () => { + it("uses a two-action toolbar and selects a language immediately", async () => { + const { screen, editor } = await renderAndGetEditor({ + value: [{ _type: "code", _key: "code", code: "print('hello')", language: "python" }], + }); + + const toolbar = screen.getByRole("toolbar", { name: "Code block actions" }); + await expect.element(toolbar).toBeInTheDocument(); + expect(toolbar.element().querySelectorAll("button")).toHaveLength(2); + await expect.element(toolbar.getByRole("button", { name: "Copy code" })).toBeInTheDocument(); + + await toolbar.getByRole("button", { name: "Set language (current: Python)" }).click(); + const input = screen.getByPlaceholder("Search for a language…"); + await expect.element(input).toBeInTheDocument(); + await screen.getByRole("option", { name: "JavaScript" }).click(); + + await vi.waitFor(() => { + const node = editor.getJSON().content?.find((item) => item.type === "codeBlock"); + expect(node?.attrs?.language).toBe("javascript"); + }); + await expect.element(input).not.toBeInTheDocument(); + }); + + it("copies the raw code and exposes copied feedback", async () => { + const clipboardWrite = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(); + const { screen } = await renderAndGetEditor({ + value: [ + { + _type: "code", + _key: "code", + code: "const greeting = 'hello';", + language: "javascript", + }, + ], + }); + + await screen.getByRole("button", { name: "Copy code" }).click(); + await vi.waitFor(() => { + expect(clipboardWrite).toHaveBeenCalledWith("const greeting = 'hello';"); + }); + await expect.element(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument(); + await expect.element(screen.getByRole("status")).toHaveTextContent("Copied"); + clipboardWrite.mockRestore(); + }); + + it("supports free-form Enter and closes the language search with Escape", async () => { + const { screen, editor } = await renderAndGetEditor({ + value: [{ _type: "code", _key: "code", code: "custom()", language: "plaintext" }], + }); + const languageButton = screen.getByRole("button", { + name: "Set language (current: Plain text)", + }); + + await languageButton.click(); + let input = screen.getByPlaceholder("Search for a language…"); + await input.fill("Custom Language"); + await userEvent.keyboard("{Enter}"); + await vi.waitFor(() => { + const node = editor.getJSON().content?.find((item) => item.type === "codeBlock"); + expect(node?.attrs?.language).toBe("custom-language"); + }); + + await screen.getByRole("button", { name: "Set language (current: custom-language)" }).click(); + input = screen.getByPlaceholder("Search for a language…"); + await userEvent.keyboard("{Escape}"); + await expect.element(input).not.toBeInTheDocument(); + }); +}); From c26f3feeb9d4e5888034303e8f311149a2467724 Mon Sep 17 00:00:00 2001 From: khoinguyenpham04 <137921741+khoinguyenpham04@users.noreply.github.com> Date: Sat, 22 Aug 2026 12:26:29 +0100 Subject: [PATCH 02/23] feat(editor): match inline code block controls --- .changeset/gentle-code-block-controls.md | 6 + e2e/tests/code-block-highlighting.spec.ts | 163 ++++++- .../components/InlinePortableTextEditor.tsx | 194 +++++++- .../core/src/components/inline-code-block.tsx | 423 ++++++++++++------ .../inline-portable-text-code-block.test.ts | 126 +++++- 5 files changed, 756 insertions(+), 156 deletions(-) create mode 100644 .changeset/gentle-code-block-controls.md diff --git a/.changeset/gentle-code-block-controls.md b/.changeset/gentle-code-block-controls.md new file mode 100644 index 0000000000..b5d9e3d41d --- /dev/null +++ b/.changeset/gentle-code-block-controls.md @@ -0,0 +1,6 @@ +--- +"@emdash-cms/admin": patch +"emdash": patch +--- + +Adds matching code-block controls to the admin and inline visual editors for selecting a language and copying code. diff --git a/e2e/tests/code-block-highlighting.spec.ts b/e2e/tests/code-block-highlighting.spec.ts index c1ad25d9a5..2c94780b00 100644 --- a/e2e/tests/code-block-highlighting.spec.ts +++ b/e2e/tests/code-block-highlighting.spec.ts @@ -41,6 +41,8 @@ test.describe("Admin code block highlighting", () => { updateRequests += 1; } }); + await admin.page.waitForTimeout(2200); + updateRequests = 0; await admin.page.mouse.move(0, 0); await expect(controls).toHaveCSS("opacity", "0"); @@ -72,6 +74,8 @@ test.describe("Admin code block highlighting", () => { (controlsBox?.y ?? 0) + (controlsBox?.height ?? 0) + 8, ); + const currentLanguageLabel = await languageButton.getAttribute("aria-label"); + const nextLanguage = currentLanguageLabel?.includes("Python") ? "JavaScript" : "Python"; await languageButton.click(); await admin.page.mouse.move(0, 0); await expect(controls).toHaveCSS("opacity", "1"); @@ -83,6 +87,9 @@ test.describe("Admin code block highlighting", () => { "font-size", "14px", ); + await popup.evaluate(async (element) => { + await Promise.all(element.getAnimations().map((animation) => animation.finished)); + }); const placeholderColor = await input.evaluate( (element) => getComputedStyle(element, "::placeholder").color, ); @@ -112,9 +119,9 @@ test.describe("Admin code block highlighting", () => { response.request().method() === "PUT" && response.url().includes("/_emdash/api/content/"), { timeout: 5000 }, ); - await admin.page.getByRole("option", { name: "Python" }).click(); + await admin.page.getByRole("option", { name: nextLanguage }).click(); await expect( - controls.getByRole("button", { name: "Set language (current: Python)" }), + controls.getByRole("button", { name: `Set language (current: ${nextLanguage})` }), ).toBeVisible(); await autosaveResponse; expect(updateRequests).toBe(1); @@ -175,6 +182,127 @@ test.describe("Inline code block highlighting", () => { await expect(codeBlocks.nth(1).locator('span[class*="hljs-"]')).toHaveCount(0); }); + test("matches the admin controls without saving during control interactions", async ({ + page, + }) => { + const codeBlockNode = page.locator(".emdash-inline-code-block").first(); + const controlsWrap = codeBlockNode.locator(".emdash-inline-code-block-controls-wrap"); + const controls = codeBlockNode.getByRole("toolbar", { name: "Code block actions" }); + const languageButton = controls.getByRole("button", { name: /^Set language/ }); + const copyButton = controls.getByRole("button", { name: "Copy code" }); + let updateRequests = 0; + page.on("request", (request) => { + if (request.method() === "PUT" && request.url().includes("/_emdash/api/content/")) { + updateRequests += 1; + } + }); + await page.waitForTimeout(2200); + updateRequests = 0; + + await page.mouse.move(0, 0); + await page.evaluate(() => { + if (document.activeElement instanceof HTMLElement) document.activeElement.blur(); + }); + await expect(controlsWrap).toHaveCSS("opacity", "0"); + await languageButton.focus(); + await expect(controlsWrap).toHaveCSS("opacity", "1"); + await codeBlockNode.hover(); + await expect(controlsWrap).toHaveCSS("opacity", "1"); + await expect(controls.getByRole("button")).toHaveCount(2); + await expect(languageButton).toHaveCSS("font-size", "14px"); + await expect(copyButton).toHaveCSS("font-size", "14px"); + + const nodeBox = await codeBlockNode.boundingBox(); + const controlsBox = await controls.boundingBox(); + const languageBox = await languageButton.boundingBox(); + const codeTextTop = await codeBlockNode.locator("code").evaluate((element) => { + const range = document.createRange(); + range.selectNodeContents(element); + return range.getClientRects()[0]?.top; + }); + expect(nodeBox).not.toBeNull(); + expect(controlsBox).not.toBeNull(); + expect(languageBox?.height).toBe(32); + expect(controlsBox?.y).toBeCloseTo((nodeBox?.y ?? 0) + 8, 0); + expect(controlsBox?.x).toBeCloseTo( + (nodeBox?.x ?? 0) + (nodeBox?.width ?? 0) - (controlsBox?.width ?? 0) - 8, + 0, + ); + expect(codeTextTop ?? 0).toBeGreaterThanOrEqual( + (controlsBox?.y ?? 0) + (controlsBox?.height ?? 0) + 8, + ); + + await languageButton.click(); + await page.mouse.move(0, 0); + await expect(controlsWrap).toHaveCSS("opacity", "1"); + const input = page.getByPlaceholder("Search for a language…"); + const popup = page.locator(".emdash-inline-code-block-popover"); + await expect(input).toBeVisible(); + await expect(input).toBeFocused(); + await expect(input).toHaveCSS("font-size", "14px"); + await expect(page.getByRole("option", { name: "Plain text" })).toHaveCSS("font-size", "14px"); + const placeholderColor = await input.evaluate( + (element) => getComputedStyle(element, "::placeholder").color, + ); + const inputColor = await input.evaluate((element) => getComputedStyle(element).color); + expect(placeholderColor).not.toBe(inputColor); + + const popupBox = await popup.boundingBox(); + const openControlsBox = await controls.boundingBox(); + const viewportWidth = await page.evaluate(() => window.innerWidth); + const expectedPopupX = Math.min( + Math.max(openControlsBox?.x ?? 0, 16), + viewportWidth - 16 - (popupBox?.width ?? 0), + ); + expect(popupBox?.x).toBeCloseTo(expectedPopupX, 0); + const popupBelowGap = + (popupBox?.y ?? 0) - ((openControlsBox?.y ?? 0) + (openControlsBox?.height ?? 0)); + const popupAboveGap = + (openControlsBox?.y ?? 0) - ((popupBox?.y ?? 0) + (popupBox?.height ?? 0)); + expect(Math.max(popupBelowGap, popupAboveGap)).toBeCloseTo(8, 0); + + await page.keyboard.press("Escape"); + await expect(input).toBeHidden(); + await expect(languageButton).toBeFocused(); + await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); + await copyButton.click(); + await expect(controls.getByRole("button", { name: "Copied" })).toBeVisible(); + + await languageButton.click(); + await page.getByRole("option", { name: "Python" }).click(); + await expect( + controls.getByRole("button", { name: "Set language (current: Python)" }), + ).toBeVisible(); + await page.waitForTimeout(500); + expect(updateRequests).toBe(0); + }); + + test("keeps inline controls and search inside a narrow RTL viewport", async ({ page }) => { + await page.setViewportSize({ width: 320, height: 800 }); + await page.evaluate(() => { + document.querySelector(".emdash-inline-code-block")?.setAttribute("dir", "rtl"); + }); + const codeBlockNode = page.locator(".emdash-inline-code-block").first(); + const controlsWrap = codeBlockNode.locator(".emdash-inline-code-block-controls-wrap"); + const controls = codeBlockNode.getByRole("toolbar", { name: "Code block actions" }); + await codeBlockNode.hover(); + await expect(controlsWrap).toHaveCSS("opacity", "1"); + + const nodeBox = await codeBlockNode.boundingBox(); + const controlsBox = await controls.boundingBox(); + expect(controlsBox?.x).toBeCloseTo((nodeBox?.x ?? 0) + 8, 0); + + await controls.getByRole("button", { name: /^Set language/ }).click(); + const popup = page.locator(".emdash-inline-code-block-popover"); + const popupBox = await popup.boundingBox(); + expect(popupBox).not.toBeNull(); + expect(popupBox?.x ?? -1).toBeGreaterThanOrEqual(0); + expect((popupBox?.x ?? 0) + (popupBox?.width ?? 0)).toBeLessThanOrEqual(320); + expect(popupBox?.width ?? 0).toBeLessThanOrEqual(288); + await expect(page.getByPlaceholder("Search for a language…")).toHaveCSS("font-size", "16px"); + await expect(page.getByRole("option", { name: "Plain text" })).toHaveCSS("font-size", "14px"); + }); + test("updates system and site theme colors without remounting or saving", async ({ page }) => { const editor = page.locator(".emdash-inline-editor"); const editorHandle = await editor.elementHandle(); @@ -216,3 +344,34 @@ test.describe("Inline code block highlighting", () => { expect(updateRequests).toBe(0); }); }); + +test("keeps inline code controls visible on touch devices", async ({ browser, baseURL }) => { + if (!baseURL) throw new Error("Playwright baseURL is required"); + const context = await browser.newContext({ + baseURL, + hasTouch: true, + viewport: { width: 393, height: 852 }, + }); + try { + const page = await context.newPage(); + await page.goto("/_emdash/api/auth/dev-bypass?redirect=/"); + await context.addCookies([ + { + name: "emdash-edit-mode", + value: "true", + domain: "localhost", + path: "/", + }, + ]); + await page.goto("/posts/post-with-code"); + await expect(page.locator(".emdash-inline-editor")).toBeVisible({ timeout: 15000 }); + const controlsWrap = page + .locator(".emdash-inline-code-block") + .first() + .locator(".emdash-inline-code-block-controls-wrap"); + await expect(controlsWrap).toHaveCSS("opacity", "1"); + await expect(controlsWrap).toHaveCSS("pointer-events", "auto"); + } finally { + await context.close(); + } +}); diff --git a/packages/core/src/components/InlinePortableTextEditor.tsx b/packages/core/src/components/InlinePortableTextEditor.tsx index d47c07c5e5..3b5fceb55f 100644 --- a/packages/core/src/components/InlinePortableTextEditor.tsx +++ b/packages/core/src/components/InlinePortableTextEditor.tsx @@ -2284,7 +2284,9 @@ export function InlinePortableTextEditor({ onSelect={handleMediaSelect} />
diff --git a/e2e/tests/code-block-highlighting.spec.ts b/e2e/tests/code-block-highlighting.spec.ts index 9753bc14f1..0331a95a38 100644 --- a/e2e/tests/code-block-highlighting.spec.ts +++ b/e2e/tests/code-block-highlighting.spec.ts @@ -1,5 +1,30 @@ +import type { Locator, Page } from "@playwright/test"; + import { test, expect } from "../fixtures"; +async function expectInside(outer: Locator, inner: Locator) { + await expect(outer).toBeVisible(); + await expect(inner).toBeVisible(); + const [outerBox, innerBox] = await Promise.all([outer.boundingBox(), inner.boundingBox()]); + expect(outerBox).not.toBeNull(); + expect(innerBox).not.toBeNull(); + if (!outerBox || !innerBox) return; + expect(innerBox.x).toBeGreaterThanOrEqual(outerBox.x); + expect(innerBox.x + innerBox.width).toBeLessThanOrEqual(outerBox.x + outerBox.width); +} +async function emulateCoarsePointer(page: Page) { + const cdp = await page.context().newCDPSession(page); + await cdp.send("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 1 }); + await cdp.send("Emulation.setEmulatedMedia", { + features: [ + { name: "hover", value: "none" }, + { name: "pointer", value: "coarse" }, + ], + }); + expect(await page.evaluate(() => matchMedia("(pointer: coarse)").matches)).toBe(true); + await page.mouse.move(0, 0); +} + test.describe("Admin code block highlighting", () => { test.beforeEach(async ({ admin }) => { await admin.devBypassAuth(); @@ -16,6 +41,33 @@ test.describe("Admin code block highlighting", () => { await expect(codeBlocks).toHaveCount(2); await expect(codeBlocks.nth(0).locator('span[class*="hljs-"]')).not.toHaveCount(0); await expect(codeBlocks.nth(1).locator('span[class*="hljs-"]')).toHaveCount(0); + const node = admin.page.locator(".emdash-code-block-node").first(); + await node.hover(); + await admin.page.context().grantPermissions(["clipboard-read", "clipboard-write"]); + await node.getByRole("button", { name: "Copy code" }).click(); + await expect(node.getByRole("status")).toHaveText("Copied"); + const clipboardText = await admin.page.evaluate(() => navigator.clipboard.readText()); + expect(clipboardText).toContain("const greeting"); + await admin.page.setViewportSize({ width: 200, height: 600 }); + await node.evaluate((element) => { + element.setAttribute("dir", "rtl"); + element.style.inlineSize = "168px"; + }); + const language = node.getByRole("button", { name: /^Set language/ }); + const copy = node.getByRole("button", { name: "Copy code" }); + await language.focus(); + await admin.page.keyboard.press("ArrowLeft"); + await expect(copy).toBeFocused(); + await language.click(); + await admin.page.getByPlaceholder("Language").fill("very-long-custom-language-name"); + await admin.page.keyboard.press("Enter"); + await expect( + node.getByRole("button", { name: /very-long-custom-language-name/ }), + ).toBeVisible(); + await expectInside(node, language); + await expectInside(node, copy); + await emulateCoarsePointer(admin.page); + await expect(node.locator(".emdash-code-block-controls")).toHaveCSS("opacity", "1"); }); test("uses borderless code surfaces in light and dark appearances", async ({ admin }) => { @@ -29,145 +81,6 @@ test.describe("Admin code block highlighting", () => { await expect(codeBlock).toHaveCSS("background-color", "rgb(32, 32, 32)"); await expect(codeBlock).toHaveCSS("border-top-width", "0px"); }); - - test("shows aligned two-action controls and applies their actions", async ({ admin }) => { - const codeBlockNode = admin.page.locator(".emdash-code-block-node").first(); - const controls = codeBlockNode.getByRole("toolbar", { name: "Code block actions" }); - const languageButton = controls.getByRole("button", { name: /^Set language/ }); - const copyButton = controls.getByRole("button", { name: "Copy code" }); - let updateRequests = 0; - admin.page.on("request", (request) => { - if (request.method() === "PUT" && request.url().includes("/_emdash/api/content/")) { - updateRequests += 1; - } - }); - await admin.page.waitForTimeout(2200); - updateRequests = 0; - - await admin.page.mouse.move(0, 0); - await expect(controls).toHaveCSS("opacity", "0"); - await languageButton.focus(); - await expect(controls).toHaveCSS("opacity", "1"); - await codeBlockNode.hover(); - await expect(controls).toHaveCSS("opacity", "1"); - await expect(controls.getByRole("button")).toHaveCount(2); - await expect(copyButton).toHaveAttribute("data-kumo-component", "Toolbar.Button"); - - const nodeBox = await codeBlockNode.boundingBox(); - const controlsBox = await controls.boundingBox(); - const languageBox = await languageButton.boundingBox(); - const codeText = await codeBlockNode.locator("code").evaluate((element) => { - const range = document.createRange(); - range.selectNodeContents(element); - const rects = [...range.getClientRects()]; - return { - top: rects[0]?.top, - bottom: rects.at(-1)?.bottom, - fontSize: parseFloat(getComputedStyle(element).fontSize), - }; - }); - const languageFontSize = await languageButton.evaluate((element) => - parseFloat(getComputedStyle(element).fontSize), - ); - const copyFontSize = await copyButton.evaluate((element) => - parseFloat(getComputedStyle(element).fontSize), - ); - expect(nodeBox).not.toBeNull(); - expect(controlsBox).not.toBeNull(); - expect(languageBox?.height).toBe(26); - expect(languageFontSize).toBe(codeText.fontSize); - expect(copyFontSize).toBe(codeText.fontSize); - expect(controlsBox?.y).toBeCloseTo((nodeBox?.y ?? 0) + 4, 0); - expect(controlsBox?.x).toBeCloseTo( - (nodeBox?.x ?? 0) + (nodeBox?.width ?? 0) - (controlsBox?.width ?? 0) - 4, - 0, - ); - expect(codeText.top ?? 0).toBeGreaterThanOrEqual( - (controlsBox?.y ?? 0) + (controlsBox?.height ?? 0), - ); - const topGap = (codeText.top ?? 0) - (nodeBox?.y ?? 0); - const bottomGap = (nodeBox?.y ?? 0) + (nodeBox?.height ?? 0) - (codeText.bottom ?? 0); - expect(Math.abs(topGap - bottomGap)).toBeLessThanOrEqual(1); - - const currentLanguageLabel = await languageButton.getAttribute("aria-label"); - const nextLanguage = currentLanguageLabel?.includes("Python") ? "JavaScript" : "Python"; - await languageButton.click(); - await admin.page.mouse.move(0, 0); - await expect(controls).toHaveCSS("opacity", "1"); - const input = admin.page.getByPlaceholder("Search for a language…"); - const popup = admin.page.locator(".kumo-popover-popup"); - await expect(input).toBeVisible(); - await expect(input).toHaveCSS("font-size", "14px"); - await expect(admin.page.getByRole("option", { name: "Plain text" })).toHaveCSS( - "font-size", - "14px", - ); - await popup.evaluate(async (element) => { - await Promise.all(element.getAnimations().map((animation) => animation.finished)); - }); - const placeholderColor = await input.evaluate( - (element) => getComputedStyle(element, "::placeholder").color, - ); - const inputColor = await input.evaluate((element) => getComputedStyle(element).color); - expect(placeholderColor).not.toBe(inputColor); - - const popupBox = await popup.boundingBox(); - const openControlsBox = await controls.boundingBox(); - expect(popupBox?.x).toBeCloseTo(openControlsBox?.x ?? 0, 0); - const popupBelowGap = - (popupBox?.y ?? 0) - ((openControlsBox?.y ?? 0) + (openControlsBox?.height ?? 0)); - const popupAboveGap = - (openControlsBox?.y ?? 0) - ((popupBox?.y ?? 0) + (popupBox?.height ?? 0)); - expect(Math.max(popupBelowGap, popupAboveGap)).toBeCloseTo(8, 0); - - await admin.page.keyboard.press("Escape"); - await expect(input).toBeHidden(); - await copyButton.hover(); - await expect( - admin.page.locator(".kumo-tooltip-popup").filter({ hasText: "Copy code" }), - ).toBeVisible(); - await expect(admin.page.locator(".kumo-tooltip-popup")).toHaveCount(1); - await admin.page.context().grantPermissions(["clipboard-read", "clipboard-write"]); - await copyButton.click(); - await expect(controls.getByRole("button", { name: "Copied" })).toBeVisible(); - await admin.page.waitForTimeout(2200); - expect(updateRequests).toBe(0); - - await languageButton.click(); - const autosaveResponse = admin.page.waitForResponse( - (response) => - response.request().method() === "PUT" && response.url().includes("/_emdash/api/content/"), - { timeout: 5000 }, - ); - await admin.page.getByRole("option", { name: nextLanguage }).click(); - await expect( - controls.getByRole("button", { name: `Set language (current: ${nextLanguage})` }), - ).toBeVisible(); - await autosaveResponse; - expect(updateRequests).toBe(1); - }); - - test("keeps controls and language search inside a narrow RTL editor", async ({ admin }) => { - await admin.page.setViewportSize({ width: 320, height: 800 }); - await admin.page.evaluate(() => { - document.querySelector(".emdash-code-block-node")?.setAttribute("dir", "rtl"); - }); - const codeBlockNode = admin.page.locator(".emdash-code-block-node").first(); - const controls = codeBlockNode.getByRole("toolbar", { name: "Code block actions" }); - await codeBlockNode.hover(); - await expect(controls).toBeVisible(); - - const nodeBox = await codeBlockNode.boundingBox(); - const controlsBox = await controls.boundingBox(); - expect(controlsBox?.x).toBeCloseTo((nodeBox?.x ?? 0) + 4, 0); - - await controls.getByRole("button", { name: /^Set language/ }).click(); - const popupBox = await admin.page.locator(".kumo-popover-popup").boundingBox(); - expect(popupBox).not.toBeNull(); - expect(popupBox?.x ?? -1).toBeGreaterThanOrEqual(0); - expect((popupBox?.x ?? 0) + (popupBox?.width ?? 0)).toBeLessThanOrEqual(320); - expect(popupBox?.width ?? 0).toBeLessThanOrEqual(288); - }); }); test("keeps public code block rendering unchanged", async ({ page }) => { @@ -202,178 +115,6 @@ test.describe("Inline code block highlighting", () => { await expect(codeBlocks.nth(1).locator('span[class*="hljs-"]')).toHaveCount(0); }); - test("matches the admin controls without saving during control interactions", async ({ - page, - }) => { - const codeBlockNode = page.locator(".emdash-inline-code-block").first(); - const controlsWrap = codeBlockNode.locator(".emdash-inline-code-block-controls-wrap"); - const controls = codeBlockNode.getByRole("toolbar", { name: "Code block actions" }); - const languageButton = controls.getByRole("button", { name: /^Set language/ }); - const copyButton = controls.getByRole("button", { name: "Copy code" }); - let updateRequests = 0; - page.on("request", (request) => { - if (request.method() === "PUT" && request.url().includes("/_emdash/api/content/")) { - updateRequests += 1; - } - }); - await page.waitForTimeout(2200); - updateRequests = 0; - await page.emulateMedia({ colorScheme: "dark" }); - const outsideControlStyle = await page.evaluate(() => { - const outside = document.createElement("div"); - outside.className = "emdash-inline-code-block-controls-wrap"; - document.body.append(outside); - const style = getComputedStyle(outside); - const result = { - opacity: style.opacity, - pointerEvents: style.pointerEvents, - position: style.position, - }; - outside.remove(); - return result; - }); - expect(outsideControlStyle).toEqual({ - opacity: "1", - pointerEvents: "auto", - position: "static", - }); - - await page.mouse.move(0, 0); - await page.evaluate(() => { - if (document.activeElement instanceof HTMLElement) document.activeElement.blur(); - }); - await expect(controlsWrap).toHaveCSS("opacity", "0"); - await languageButton.focus(); - await expect(controlsWrap).toHaveCSS("opacity", "1"); - await codeBlockNode.hover(); - await expect(controlsWrap).toHaveCSS("opacity", "1"); - await expect(controls.getByRole("button")).toHaveCount(2); - await expect(controls).toHaveCSS("background-color", "rgb(24, 24, 24)"); - - const nodeBox = await codeBlockNode.boundingBox(); - const controlsBox = await controls.boundingBox(); - const languageBox = await languageButton.boundingBox(); - const codeText = await codeBlockNode.locator("code").evaluate((element) => { - const range = document.createRange(); - range.selectNodeContents(element); - const rects = [...range.getClientRects()]; - return { - top: rects[0]?.top, - bottom: rects.at(-1)?.bottom, - fontSize: parseFloat(getComputedStyle(element).fontSize), - }; - }); - const languageFontSize = await languageButton.evaluate((element) => - parseFloat(getComputedStyle(element).fontSize), - ); - const copyFontSize = await copyButton.evaluate((element) => - parseFloat(getComputedStyle(element).fontSize), - ); - expect(nodeBox).not.toBeNull(); - expect(controlsBox).not.toBeNull(); - expect(languageBox?.height).toBe(26); - expect(languageFontSize).toBe(codeText.fontSize); - expect(copyFontSize).toBe(codeText.fontSize); - expect(controlsBox?.y).toBeCloseTo((nodeBox?.y ?? 0) + 4, 0); - expect(controlsBox?.x).toBeCloseTo( - (nodeBox?.x ?? 0) + (nodeBox?.width ?? 0) - (controlsBox?.width ?? 0) - 4, - 0, - ); - expect(codeText.top ?? 0).toBeGreaterThanOrEqual( - (controlsBox?.y ?? 0) + (controlsBox?.height ?? 0), - ); - const topGap = (codeText.top ?? 0) - (nodeBox?.y ?? 0); - const bottomGap = (nodeBox?.y ?? 0) + (nodeBox?.height ?? 0) - (codeText.bottom ?? 0); - expect(Math.abs(topGap - bottomGap)).toBeLessThanOrEqual(1); - - await languageButton.click(); - await page.mouse.move(0, 0); - await expect(controlsWrap).toHaveCSS("opacity", "1"); - const input = page.getByPlaceholder("Search for a language…"); - const popup = page.locator(".emdash-inline-code-block-popover"); - await expect(input).toBeVisible(); - await expect(input).toBeFocused(); - await expect(input).toHaveCSS("font-size", "14px"); - await expect(page.getByRole("option", { name: "Plain text" })).toHaveCSS("font-size", "14px"); - const placeholderColor = await input.evaluate( - (element) => getComputedStyle(element, "::placeholder").color, - ); - const inputColor = await input.evaluate((element) => getComputedStyle(element).color); - expect(placeholderColor).not.toBe(inputColor); - - const popupBox = await popup.boundingBox(); - const openControlsBox = await controls.boundingBox(); - const viewportWidth = await page.evaluate(() => window.innerWidth); - const expectedPopupX = Math.min( - Math.max(openControlsBox?.x ?? 0, 16), - viewportWidth - 16 - (popupBox?.width ?? 0), - ); - expect(popupBox?.x).toBeCloseTo(expectedPopupX, 0); - const popupBelowGap = - (popupBox?.y ?? 0) - ((openControlsBox?.y ?? 0) + (openControlsBox?.height ?? 0)); - const popupAboveGap = - (openControlsBox?.y ?? 0) - ((popupBox?.y ?? 0) + (popupBox?.height ?? 0)); - expect(Math.max(popupBelowGap, popupAboveGap)).toBeCloseTo(8, 0); - - await input.fill("yaml"); - await expect(page.getByRole("option", { name: "YAML" })).toBeVisible(); - await expect - .poll(async () => { - const filteredPopupBox = await popup.boundingBox(); - const filteredControlsBox = await controls.boundingBox(); - const filteredBelowGap = - (filteredPopupBox?.y ?? 0) - - ((filteredControlsBox?.y ?? 0) + (filteredControlsBox?.height ?? 0)); - const filteredAboveGap = - (filteredControlsBox?.y ?? 0) - - ((filteredPopupBox?.y ?? 0) + (filteredPopupBox?.height ?? 0)); - return Math.max(filteredBelowGap, filteredAboveGap); - }) - .toBeCloseTo(8, 0); - await input.fill(""); - - await page.keyboard.press("Escape"); - await expect(input).toBeHidden(); - await expect(languageButton).toBeFocused(); - await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); - await copyButton.click(); - await expect(controls.getByRole("button", { name: "Copied" })).toBeVisible(); - - await languageButton.click(); - await page.getByRole("option", { name: "Python" }).click(); - await expect( - controls.getByRole("button", { name: "Set language (current: Python)" }), - ).toBeVisible(); - await page.waitForTimeout(500); - expect(updateRequests).toBe(0); - }); - - test("keeps inline controls and search inside a narrow RTL viewport", async ({ page }) => { - await page.setViewportSize({ width: 320, height: 800 }); - await page.evaluate(() => { - document.querySelector(".emdash-inline-code-block")?.setAttribute("dir", "rtl"); - }); - const codeBlockNode = page.locator(".emdash-inline-code-block").first(); - const controlsWrap = codeBlockNode.locator(".emdash-inline-code-block-controls-wrap"); - const controls = codeBlockNode.getByRole("toolbar", { name: "Code block actions" }); - await codeBlockNode.hover(); - await expect(controlsWrap).toHaveCSS("opacity", "1"); - - const nodeBox = await codeBlockNode.boundingBox(); - const controlsBox = await controls.boundingBox(); - expect(controlsBox?.x).toBeCloseTo((nodeBox?.x ?? 0) + 4, 0); - - await controls.getByRole("button", { name: /^Set language/ }).click(); - const popup = page.locator(".emdash-inline-code-block-popover"); - const popupBox = await popup.boundingBox(); - expect(popupBox).not.toBeNull(); - expect(popupBox?.x ?? -1).toBeGreaterThanOrEqual(0); - expect((popupBox?.x ?? 0) + (popupBox?.width ?? 0)).toBeLessThanOrEqual(320); - expect(popupBox?.width ?? 0).toBeLessThanOrEqual(288); - await expect(page.getByPlaceholder("Search for a language…")).toHaveCSS("font-size", "16px"); - await expect(page.getByRole("option", { name: "Plain text" })).toHaveCSS("font-size", "14px"); - }); - test("updates system and site theme colors without remounting or saving", async ({ page }) => { const editor = page.locator(".emdash-inline-editor"); const editorHandle = await editor.elementHandle(); @@ -411,38 +152,81 @@ test.describe("Inline code block highlighting", () => { await expect(codeBlock).toHaveCSS("background-color", "rgb(25, 35, 45)"); await expect(codeBlock).toHaveCSS("color", "rgb(245, 245, 245)"); + await expect(codeBlock.locator("code")).toHaveCSS("background-color", "rgba(0, 0, 0, 0)"); + await expect(codeBlock.locator("code")).toHaveCSS("color", "rgb(245, 245, 245)"); + await expect(codeBlock.locator("code")).toHaveCSS("font-size", "13px"); expect(await editorHandle?.evaluate((element) => element.isConnected)).toBe(true); expect(updateRequests).toBe(0); }); -}); -test("keeps inline code controls visible on touch devices", async ({ browser, baseURL }) => { - if (!baseURL) throw new Error("Playwright baseURL is required"); - const context = await browser.newContext({ - baseURL, - hasTouch: true, - viewport: { width: 393, height: 852 }, + test("keeps controls usable in narrow RTL and does not save on copy", async ({ page }) => { + await page.setViewportSize({ width: 200, height: 600 }); + const node = page.locator(".emdash-inline-code-block").first(); + await node.evaluate((element) => element.setAttribute("dir", "rtl")); + const language = node.getByRole("button", { name: /^Set language/ }); + const copy = node.getByRole("button", { name: "Copy code" }); + await node.hover(); + await language.focus(); + await page.keyboard.press("Tab"); + await expect(copy).toBeFocused(); + await language.click(); + await expectInside(node, node.locator(".emdash-inline-code-block-popover")); + const input = node.getByRole("combobox", { name: "Language" }); + await input.fill("very-long-custom-language-name"); + await page.keyboard.press("Enter"); + await expect( + node.getByRole("button", { name: /very-long-custom-language-name/ }), + ).toBeVisible(); + await expectInside(node, language); + await expectInside(node, copy); + let updates = 0; + page.on("request", (request) => { + if (request.method() === "PUT" && request.url().includes("/_emdash/api/content/")) updates++; + }); + await page.waitForTimeout(500); + await node.locator("code").click(); + await page.keyboard.press("End"); + await page.keyboard.type("x"); + await page.keyboard.press("Shift+Home"); + const selectionBeforeCopy = await page.evaluate(() => document.getSelection()?.toString()); + const codeBeforeCopy = await node.locator("code").innerText(); + expect(selectionBeforeCopy).not.toBe(""); + updates = 0; + await page.evaluate(() => { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText: () => Promise.reject(new DOMException("Denied", "NotAllowedError")) }, + }); + document.execCommand = (command) => { + (window as Window & { __legacyCopy?: { command: string; value: string } }).__legacyCopy = { + command, + value: (document.activeElement as HTMLTextAreaElement).value, + }; + return true; + }; + }); + await copy.click(); + await expect(node.getByRole("status")).toHaveText("Copied"); + expect( + await page.evaluate( + () => + (window as Window & { __legacyCopy?: { command: string; value: string } }).__legacyCopy, + ), + ).toEqual({ command: "copy", value: codeBeforeCopy }); + expect(await page.evaluate(() => document.getSelection()?.toString())).toBe( + selectionBeforeCopy, + ); + expect( + await page.evaluate(() => document.activeElement?.classList.contains("ProseMirror")), + ).toBe(true); + await page.waitForTimeout(1000); + await copy.click(); + await page.waitForTimeout(600); + await expect(node.getByRole("status")).toHaveText("Copied"); + await page.waitForTimeout(900); + await expect(node.getByRole("status")).toHaveText(""); + expect(updates).toBe(0); + await emulateCoarsePointer(page); + await expect(node.locator(".emdash-inline-code-block-controls-wrap")).toHaveCSS("opacity", "1"); }); - try { - const page = await context.newPage(); - await page.goto("/_emdash/api/auth/dev-bypass?redirect=/"); - await context.addCookies([ - { - name: "emdash-edit-mode", - value: "true", - domain: "localhost", - path: "/", - }, - ]); - await page.goto("/posts/post-with-code"); - await expect(page.locator(".emdash-inline-editor")).toBeVisible({ timeout: 15000 }); - const controlsWrap = page - .locator(".emdash-inline-code-block") - .first() - .locator(".emdash-inline-code-block-controls-wrap"); - await expect(controlsWrap).toHaveCSS("opacity", "1"); - await expect(controlsWrap).toHaveCSS("pointer-events", "auto"); - } finally { - await context.close(); - } }); diff --git a/packages/admin/src/components/editor/CodeBlockNode.tsx b/packages/admin/src/components/editor/CodeBlockNode.tsx index 757423cbca..20af156ed3 100644 --- a/packages/admin/src/components/editor/CodeBlockNode.tsx +++ b/packages/admin/src/components/editor/CodeBlockNode.tsx @@ -2,23 +2,31 @@ * Code block node with language picker. * * Wraps the Lowlight code block with a React node view that - * overlays a Kumo action toolbar at the logical end of the block. The toolbar - * opens a searchable language popover and copies the raw code. The selected - * language is persisted on the node's `language` attribute and round-trips - * through Portable Text as `block.language`. + * overlays a small language chip in the top-right corner. Clicking the chip + * opens a popover with a Kumo Autocomplete: a free-form text input plus a + * filtered list of curated language suggestions. The value is persisted on + * the node's `language` attribute and round-trips through Portable Text as + * `block.language`. * * The picker accepts arbitrary strings (not restricted to the curated list) * so that less common languages can still be used. Free-form input is * sanitized to a single safe CSS class token via `normalizeLanguage` so the * frontend's `language-{id}` class stays well-formed. * - * Kumo's `Popover` portals the search input out of the contentEditable DOM so - * ProseMirror does not interpret input typing as an editor selection change. + * The popover content is rendered through Kumo's `Popover`, which portals it + * out of the editor's contentEditable DOM. That portal is load-bearing, not + * cosmetic: a code block is a non-atom ProseMirror node with live editable + * content, so if the picker's text input lived inside the node view, typing + * would move the DOM selection into it. ProseMirror reads that selection, + * dispatches a selection-correcting transaction, and the resulting node-view + * redraw recreates this React component mid-edit, tearing the picker down -- + * the "language picker loses focus and closes when you type" bug (issue + * #1200). Keeping the input outside the editor DOM avoids it entirely. */ -import { CommandPalette, Popover, Toolbar, Tooltip, TooltipProvider } from "@cloudflare/kumo"; +import { Autocomplete, Button, Popover, Toolbar, Tooltip, TooltipProvider } from "@cloudflare/kumo"; import { useLingui } from "@lingui/react/macro"; -import { CaretDown, Check, Copy } from "@phosphor-icons/react"; +import { CaretDown, Check, Copy, X } from "@phosphor-icons/react"; import { CodeBlockLowlight } from "@tiptap/extension-code-block-lowlight"; import type { NodeViewProps } from "@tiptap/react"; import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer } from "@tiptap/react"; @@ -59,12 +67,6 @@ const editorLowlight = { }, }; -interface LanguageItem { - id: string; - label: string; - aliases?: string[]; -} - async function copyTextToClipboard(text: string): Promise { if (navigator.clipboard?.writeText) { try { @@ -72,37 +74,31 @@ async function copyTextToClipboard(text: string): Promise { return; } catch {} } - const activeElement = document.activeElement; + const selection = document.getSelection(); + const previousRange = selection?.rangeCount ? selection.getRangeAt(0).cloneRange() : null; const textarea = document.createElement("textarea"); textarea.value = text; textarea.readOnly = true; textarea.style.position = "fixed"; textarea.style.opacity = "0"; document.body.append(textarea); - const selection = document.getSelection(); - const previousRange = selection?.rangeCount ? selection.getRangeAt(0) : null; textarea.select(); try { if (!document.execCommand("copy")) throw new Error("Clipboard copy failed"); } finally { textarea.remove(); + if (activeElement instanceof HTMLElement && activeElement.isConnected) activeElement.focus(); if (previousRange) { selection?.removeAllRanges(); selection?.addRange(previousRange); } - if (activeElement instanceof HTMLElement && activeElement.isConnected) { - activeElement.focus(); - } } } - function CodeBlockNodeView({ node, updateAttributes }: NodeViewProps) { const { t } = useLingui(); const [isEditing, setIsEditing] = React.useState(false); const [copied, setCopied] = React.useState(false); - const [keyboardHighlightedLanguage, setKeyboardHighlightedLanguage] = - React.useState(null); const copyResetTimer = React.useRef | null>(null); const storedLanguage = typeof node.attrs.language === "string" ? node.attrs.language : ""; @@ -115,33 +111,27 @@ function CodeBlockNodeView({ node, updateAttributes }: NodeViewProps) { ); const languageItems = React.useMemo( - () => - CODE_BLOCK_LANGUAGES.map((language) => ({ - id: language.id, - label: t(language.label), - aliases: language.aliases, - })), + () => CODE_BLOCK_LANGUAGES.map((language) => t(language.label)), [t], ); const findLanguageByDisplayLabel = React.useCallback( - (label: string) => languageItems.find((language) => language.label === label), - [languageItems], + (label: string) => CODE_BLOCK_LANGUAGES.find((language) => t(language.label) === label), + [t], ); - const filterLanguages = React.useCallback((item: LanguageItem, query: string) => { - if (!query) return true; - const searchText = query.toLowerCase(); - if (item.label.toLowerCase().includes(searchText)) return true; - if (item.id.toLowerCase().includes(searchText)) return true; - return item.aliases?.some((alias) => alias.toLowerCase().includes(searchText)) ?? false; - }, []); + const filterLanguages = React.useCallback( + (item: string, query: string) => { + if (!query) return true; + const searchText = query.toLowerCase(); + const lang = findLanguageByDisplayLabel(item); + if (!lang) return false; - React.useEffect( - () => () => { - if (copyResetTimer.current) clearTimeout(copyResetTimer.current); + if (t(lang.label).toLowerCase().includes(searchText)) return true; + if (lang.id.toLowerCase().includes(searchText)) return true; + return lang.aliases?.some((alias) => alias.toLowerCase().includes(searchText)) ?? false; }, - [], + [findLanguageByDisplayLabel, t], ); const [draft, setDraft] = React.useState(() => labelText(storedLanguage)); @@ -156,14 +146,12 @@ function CodeBlockNodeView({ node, updateAttributes }: NodeViewProps) { }, [storedLanguage, isEditing, labelText]); const openPicker = React.useCallback(() => { - setDraft(""); - setKeyboardHighlightedLanguage(null); + setDraft(storedLanguage ? labelText(storedLanguage) : ""); setIsEditing(true); - }, []); + }, [storedLanguage, labelText]); const closePicker = React.useCallback(() => { setIsEditing(false); - setKeyboardHighlightedLanguage(null); setDraft(labelText(storedLanguage)); }, [storedLanguage, labelText]); @@ -174,23 +162,18 @@ function CodeBlockNodeView({ node, updateAttributes }: NodeViewProps) { const next = selectedLanguage?.id ?? normalizeLanguage(raw); updateAttributes({ language: next ?? null }); setIsEditing(false); - setKeyboardHighlightedLanguage(null); }, [draft, findLanguageByDisplayLabel, updateAttributes], ); - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Escape") { - e.preventDefault(); - closePicker(); - return; - } - if (e.key === "Enter" && !keyboardHighlightedLanguage) { + // Enter commits the current draft. Escape is handled by the Popover itself + // (it calls onOpenChange(false) -> closePicker). + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { e.preventDefault(); commit(); } }; - const copyCode = React.useCallback(async () => { try { await copyTextToClipboard(node.textContent); @@ -201,10 +184,14 @@ function CodeBlockNodeView({ node, updateAttributes }: NodeViewProps) { setCopied(false); } }, [node.textContent]); + React.useEffect( + () => () => { + if (copyResetTimer.current) clearTimeout(copyResetTimer.current); + }, + [], + ); const label = labelText(storedLanguage); - const currentLanguageId = normalizeLanguage(storedLanguage); - const controlsPersistent = isEditing || copied; return ( as="code" /> -
+
(open ? openPicker() : closePicker())} @@ -223,19 +214,21 @@ function CodeBlockNodeView({ node, updateAttributes }: NodeViewProps) { event.preventDefault()} aria-label={t`Set language (current: ${label})`} > - {label} -
diff --git a/packages/admin/src/styles.css b/packages/admin/src/styles.css index 781c64e788..bd684ef48d 100644 --- a/packages/admin/src/styles.css +++ b/packages/admin/src/styles.css @@ -309,27 +309,23 @@ body { pointer-events: none; transition: opacity 120ms ease-out; } - .emdash-code-block-node:hover .emdash-code-block-controls, .emdash-code-block-node:focus-within .emdash-code-block-controls, .emdash-code-block-controls[data-persistent="true"] { opacity: 1; pointer-events: auto; } - @media (hover: none), (pointer: coarse) { .emdash-code-block-controls { opacity: 1; pointer-events: auto; } } - @media (prefers-reduced-motion: reduce) { .emdash-code-block-controls { transition: none; } } - /** * TipTap placeholder styles */ diff --git a/packages/admin/tests/editor/PortableTextEditor.test.tsx b/packages/admin/tests/editor/PortableTextEditor.test.tsx index 163ceb6a99..8a21004128 100644 --- a/packages/admin/tests/editor/PortableTextEditor.test.tsx +++ b/packages/admin/tests/editor/PortableTextEditor.test.tsx @@ -9,7 +9,6 @@ import type { Editor } from "@tiptap/react"; import * as React from "react"; import { describe, it, expect, vi } from "vitest"; -import { userEvent } from "vitest/browser"; import type { PluginBlockDef } from "../../src/components/PortableTextEditor"; import { @@ -1274,111 +1273,84 @@ describe("onChange output shape", () => { }); }); -describe("Code block controls", () => { - it("uses a two-action toolbar and selects a language immediately", async () => { - const { screen, editor } = await renderAndGetEditor({ - value: [{ _type: "code", _key: "code", code: "print('hello')", language: "python" }], - }); - - const toolbar = screen.getByRole("toolbar", { name: "Code block actions" }); - await expect.element(toolbar).toBeInTheDocument(); - expect(toolbar.element().querySelectorAll("button")).toHaveLength(2); - await expect.element(toolbar.getByRole("button", { name: "Copy code" })).toBeInTheDocument(); - - await toolbar.getByRole("button", { name: "Set language (current: Python)" }).click(); - const input = screen.getByPlaceholder("Search for a language…"); - await expect.element(input).toBeInTheDocument(); - await screen.getByRole("option", { name: "JavaScript" }).click(); - - await vi.waitFor(() => { - const node = editor.getJSON().content?.find((item) => item.type === "codeBlock"); - expect(node?.attrs?.language).toBe("javascript"); - }); - await expect.element(input).not.toBeInTheDocument(); - }); - - it("copies the raw code and exposes copied feedback", async () => { +describe("Code block copy action", () => { + it("copies raw code and resets its accessible feedback", async () => { const clipboardWrite = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(); - const { screen } = await renderAndGetEditor({ - value: [ - { - _type: "code", - _key: "code", - code: "const greeting = 'hello';", - language: "javascript", - }, - ], - }); - - await screen.getByRole("button", { name: "Copy code" }).click(); - await vi.waitFor(() => { - expect(clipboardWrite).toHaveBeenCalledWith("const greeting = 'hello';"); - }); - await expect.element(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument(); - await expect.element(screen.getByRole("status")).toHaveTextContent("Copied"); - clipboardWrite.mockRestore(); - }); - - it("falls back to document copy when the Clipboard API is unavailable", async () => { - const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, "clipboard"); - const copyCommand = vi.spyOn(document, "execCommand").mockReturnValue(true); - Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined }); try { const { screen } = await renderAndGetEditor({ value: [ { _type: "code", _key: "code", - code: "const fallback = true;", + code: "const greeting = 'hello';", language: "javascript", }, ], }); - - await screen.getByRole("button", { name: "Copy code" }).click(); + await expect + .element(screen.getByRole("button", { name: "Set language (current: JavaScript)" })) + .toBeInTheDocument(); + const copyButton = screen.getByRole("button", { name: "Copy code" }); + await expect.element(copyButton).toBeInTheDocument(); + vi.useFakeTimers(); + await copyButton.click(); await vi.waitFor(() => { - expect(copyCommand).toHaveBeenCalledWith("copy"); + expect(clipboardWrite).toHaveBeenCalledWith("const greeting = 'hello';"); }); - await expect.element(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument(); - expect(document.querySelector("textarea[readonly]")).toBeNull(); + await expect.element(screen.getByRole("button", { name: "Copy code" })).toBeInTheDocument(); + await expect.element(screen.getByRole("status")).toHaveTextContent("Copied"); + await vi.advanceTimersByTimeAsync(1500); + await expect.element(screen.getByRole("status")).toHaveTextContent(""); } finally { - copyCommand.mockRestore(); - if (clipboardDescriptor) { - Object.defineProperty(navigator, "clipboard", clipboardDescriptor); - } else { - Reflect.deleteProperty(navigator, "clipboard"); - } + vi.useRealTimers(); + clipboardWrite.mockRestore(); } }); - it("falls back after a rejected Clipboard API write and keeps keyboard focus", async () => { + it("falls back after Clipboard API rejection and restores focus and selection", async () => { const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, "clipboard"); - const copyCommand = vi.spyOn(document, "execCommand").mockReturnValue(true); const clipboardWrite = vi.fn().mockRejectedValue(new DOMException("Denied", "NotAllowedError")); + const copyCommand = vi.spyOn(document, "execCommand").mockReturnValue(true); Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText: clipboardWrite }, }); try { - const { screen } = await renderAndGetEditor({ + const { screen, editor } = await renderAndGetEditor({ value: [ { _type: "code", _key: "code", - code: "const fallback = true;", - language: "javascript", + code: "first line\nsecond line", + language: "plaintext", }, ], }); - const copyButton = screen.getByRole("button", { name: "Copy code" }); - copyButton.element().focus(); + await expect.element(copyButton).toBeInTheDocument(); + editor.chain().focus().setTextSelection({ from: 3, to: 13 }).run(); + const selectionBeforeCopy = { + from: editor.state.selection.from, + to: editor.state.selection.to, + }; + await vi.waitFor(() => expect(document.getSelection()?.toString()).not.toBe("")); + const domSelection = document.getSelection(); + const rangeBeforeCopy = domSelection!.getRangeAt(0).cloneRange(); + const activeElement = document.activeElement; await copyButton.click(); await vi.waitFor(() => { - expect(clipboardWrite).toHaveBeenCalledWith("const fallback = true;"); + expect(clipboardWrite).toHaveBeenCalledWith("first line\nsecond line"); expect(copyCommand).toHaveBeenCalledWith("copy"); }); - await expect.element(copyButton).toHaveFocus(); + expect(document.activeElement).toBe(activeElement); + expect(editor.state.selection.from).toBe(selectionBeforeCopy.from); + expect(editor.state.selection.to).toBe(selectionBeforeCopy.to); + expect(domSelection?.toString()).not.toBe(""); + const rangeAfterCopy = domSelection!.getRangeAt(0); + expect(rangeAfterCopy.startContainer).toBe(rangeBeforeCopy.startContainer); + expect(rangeAfterCopy.startOffset).toBe(rangeBeforeCopy.startOffset); + expect(rangeAfterCopy.endContainer).toBe(rangeBeforeCopy.endContainer); + expect(rangeAfterCopy.endOffset).toBe(rangeBeforeCopy.endOffset); } finally { copyCommand.mockRestore(); if (clipboardDescriptor) { @@ -1389,26 +1361,36 @@ describe("Code block controls", () => { } }); - it("supports free-form Enter and closes the language search with Escape", async () => { + it("preserves alias, free-form, apply, and cancel behavior", async () => { const { screen, editor } = await renderAndGetEditor({ - value: [{ _type: "code", _key: "code", code: "custom()", language: "plaintext" }], - }); - const languageButton = screen.getByRole("button", { - name: "Set language (current: Plain text)", - }); - - await languageButton.click(); - let input = screen.getByPlaceholder("Search for a language…"); - await input.fill("Custom Language"); - await userEvent.keyboard("{Enter}"); - await vi.waitFor(() => { - const node = editor.getJSON().content?.find((item) => item.type === "codeBlock"); - expect(node?.attrs?.language).toBe("custom-language"); + value: [ + { + _type: "code", + _key: "code", + code: "custom()", + language: "plaintext", + }, + ], }); - - await screen.getByRole("button", { name: "Set language (current: custom-language)" }).click(); - input = screen.getByPlaceholder("Search for a language…"); - await userEvent.keyboard("{Escape}"); - await expect.element(input).not.toBeInTheDocument(); + const storedLanguage = () => + editor.getJSON().content?.find((item) => item.type === "codeBlock")?.attrs?.language; + const clickPickerAction = (label: "Apply language" | "Cancel") => { + const button = document.querySelector(`button[aria-label="${label}"]`); + expect(button).not.toBeNull(); + button?.click(); + }; + await screen.getByRole("button", { name: "Set language (current: Plain text)" }).click(); + await screen.getByPlaceholder("Language").fill("js"); + clickPickerAction("Apply language"); + await vi.waitFor(() => expect(storedLanguage()).toBe("javascript")); + await screen.getByRole("button", { name: "Set language (current: JavaScript)" }).click(); + await screen.getByPlaceholder("Language").fill("Discarded Language"); + clickPickerAction("Cancel"); + expect(storedLanguage()).toBe("javascript"); + + await screen.getByRole("button", { name: "Set language (current: JavaScript)" }).click(); + await screen.getByPlaceholder("Language").fill("Custom Language"); + clickPickerAction("Apply language"); + await vi.waitFor(() => expect(storedLanguage()).toBe("custom-language")); }); }); diff --git a/packages/core/src/components/InlinePortableTextEditor.tsx b/packages/core/src/components/InlinePortableTextEditor.tsx index d158cee2e3..998d0d9e34 100644 --- a/packages/core/src/components/InlinePortableTextEditor.tsx +++ b/packages/core/src/components/InlinePortableTextEditor.tsx @@ -2285,7 +2285,7 @@ export function InlinePortableTextEditor({ onSelect={handleMediaSelect} />