Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
bd61fcc
feat(editor): add admin code block controls
khoinguyenpham04 Aug 22, 2026
c26f3fe
feat(editor): match inline code block controls
khoinguyenpham04 Aug 22, 2026
4094467
fix(editor): keep inline code controls aligned and scoped
khoinguyenpham04 Aug 22, 2026
f73e3b0
fix(editor): refine code block overlay and copy controls
khoinguyenpham04 Aug 22, 2026
1ff9fff
fix(editor): harden clipboard fallback
khoinguyenpham04 Aug 22, 2026
be877c7
fix(editor): tighten code block spacing
khoinguyenpham04 Aug 22, 2026
7dbcade
fix(editor): compact language picker
khoinguyenpham04 Aug 22, 2026
b372b24
fix(editor): polish code block controls
khoinguyenpham04 Aug 22, 2026
d76774f
refactor(editor): simplify code block controls
khoinguyenpham04 Aug 23, 2026
52cb0ca
test(admin): preserve keyboard code controls
khoinguyenpham04 Aug 23, 2026
5bbe6c9
test(core): preserve keyboard code controls
khoinguyenpham04 Aug 23, 2026
9341402
docs(changeset): clarify code block controls
khoinguyenpham04 Aug 23, 2026
9451050
fix(admin): keep keyboard cancel from applying language
khoinguyenpham04 Aug 23, 2026
8176b4d
fix(editor): keep latest copy feedback
khoinguyenpham04 Aug 23, 2026
dc3d625
fix(editor): report copy failures
khoinguyenpham04 Aug 23, 2026
f8ecccf
test(editor): consolidate copy feedback coverage
khoinguyenpham04 Aug 23, 2026
50a1a19
style(test): format inline copy coverage
khoinguyenpham04 Aug 23, 2026
2951173
fix(editor): skip stale clipboard fallbacks
khoinguyenpham04 Aug 23, 2026
3786f10
style(editor): format clipboard guards
khoinguyenpham04 Aug 23, 2026
c833b7a
docs(changeset): clarify code block actions
khoinguyenpham04 Aug 23, 2026
8f36a31
Update .changeset/quiet-code-block-actions.md
khoinguyenpham04 Aug 24, 2026
f8ebd8f
fix(editor): smooth inline code controls
khoinguyenpham04 Aug 26, 2026
f78bc1b
test(e2e): focus code controls directly
khoinguyenpham04 Aug 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/quiet-code-block-actions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@emdash-cms/admin": patch
"emdash": patch
---

Adds one-click copy actions to code-block controls in the admin and inline visual editors, and polishes the layout so controls stay usable on narrow screens and in right-to-left locales.
Comment thread
khoinguyenpham04 marked this conversation as resolved.
11 changes: 11 additions & 0 deletions e2e/fixture/src/pages/posts/[slug].astro
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@ if (!post) return new Response("Not found", { status: 404 });
<html>
<head>
<title>{post.data.title}</title>
<style>
#body :global(pre) {
padding-block: 1.25rem;
border: 1px solid #d1d5db;
}
#body :global(pre), #body :global(pre code) {
background: #181818;
color: #ffffff;
font-size: 1rem;
}
</style>
</head>
<body>
<article>
Expand Down
152 changes: 152 additions & 0 deletions e2e/tests/code-block-highlighting.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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 }) => {
Expand All @@ -29,6 +81,19 @@ 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("reveals code controls when focus enters the toolbar", async ({ admin }) => {
const node = admin.page.locator(".emdash-code-block-node").first();
const controls = node.locator(".emdash-code-block-controls");
const language = node.getByRole("button", { name: /^Set language/ });

await admin.page.mouse.move(0, 0);
await expect(controls).toHaveCSS("opacity", "0");
await language.focus();

await expect(language).toBeFocused();
await expect(controls).toHaveCSS("opacity", "1");
});
});

test("keeps public code block rendering unchanged", async ({ page }) => {
Expand Down Expand Up @@ -100,7 +165,94 @@ 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("reveals inline code controls when focus enters them", async ({ page }) => {
const node = page.locator(".emdash-inline-code-block").first();
const controls = node.locator(".emdash-inline-code-block-controls-wrap");
const language = node.getByRole("button", { name: /^Set language/ });

await page.mouse.move(0, 0);
await expect(controls).toHaveCSS("opacity", "0");
await language.focus();

await expect(language).toBeFocused();
await expect(controls).toHaveCSS("opacity", "1");
});

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");
});
});
149 changes: 119 additions & 30 deletions packages/admin/src/components/editor/CodeBlockNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@
* #1200). Keeping the input outside the editor DOM avoids it entirely.
*/

import { Autocomplete, Button, Popover } from "@cloudflare/kumo";
import { Autocomplete, Button, Popover, Toolbar, Tooltip, TooltipProvider } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import { Check, X } 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";
Expand Down Expand Up @@ -67,9 +67,41 @@ const editorLowlight = {
},
};

function CodeBlockNodeView({ node, updateAttributes, selected }: NodeViewProps) {
async function copyTextToClipboard(text: string, shouldUseFallback: () => boolean): Promise<void> {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return;
} catch {}
}
if (!shouldUseFallback()) return;
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);
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);
}
}
}
function CodeBlockNodeView({ node, updateAttributes }: NodeViewProps) {
const { t } = useLingui();
const [isEditing, setIsEditing] = React.useState(false);
const [copyStatus, setCopyStatus] = React.useState<"idle" | "copied" | "failed">("idle");
const copyResetTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const copyRequestId = React.useRef(0);
const storedLanguage = typeof node.attrs.language === "string" ? node.attrs.language : "";

const labelText = React.useCallback(
Expand Down Expand Up @@ -136,50 +168,107 @@ function CodeBlockNodeView({ node, updateAttributes, selected }: NodeViewProps)
[draft, findLanguageByDisplayLabel, updateAttributes],
);

// Enter commits the current draft. Escape is handled by the Popover itself
// (it calls onOpenChange(false) -> closePicker).
// Enter in the autocomplete input commits the current draft. Escape is
// handled by the Popover itself (it calls onOpenChange(false) -> closePicker).
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter") {
if (e.key === "Enter" && e.target instanceof HTMLInputElement) {
e.preventDefault();
commit();
}
};
const copyCode = React.useCallback(async () => {
const requestId = ++copyRequestId.current;
setCopyStatus("idle");
try {
await copyTextToClipboard(node.textContent, () => requestId === copyRequestId.current);
if (requestId !== copyRequestId.current) return;
setCopyStatus("copied");
if (copyResetTimer.current) clearTimeout(copyResetTimer.current);
copyResetTimer.current = setTimeout(setCopyStatus, 1500, "idle");
} catch {
if (requestId !== copyRequestId.current) return;
if (copyResetTimer.current) clearTimeout(copyResetTimer.current);
setCopyStatus("failed");
}
}, [node.textContent]);
React.useEffect(
() => () => {
copyRequestId.current += 1;
if (copyResetTimer.current) clearTimeout(copyResetTimer.current);
},
[],
);

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 copied = copyStatus === "copied";
const copyFailed = copyStatus === "failed";

return (
<NodeViewWrapper className="group relative my-4" data-language={storedLanguage || undefined}>
<NodeViewWrapper
className="emdash-code-block-node relative my-4"
data-language={storedLanguage || undefined}
>
<pre className="emdash-code-block">
<NodeViewContent<"code"> as="code" />
</pre>

<div className="absolute end-2 top-2 select-none" contentEditable={false}>
<div
className="absolute end-1 top-0 z-10 select-none"
style={{ width: "max-content", maxWidth: "calc(100% - 0.25rem)" }}
contentEditable={false}
>
<Popover
open={isEditing}
onOpenChange={(open: boolean) => (open ? openPicker() : closePicker())}
>
<Popover.Trigger
render={
<button
type="button"
tabIndex={chipPersistent ? 0 : -1}
onMouseDown={(e) => 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`}
</button>
}
/>
<TooltipProvider>
<Toolbar
size="sm"
className="emdash-code-block-controls max-w-full text-[13px]"
data-persistent={isEditing || copyStatus !== "idle" ? "true" : "false"}
aria-label={t`Code block actions`}
>
<Popover.Trigger
render={
<Toolbar.Button
className="min-w-0 flex-1 overflow-hidden text-[13px]"
onMouseDown={(event) => event.preventDefault()}
aria-label={t`Set language (current: ${label})`}
>
<span className="max-w-40 truncate">
{storedLanguage ? label : t`Set language`}
</span>
<CaretDown className="size-3.5 shrink-0" aria-hidden="true" />
</Toolbar.Button>
}
/>
<Tooltip
content={copyFailed ? t`Retry copy` : copied ? t`Copied` : t`Copy code`}
render={
<Toolbar.Button
shape="square"
className="relative isolate overflow-hidden text-[13px]"
onMouseDown={(event) => event.preventDefault()}
onClick={copyCode}
aria-label={copyFailed ? t`Retry copy` : t`Copy code`}
>
<span className="contents" aria-hidden="true">
{copied ? (
<Check className="size-3.5" />
) : copyFailed ? (
<X className="size-3.5" />
) : (
<Copy className="size-3.5" />
)}
</span>
</Toolbar.Button>
}
/>
</Toolbar>
</TooltipProvider>
<span className="sr-only" role="status" aria-live="polite">
{copyFailed ? t`Copy failed` : copied ? t`Copied` : ""}
</span>
<Popover.Content side="bottom" className="w-auto p-1">
<div className="flex items-center gap-1" onKeyDown={handleKeyDown}>
<Autocomplete
Expand Down
Loading
Loading