From 1c2cbbd0155e8021c5f394a53d644c3eb64b2695 Mon Sep 17 00:00:00 2001 From: Noah Pham Date: Mon, 13 Jul 2026 22:52:10 +0100 Subject: [PATCH 01/12] feat(admin): refine portable text editor --- .changeset/refine-portable-text-editor.md | 5 + .../admin/src/components/ContentEditor.tsx | 2 +- .../src/components/PortableTextEditor.tsx | 315 +++++++++++++----- .../components/editor/DragHandleWrapper.tsx | 85 +++-- packages/admin/src/styles.css | 5 - .../tests/components/ContentEditor.test.tsx | 10 + .../DragHandleWrapper.interactions.test.tsx | 32 ++ .../tests/editor/DragHandleWrapper.test.ts | 10 + .../tests/editor/PortableTextEditor.test.tsx | 19 ++ .../admin/tests/editor/slash-menu.test.tsx | 39 ++- packages/admin/tests/editor/toolbar.test.tsx | 35 +- 11 files changed, 447 insertions(+), 110 deletions(-) create mode 100644 .changeset/refine-portable-text-editor.md create mode 100644 packages/admin/tests/editor/DragHandleWrapper.interactions.test.tsx create mode 100644 packages/admin/tests/editor/DragHandleWrapper.test.ts diff --git a/.changeset/refine-portable-text-editor.md b/.changeset/refine-portable-text-editor.md new file mode 100644 index 0000000000..dcc22bd466 --- /dev/null +++ b/.changeset/refine-portable-text-editor.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/admin": patch +--- + +Improves portable text editing with a centered writing column, a responsive single-row toolbar, direct block insertion controls, and an unclipped link popover. diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index 806aca20a6..cd19fa197e 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -1174,7 +1174,7 @@ function FieldRenderer({ DOMRect | null) | null; range: Range | null; + trigger: "slash" | "gutter"; + gutterBlockPos: number | null; } /** @@ -1161,6 +1163,8 @@ function createSlashCommandsExtension(options: { selectedIndex: 0, clientRect: props.clientRect ?? null, range: props.range, + trigger: "slash", + gutterBlockPos: null, }); }, onUpdate: (props) => { @@ -1170,6 +1174,8 @@ function createSlashCommandsExtension(options: { selectedIndex: 0, clientRect: props.clientRect ?? null, range: props.range, + trigger: "slash", + gutterBlockPos: null, })); }, onKeyDown: (props) => { @@ -1226,7 +1232,7 @@ function createSlashCommandsExtension(options: { function SlashCommandMenu({ state, onCommand, - onClose: _onClose, + onClose, setSelectedIndex, }: { state: SlashMenuState; @@ -1282,6 +1288,19 @@ function SlashCommandMenu({ } }, [state.isOpen]); + React.useEffect(() => { + if (!state.isOpen) return; + + const handlePointerDown = (event: PointerEvent) => { + const target = event.target as Node | null; + if (target && containerRef.current?.contains(target)) return; + onClose(); + }; + + document.addEventListener("pointerdown", handlePointerDown, true); + return () => document.removeEventListener("pointerdown", handlePointerDown, true); + }, [onClose, state.isOpen]); + if (!state.isOpen) return null; return createPortal( @@ -2096,7 +2115,7 @@ export interface PortableTextEditorProps { export function PortableTextEditor({ value, onChange, - placeholder = "Start writing...", + placeholder, className, editable = true, "aria-labelledby": ariaLabelledby, @@ -2109,6 +2128,8 @@ export function PortableTextEditor({ onBlockSidebarClose, }: PortableTextEditorProps) { const { t } = useLingui(); + const placeholderRef = React.useRef(placeholder ?? t`Type / for commands`); + placeholderRef.current = placeholder ?? t`Type / for commands`; // Use a ref for onChange to avoid recreating the editor when the callback changes const onChangeRef = React.useRef(onChange); @@ -2148,6 +2169,8 @@ export function PortableTextEditor({ selectedIndex: 0, clientRect: null, range: null, + trigger: "slash", + gutterBlockPos: null, }); // Ref to access current state synchronously in keyboard handlers. @@ -2302,12 +2325,7 @@ export function PortableTextEditor({ TableCell, Placeholder.configure({ includeChildren: true, - placeholder: ({ node }) => { - if (node.type.name === "paragraph") { - return placeholder; - } - return placeholder; - }, + placeholder: () => placeholderRef.current, }), TextAlign.configure({ types: ["heading", "paragraph"], @@ -2334,7 +2352,7 @@ export function PortableTextEditor({ () => ({ attributes: { class: - "prose prose-sm sm:prose-base dark:prose-invert max-w-none focus:outline-none min-h-[200px] p-4", + "prose prose-sm sm:prose-base dark:prose-invert w-full max-w-[calc(75ch+8rem)] mx-auto focus:outline-none min-h-[200px] p-4 ps-14 pe-14 sm:ps-16 sm:pe-16", dir: "auto", }, }), @@ -2359,6 +2377,113 @@ export function PortableTextEditor({ }, }); + const openBlockInsertMenuAt = React.useCallback( + (insertPos: number) => { + if (!editor) return; + + const selectionPos = insertPos + 1; + const inserted = editor + .chain() + .focus() + .insertContentAt(insertPos, { type: "paragraph" }) + .setTextSelection(selectionPos) + .run(); + if (!inserted) return; + + setSlashMenuState({ + isOpen: true, + items: filterCommandsRef.current(""), + selectedIndex: 0, + clientRect: () => { + if (editor.isDestroyed) return null; + const coords = editor.view.coordsAtPos(selectionPos); + const DOMRectCtor = editor.view.dom.ownerDocument.defaultView?.DOMRect; + if (!DOMRectCtor) return null; + return new DOMRectCtor( + coords.left, + coords.top, + coords.right - coords.left, + coords.bottom - coords.top, + ); + }, + range: { from: selectionPos, to: selectionPos }, + trigger: "gutter", + gutterBlockPos: insertPos, + }); + }, + [editor, setSlashMenuState], + ); + + const closeSlashMenu = React.useCallback(() => { + const state = slashMenuStateRef.current; + if (editor && state.trigger === "gutter" && state.gutterBlockPos !== null) { + const node = editor.state.doc.nodeAt(state.gutterBlockPos); + if (node?.type.name === "paragraph" && node.content.size === 0) { + editor + .chain() + .deleteRange({ + from: state.gutterBlockPos, + to: state.gutterBlockPos + node.nodeSize, + }) + .run(); + } + } + setSlashMenuState((prev) => ({ ...prev, isOpen: false, gutterBlockPos: null })); + }, [editor, setSlashMenuState]); + + React.useEffect(() => { + if (!editor || !slashMenuState.isOpen || slashMenuState.trigger !== "gutter") return; + + const handleKeyDown = (event: KeyboardEvent) => { + const state = slashMenuStateRef.current; + if (!state.isOpen || state.trigger !== "gutter") return; + + if (event.key === "Escape") { + event.preventDefault(); + closeSlashMenu(); + return; + } + + if (event.key === "ArrowUp" || event.key === "ArrowDown") { + if (state.items.length === 0) return; + event.preventDefault(); + const delta = event.key === "ArrowUp" ? -1 : 1; + setSlashMenuState((prev) => ({ + ...prev, + selectedIndex: (prev.selectedIndex + delta + prev.items.length) % prev.items.length, + })); + return; + } + + if (event.key === "Enter") { + const item = state.items[state.selectedIndex]; + if (!item || !state.range) return; + event.preventDefault(); + item.command({ editor, range: state.range }); + setSlashMenuState((prev) => ({ ...prev, isOpen: false, gutterBlockPos: null })); + return; + } + + if (event.key.length === 1 || event.key === "Backspace" || event.key === "Delete") { + setSlashMenuState((prev) => ({ ...prev, isOpen: false, gutterBlockPos: null })); + } + }; + + const editorElement = editor.view.dom; + editorElement.addEventListener("keydown", handleKeyDown); + return () => editorElement.removeEventListener("keydown", handleKeyDown); + }, [closeSlashMenu, editor, setSlashMenuState, slashMenuState.isOpen, slashMenuState.trigger]); + + const handleTouchInsertBlock = React.useCallback(() => { + if (!editor) return; + const { $from } = editor.state.selection; + const topLevelPos = $from.depth > 0 ? $from.before(1) : $from.pos; + const topLevelNode = editor.state.doc.nodeAt(topLevelPos); + openBlockInsertMenuAt( + topLevelNode ? topLevelPos + topLevelNode.nodeSize : editor.state.doc.content.size, + ); + }, [editor, openBlockInsertMenuAt]); + // Notify when editor is ready, and on unmount so consumers can clear the // reference before TipTap destroys the instance (e.g. when keying by item.id // to switch translations). @@ -2507,12 +2632,13 @@ export function PortableTextEditor({ // Handle slash menu command execution const handleSlashCommand = React.useCallback( (item: SlashCommandItem) => { - if (editor && slashMenuState.range) { - item.command({ editor, range: slashMenuState.range }); - setSlashMenuState((prev) => ({ ...prev, isOpen: false })); + const state = slashMenuStateRef.current; + if (editor && state.range) { + item.command({ editor, range: state.range }); + setSlashMenuState((prev) => ({ ...prev, isOpen: false, gutterBlockPos: null })); } }, - [editor, slashMenuState.range], + [editor, setSlashMenuState], ); // Handle section selection - insert section content at cursor @@ -2551,13 +2677,18 @@ export function PortableTextEditor({ aria-labelledby={ariaLabelledby} > {!minimal && ( - + )}
- {editable && } + {editable && }
{!minimal && } @@ -2565,7 +2696,7 @@ export function PortableTextEditor({ setSlashMenuState((prev) => ({ ...prev, isOpen: false }))} + onClose={closeSlashMenu} setSelectedIndex={(index) => setSlashMenuState((prev) => ({ ...prev, selectedIndex: index })) } @@ -2864,10 +2995,12 @@ function EditorToolbar({ editor, focusMode, onFocusModeChange, + onInsertBlock, }: { editor: Editor; focusMode: FocusMode; onFocusModeChange: (mode: FocusMode) => void; + onInsertBlock: () => void; }) { const { t } = useLingui(); const [mediaPickerOpen, setMediaPickerOpen] = React.useState(false); @@ -2963,9 +3096,9 @@ function EditorToolbar({ const buttons = [ ...toolbar.querySelectorAll( - 'button:not([disabled]), [role="button"]:not([disabled])', + 'button:not([disabled]):not([data-touch-block-insert]), [role="button"]:not([disabled]):not([data-touch-block-insert])', ), - ]; + ].filter((button) => button.getClientRects().length > 0); const currentIndex = buttons.findIndex((btn) => btn === document.activeElement); if (currentIndex === -1) return; @@ -3001,11 +3134,24 @@ function EditorToolbar({ ref={toolbarRef} role="toolbar" aria-label={t`Text formatting`} - className="sticky -top-6 z-10 border-b bg-kumo-tint p-1 flex flex-wrap gap-0.5" + className="sticky -top-6 z-10 border-b bg-kumo-tint p-1 flex flex-nowrap gap-0.5 overflow-x-auto" onKeyDown={handleKeyDown} > {/* Text formatting */} + editor.chain().focus().toggleBold().run()} active={editorState.isBold} @@ -3145,63 +3291,79 @@ function EditorToolbar({ {/* Insert */} {/* Link with popover */} -
- setShowLinkPopover(!showLinkPopover)} - active={editorState.isLink} - title={t`Insert Link`} - > - - {showLinkPopover && ( -
-
- -
- setLinkUrl(e.target.value)} - onKeyDown={handleLinkKeyDown} - className="h-8 w-52 text-sm" - /> -
-
- -
- {editorState.isLink && ( - - )} - + } + /> + +
+ +
+ setLinkUrl(e.target.value)} + onKeyDown={handleLinkKeyDown} + className="h-8 w-52 text-sm" + aria-label={t`URL`} + /> +
+
+ +
+ {editorState.isLink && ( + -
+ )} +
- )} -
+ + setMediaPickerOpen(true)} title={t`Insert Image`}> @@ -3257,7 +3419,6 @@ function EditorToolbar({
; + return
{children}
; } function ToolbarSeparator() { - return
; + return
; } interface ToolbarButtonProps { @@ -3292,7 +3453,7 @@ function ToolbarButton({ onClick, active, disabled, title, children }: ToolbarBu type="button" variant="ghost" shape="square" - className={cn("h-8 w-8", active && "bg-kumo-tint text-kumo-default")} + className={cn("h-8 w-8 flex-none", active && "bg-kumo-tint text-kumo-default")} onMouseDown={(e) => e.preventDefault()} onClick={onClick} disabled={disabled} diff --git a/packages/admin/src/components/editor/DragHandleWrapper.tsx b/packages/admin/src/components/editor/DragHandleWrapper.tsx index 2d75374cdd..29173f3593 100644 --- a/packages/admin/src/components/editor/DragHandleWrapper.tsx +++ b/packages/admin/src/components/editor/DragHandleWrapper.tsx @@ -8,8 +8,9 @@ * - Block menu integration for transforms, duplicate, delete */ +import { Button } from "@cloudflare/kumo"; import { useLingui } from "@lingui/react/macro"; -import { DotsSixVertical } from "@phosphor-icons/react"; +import { DotsSixVertical, Plus } from "@phosphor-icons/react"; import type { Editor } from "@tiptap/core"; import { DragHandle } from "@tiptap/extension-drag-handle-react"; import type { Node as PMNode } from "@tiptap/pm/model"; @@ -20,6 +21,7 @@ import { BlockMenu } from "./BlockMenu"; interface DragHandleWrapperProps { editor: Editor; + onInsertBlock: (insertPos: number) => void; } interface HoveredNode { @@ -38,11 +40,19 @@ declare module "@tiptap/core" { } } +export function _getDragHandlePlacement(direction: "ltr" | "rtl") { + return direction === "rtl" ? ("right-start" as const) : ("left-start" as const); +} + /** * DragHandleWrapper - Official TipTap drag handle with BlockMenu integration */ -export function DragHandleWrapper({ editor }: DragHandleWrapperProps) { +export function DragHandleWrapper({ editor, onInsertBlock }: DragHandleWrapperProps) { const { t } = useLingui(); + const direction = + editor.view.dom.ownerDocument.defaultView?.getComputedStyle(editor.view.dom).direction === "rtl" + ? "rtl" + : "ltr"; const [hoveredNode, setHoveredNode] = React.useState(null); const [menuOpen, setMenuOpen] = React.useState(false); const [menuAnchor, setMenuAnchor] = React.useState(null); @@ -69,6 +79,17 @@ export function DragHandleWrapper({ editor }: DragHandleWrapperProps) { [editor, hoveredNode], ); + const handleInsertClick = React.useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!hoveredNode) return; + + onInsertBlock(hoveredNode.pos + hoveredNode.node.nodeSize); + }, + [hoveredNode, onInsertBlock], + ); + // Close the menu const handleCloseMenu = React.useCallback(() => { setMenuOpen(false); @@ -96,10 +117,10 @@ export function DragHandleWrapper({ editor }: DragHandleWrapperProps) { // tears down the Suggestion plugin view (calling onExit → setState → loop). const computePositionConfig = React.useMemo( () => ({ - placement: "left-start" as const, + placement: _getDragHandlePlacement(direction), strategy: "absolute" as const, }), - [], + [direction], ); return ( @@ -109,23 +130,45 @@ export function DragHandleWrapper({ editor }: DragHandleWrapperProps) { onNodeChange={handleNodeChange} computePositionConfig={computePositionConfig} > - +
+ + +
{/* Block menu */} diff --git a/packages/admin/src/styles.css b/packages/admin/src/styles.css index d379c4003c..659d3671d7 100644 --- a/packages/admin/src/styles.css +++ b/packages/admin/src/styles.css @@ -224,11 +224,6 @@ body { transition: opacity 0.2s ease; } -/* Add left padding to accommodate block handles */ -.ProseMirror { - padding-left: 2.5rem; -} - /* Block dragging styles */ .ProseMirror .dragging { opacity: 0.5; diff --git a/packages/admin/tests/components/ContentEditor.test.tsx b/packages/admin/tests/components/ContentEditor.test.tsx index b9975c38b7..577d4c8fe5 100644 --- a/packages/admin/tests/components/ContentEditor.test.tsx +++ b/packages/admin/tests/components/ContentEditor.test.tsx @@ -215,6 +215,16 @@ describe("ContentEditor", () => { portableTextProps.current = null; }); + it("uses a task-oriented placeholder for portable text fields", async () => { + await renderEditor({ + isNew: false, + item: makeItem(), + fields: { content: { kind: "portableText", label: "Content" } }, + }); + + expect(portableTextProps.current?.placeholder).toBe("Type / for commands"); + }); + describe("block panel + mobile sheet sync", () => { const ptFields: Record = { content: { kind: "portableText", label: "Content" }, diff --git a/packages/admin/tests/editor/DragHandleWrapper.interactions.test.tsx b/packages/admin/tests/editor/DragHandleWrapper.interactions.test.tsx new file mode 100644 index 0000000000..328c759fbe --- /dev/null +++ b/packages/admin/tests/editor/DragHandleWrapper.interactions.test.tsx @@ -0,0 +1,32 @@ +import type { Editor } from "@tiptap/core"; +import * as React from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { DragHandleWrapper } from "../../src/components/editor/DragHandleWrapper"; +import { render } from "../utils/render"; + +vi.mock("@tiptap/extension-drag-handle-react", () => ({ + DragHandle: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock("../../src/components/editor/BlockMenu", () => ({ + BlockMenu: () => null, +})); + +describe("DragHandleWrapper interactions", () => { + it("prevents the insert button from starting a native block drag", async () => { + const editorElement = document.createElement("div"); + const editor = { + view: { dom: editorElement }, + } as unknown as Editor; + const screen = await render(); + const insertButton = screen.getByRole("button", { name: "Insert block below" }).element(); + const mouseDown = new MouseEvent("mousedown", { bubbles: true, cancelable: true }); + + insertButton.dispatchEvent(mouseDown); + + expect(mouseDown.defaultPrevented).toBe(true); + }); +}); diff --git a/packages/admin/tests/editor/DragHandleWrapper.test.ts b/packages/admin/tests/editor/DragHandleWrapper.test.ts new file mode 100644 index 0000000000..9d2c8af172 --- /dev/null +++ b/packages/admin/tests/editor/DragHandleWrapper.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; + +import { _getDragHandlePlacement } from "../../src/components/editor/DragHandleWrapper"; + +describe("DragHandleWrapper", () => { + it("places controls at the content's logical start edge", () => { + expect(_getDragHandlePlacement("ltr")).toBe("left-start"); + expect(_getDragHandlePlacement("rtl")).toBe("right-start"); + }); +}); diff --git a/packages/admin/tests/editor/PortableTextEditor.test.tsx b/packages/admin/tests/editor/PortableTextEditor.test.tsx index 464a950e2a..95dde6742e 100644 --- a/packages/admin/tests/editor/PortableTextEditor.test.tsx +++ b/packages/admin/tests/editor/PortableTextEditor.test.tsx @@ -450,6 +450,25 @@ describe("Portable Text ↔ ProseMirror conversion", () => { expect(textContent.trim()).toBe(""); }); + it("teaches slash commands in the default placeholder", async () => { + await render(); + const pm = await waitForEditor(); + const placeholder = pm.querySelector("[data-placeholder]"); + expect(placeholder?.getAttribute("data-placeholder")).toBe("Type / for commands"); + }); + + it("centers the writing column with responsive space for block controls", async () => { + await render(); + const pm = await waitForEditor(); + expect(pm.className).toContain("w-full"); + expect(pm.className).toContain("max-w-[calc(75ch+8rem)]"); + expect(pm.className).toContain("mx-auto"); + expect(pm.className).toContain("ps-14"); + expect(pm.className).toContain("pe-14"); + expect(pm.className).toContain("sm:ps-16"); + expect(pm.className).toContain("sm:pe-16"); + }); + it("renders bold+italic text with multiple marks", async () => { await render( , diff --git a/packages/admin/tests/editor/slash-menu.test.tsx b/packages/admin/tests/editor/slash-menu.test.tsx index 90c454fc7c..d8adbb9aed 100644 --- a/packages/admin/tests/editor/slash-menu.test.tsx +++ b/packages/admin/tests/editor/slash-menu.test.tsx @@ -30,7 +30,17 @@ vi.mock("../../src/components/SectionPickerModal", () => ({ })); vi.mock("../../src/components/editor/DragHandleWrapper", () => ({ - DragHandleWrapper: () => null, + DragHandleWrapper: ({ + editor, + onInsertBlock, + }: { + editor: Editor; + onInsertBlock?: (insertPos: number) => void; + }) => ( + + ), })); vi.mock("../../src/components/editor/ImageNode", async () => { @@ -194,6 +204,33 @@ function isItemSelected(el: HTMLElement): boolean { // ============================================================================= describe("Slash Command Menu", () => { + it("opens from the gutter and cancels without inserting a slash", async () => { + const { screen, editor, pm } = await renderEditor(); + await focusEditor(pm); + const before = editor.getText(); + + await screen.getByRole("button", { name: "Test gutter insert" }).click(); + await waitForSlashMenu(); + expect(editor.getText()).not.toContain("/"); + + await userEvent.keyboard("{Escape}"); + await waitForSlashMenuClosed(); + expect(editor.getText()).toBe(before); + }); + + it("discards an untouched gutter block when clicking outside the menu", async () => { + const { screen, editor, pm } = await renderEditor(); + await focusEditor(pm); + const before = editor.getText(); + + await screen.getByRole("button", { name: "Test gutter insert" }).click(); + await waitForSlashMenu(); + pm.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); + + await waitForSlashMenuClosed(); + expect(editor.getText()).toBe(before); + }); + it("opens when typing / at the start of an empty line", async () => { const { editor, pm } = await renderEditor(); await focusEditor(pm); diff --git a/packages/admin/tests/editor/toolbar.test.tsx b/packages/admin/tests/editor/toolbar.test.tsx index e5a33b42ee..fec5f1b424 100644 --- a/packages/admin/tests/editor/toolbar.test.tsx +++ b/packages/admin/tests/editor/toolbar.test.tsx @@ -192,6 +192,31 @@ describe("Toolbar Presence and Structure", () => { .toBeVisible(); }); + it("renders the link editor outside the horizontally scrolling toolbar", async () => { + const { screen } = await renderEditor(); + const toolbar = screen.getByRole("toolbar", { name: "Text formatting" }).element(); + screen.getByRole("button", { name: "Insert Link" }).element().click(); + + await vi.waitFor(() => { + const input = document.querySelector('input[placeholder="https://..."]'); + expect(input).toBeTruthy(); + expect(toolbar.contains(input)).toBe(false); + }); + }); + + it("provides an independent block inserter for coarse pointers", async () => { + const { screen } = await renderEditor(); + const toolbar = screen.getByRole("toolbar", { name: "Text formatting" }).element(); + const touchInsert = toolbar.querySelector("[data-touch-block-insert]"); + + expect(touchInsert).toBeTruthy(); + expect(touchInsert?.className).toContain("pointer-coarse:flex"); + touchInsert?.click(); + await vi.waitFor(() => { + expect(document.querySelector("body > div [data-index]")).toBeTruthy(); + }); + }); + it("has history buttons", async () => { const { screen } = await renderEditor(); await expect.element(screen.getByRole("button", { name: "Undo" })).toBeVisible(); @@ -574,7 +599,7 @@ describe("Link Insertion", () => { linkBtn.element().click(); await vi.waitFor(() => { - const input = screen.container.querySelector('input[type="url"]'); + const input = document.querySelector('input[type="url"]'); expect(input).toBeTruthy(); }); }); @@ -598,10 +623,10 @@ describe("Link Insertion", () => { screen.getByRole("button", { name: "Insert Link" }).element().click(); await vi.waitFor(() => { - expect(screen.container.querySelector('input[type="url"]')).toBeTruthy(); + expect(document.querySelector('input[type="url"]')).toBeTruthy(); }); - const input = screen.container.querySelector('input[type="url"]') as HTMLInputElement; + const input = document.querySelector('input[type="url"]') as HTMLInputElement; // Focus input and type URL input.focus(); // Use native input value setter to trigger React's onChange @@ -627,13 +652,13 @@ describe("Link Insertion", () => { screen.getByRole("button", { name: "Insert Link" }).element().click(); await vi.waitFor(() => { - expect(screen.container.querySelector('input[type="url"]')).toBeTruthy(); + expect(document.querySelector('input[type="url"]')).toBeTruthy(); }); screen.getByRole("button", { name: "Cancel" }).element().click(); await vi.waitFor(() => { - expect(screen.container.querySelector('input[type="url"]')).toBeNull(); + expect(document.querySelector('input[type="url"]')).toBeNull(); }); }); From fa8f04a30054297ad22a11e70dfec4daec9d0d65 Mon Sep 17 00:00:00 2001 From: Noah Pham Date: Mon, 13 Jul 2026 23:22:04 +0100 Subject: [PATCH 02/12] feat(admin): add heading dropdown menu --- .../src/components/PortableTextEditor.tsx | 26 +-- .../components/editor/HeadingDropdownMenu.tsx | 170 ++++++++++++++++++ packages/admin/src/styles.css | 5 + packages/admin/tests/editor/toolbar.test.tsx | 55 ++++-- 4 files changed, 218 insertions(+), 38 deletions(-) create mode 100644 packages/admin/src/components/editor/HeadingDropdownMenu.tsx diff --git a/packages/admin/src/components/PortableTextEditor.tsx b/packages/admin/src/components/PortableTextEditor.tsx index cac393277f..43df0b406d 100644 --- a/packages/admin/src/components/PortableTextEditor.tsx +++ b/packages/admin/src/components/PortableTextEditor.tsx @@ -94,6 +94,7 @@ import { CaretNext } from "./ArrowIcons.js"; import { BlockKitMediaPickerField } from "./BlockKitMediaPickerField"; import { CodeBlockExtension } from "./editor/CodeBlockNode"; import { DragHandleWrapper } from "./editor/DragHandleWrapper"; +import { HeadingDropdownMenu } from "./editor/HeadingDropdownMenu"; import { HtmlBlockExtension } from "./editor/HtmlBlockNode"; import { ImageExtension } from "./editor/ImageNode"; import { MarkdownLinkExtension } from "./editor/MarkdownLinkExtension"; @@ -3018,9 +3019,6 @@ function EditorToolbar({ isUnderline: ctx.editor.isActive("underline"), isStrike: ctx.editor.isActive("strike"), isCode: ctx.editor.isActive("code"), - isHeading1: ctx.editor.isActive("heading", { level: 1 }), - isHeading2: ctx.editor.isActive("heading", { level: 2 }), - isHeading3: ctx.editor.isActive("heading", { level: 3 }), isBulletList: ctx.editor.isActive("bulletList"), isOrderedList: ctx.editor.isActive("orderedList"), isBlockquote: ctx.editor.isActive("blockquote"), @@ -3193,27 +3191,7 @@ function EditorToolbar({ {/* Headings */} - editor.chain().focus().toggleHeading({ level: 1 }).run()} - active={editorState.isHeading1} - title={t`Heading 1`} - > - - editor.chain().focus().toggleHeading({ level: 2 }).run()} - active={editorState.isHeading2} - title={t`Heading 2`} - > - - editor.chain().focus().toggleHeading({ level: 3 }).run()} - active={editorState.isHeading3} - title={t`Heading 3`} - > - + diff --git a/packages/admin/src/components/editor/HeadingDropdownMenu.tsx b/packages/admin/src/components/editor/HeadingDropdownMenu.tsx new file mode 100644 index 0000000000..509de18c53 --- /dev/null +++ b/packages/admin/src/components/editor/HeadingDropdownMenu.tsx @@ -0,0 +1,170 @@ +import { Button, DropdownMenu } from "@cloudflare/kumo"; +import { useLingui } from "@lingui/react/macro"; +import { + CaretDown, + TextH, + TextHFive, + TextHFour, + TextHOne, + TextHSix, + TextHThree, + TextHTwo, + type Icon, +} from "@phosphor-icons/react"; +import type { Editor } from "@tiptap/core"; +import { useEditorState } from "@tiptap/react"; +import * as React from "react"; + +import { cn } from "../../lib/utils.js"; + +/** + * TipTap's heading-dropdown-menu API adapted to EmDash's Kumo primitives. + * Source pattern: `npx @tiptap/cli add heading-dropdown-menu`. + */ + +export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6; + +const DEFAULT_LEVELS: readonly HeadingLevel[] = [1, 2, 3, 4, 5, 6]; + +const HEADING_ICONS: Record = { + 1: TextHOne, + 2: TextHTwo, + 3: TextHThree, + 4: TextHFour, + 5: TextHFive, + 6: TextHSix, +}; + +export function getActiveHeadingLevel( + editor: Editor | null, + levels: readonly HeadingLevel[] = DEFAULT_LEVELS, +): HeadingLevel | undefined { + if (!editor || !editor.isEditable) return undefined; + return levels.find((level) => editor.isActive("heading", { level })); +} + +function canToggleHeading(editor: Editor | null, levels: readonly HeadingLevel[]): boolean { + if (!editor || !editor.isEditable || !editor.schema.nodes.heading) return false; + return levels.some( + (level) => editor.can().setNode("heading", { level }) || editor.can().clearNodes(), + ); +} + +function toggleHeading(editor: Editor | null, level: HeadingLevel): boolean { + if (!editor || !canToggleHeading(editor, [level])) return false; + return editor.chain().focus().toggleHeading({ level }).run(); +} + +export interface UseHeadingDropdownMenuConfig { + editor?: Editor | null; + levels?: readonly HeadingLevel[]; + hideWhenUnavailable?: boolean; +} + +export function useHeadingDropdownMenu(config: UseHeadingDropdownMenuConfig = {}) { + const { editor = null, levels = DEFAULT_LEVELS, hideWhenUnavailable = false } = config; + + const state = useEditorState({ + editor, + selector: ({ editor: currentEditor }) => { + const activeLevel = getActiveHeadingLevel(currentEditor, levels); + const canToggle = canToggleHeading(currentEditor, levels); + return { + activeLevel, + isActive: activeLevel !== undefined, + canToggle, + isVisible: !hideWhenUnavailable || canToggle, + }; + }, + }); + + const activeLevel = state?.activeLevel; + + return { + isVisible: state?.isVisible ?? !hideWhenUnavailable, + activeLevel, + isActive: state?.isActive ?? false, + canToggle: state?.canToggle ?? false, + levels, + Icon: activeLevel ? HEADING_ICONS[activeLevel] : TextH, + }; +} + +export interface HeadingDropdownMenuProps extends UseHeadingDropdownMenuConfig { + className?: string; + onOpenChange?: (isOpen: boolean) => void; +} + +export const HeadingDropdownMenu = React.forwardRef( + function HeadingDropdownMenu( + { + editor = null, + levels = DEFAULT_LEVELS, + hideWhenUnavailable = false, + className, + onOpenChange, + }, + ref, + ) { + const { t } = useLingui(); + const [open, setOpen] = React.useState(false); + const { isVisible, activeLevel, isActive, canToggle, Icon } = useHeadingDropdownMenu({ + editor, + levels, + hideWhenUnavailable, + }); + + const handleOpenChange = React.useCallback( + (nextOpen: boolean) => { + if (nextOpen && !canToggle) return; + setOpen(nextOpen); + onOpenChange?.(nextOpen); + }, + [canToggle, onOpenChange], + ); + + if (!isVisible) return null; + + return ( + + event.preventDefault()} + aria-label={t`Headings`} + aria-pressed={isActive} + > + + ); + }, +); diff --git a/packages/admin/src/styles.css b/packages/admin/src/styles.css index 659d3671d7..734f1d48a8 100644 --- a/packages/admin/src/styles.css +++ b/packages/admin/src/styles.css @@ -110,6 +110,11 @@ body { color: var(--text-color-kumo-default); } +/* Keep heading choices visibly distinct from the dropdown surface. */ +[data-emdash-heading-item][data-highlighted] { + background-color: var(--color-kumo-interact) !important; +} + /* Transitions.dev — Text states swap */ :root { --text-swap-dur: 150ms; diff --git a/packages/admin/tests/editor/toolbar.test.tsx b/packages/admin/tests/editor/toolbar.test.tsx index fec5f1b424..0f8f18045f 100644 --- a/packages/admin/tests/editor/toolbar.test.tsx +++ b/packages/admin/tests/editor/toolbar.test.tsx @@ -136,6 +136,17 @@ function getToolbarButton(screen: Awaited>, name: stri return screen.getByRole("toolbar", { name: "Text formatting" }).getByRole("button", { name }); } +async function getHeadingMenuItem( + screen: Awaited>, + name: "Heading 1" | "Heading 2" | "Heading 3", +) { + const trigger = getToolbarButton(screen, "Headings"); + trigger.element().click(); + const item = screen.getByRole("menuitem", { name }); + await expect.element(item).toBeVisible(); + return { trigger, item }; +} + // ============================================================================= // 1. Toolbar Presence and Structure // ============================================================================= @@ -156,11 +167,25 @@ describe("Toolbar Presence and Structure", () => { await expect.element(screen.getByRole("button", { name: "Inline Code" })).toBeVisible(); }); - it("has all heading buttons", async () => { + it("collapses the supported heading levels into one menu", async () => { const { screen } = await renderEditor(); - await expect.element(screen.getByRole("button", { name: "Heading 1" })).toBeVisible(); - await expect.element(screen.getByRole("button", { name: "Heading 2" })).toBeVisible(); - await expect.element(screen.getByRole("button", { name: "Heading 3" })).toBeVisible(); + await expect.element(getToolbarButton(screen, "Headings")).toBeVisible(); + + getToolbarButton(screen, "Headings").element().click(); + await expect.element(screen.getByRole("menuitem", { name: "Heading 1" })).toBeVisible(); + await expect.element(screen.getByRole("menuitem", { name: "Heading 2" })).toBeVisible(); + await expect.element(screen.getByRole("menuitem", { name: "Heading 3" })).toBeVisible(); + expect( + screen.getByRole("menuitem", { name: "Heading 1" }).element().hasAttribute( + "data-emdash-heading-item", + ), + ).toBe(true); + + const headingLabels = Array.from( + document.querySelectorAll('[role="menuitem"]'), + (item) => item.textContent?.trim(), + ); + expect(headingLabels).not.toContain("Heading 4"); }); it("has all list buttons", async () => { @@ -310,12 +335,12 @@ describe("Formatting Button Toggle States", () => { // Focus editor and place cursor (block commands need cursor in a paragraph) editor.commands.focus(); - const btn = screen.getByRole("button", { name: "Heading 1" }); - await expect.element(btn).toHaveAttribute("aria-pressed", "false"); - btn.element().click(); + const { trigger, item } = await getHeadingMenuItem(screen, "Heading 1"); + await expect.element(trigger).toHaveAttribute("aria-pressed", "false"); + item.element().click(); await vi.waitFor(() => { - expect(btn.element().getAttribute("aria-pressed")).toBe("true"); + expect(trigger.element().getAttribute("aria-pressed")).toBe("true"); expect(editor.isActive("heading", { level: 1 })).toBe(true); }); }); @@ -324,11 +349,12 @@ describe("Formatting Button Toggle States", () => { const { screen, editor } = await renderEditor(); editor.commands.focus(); - const btn = screen.getByRole("button", { name: "Heading 2" }); - btn.element().click(); + const { trigger, item } = await getHeadingMenuItem(screen, "Heading 2"); + item.element().click(); await vi.waitFor(() => { - expect(btn.element().getAttribute("aria-pressed")).toBe("true"); + expect(trigger.element().getAttribute("aria-pressed")).toBe("true"); + expect(editor.isActive("heading", { level: 2 })).toBe(true); }); }); @@ -336,11 +362,12 @@ describe("Formatting Button Toggle States", () => { const { screen, editor } = await renderEditor(); editor.commands.focus(); - const btn = screen.getByRole("button", { name: "Heading 3" }); - btn.element().click(); + const { trigger, item } = await getHeadingMenuItem(screen, "Heading 3"); + item.element().click(); await vi.waitFor(() => { - expect(btn.element().getAttribute("aria-pressed")).toBe("true"); + expect(trigger.element().getAttribute("aria-pressed")).toBe("true"); + expect(editor.isActive("heading", { level: 3 })).toBe(true); }); }); From 5d3350953c9c474828b73443290a27d02b1e1833 Mon Sep 17 00:00:00 2001 From: Noah Pham Date: Tue, 14 Jul 2026 00:02:35 +0100 Subject: [PATCH 03/12] fix(admin): harden portable text block insertion --- .../src/components/PortableTextEditor.tsx | 204 ++++++++++++------ .../components/editor/DragHandleWrapper.tsx | 58 +++-- .../components/editor/HeadingDropdownMenu.tsx | 15 +- .../DragHandleWrapper.interactions.test.tsx | 63 +++++- .../tests/editor/DragHandleWrapper.test.ts | 38 +++- .../admin/tests/editor/slash-menu.test.tsx | 109 +++++++++- packages/admin/tests/editor/toolbar.test.tsx | 43 ++-- 7 files changed, 414 insertions(+), 116 deletions(-) diff --git a/packages/admin/src/components/PortableTextEditor.tsx b/packages/admin/src/components/PortableTextEditor.tsx index 43df0b406d..e3f7826295 100644 --- a/packages/admin/src/components/PortableTextEditor.tsx +++ b/packages/admin/src/components/PortableTextEditor.tsx @@ -993,6 +993,8 @@ interface SlashCommandItem { description: MessageDescriptor | string; icon: Icon | React.ComponentType<{ className?: string }>; command: (props: { editor: Editor; range: Range }) => void; + /** Delay document insertion until a modal-backed command returns a selection. */ + deferInsertion?: boolean; aliases?: string[]; /** * Display category. Built-in commands use `msg`-tagged descriptors; @@ -2162,6 +2164,7 @@ export function PortableTextEditor({ // Section picker state (for inserting sections) const [sectionPickerOpen, setSectionPickerOpen] = React.useState(false); + const pendingBlockInsertPosRef = React.useRef(null); // Slash commands state const [slashMenuState, setSlashMenuStateRaw] = React.useState({ @@ -2216,6 +2219,7 @@ export function PortableTextEditor({ icon: ImageIcon, aliases: ["img", "photo", "picture", "url"], category: msg`Media`, + deferInsertion: true, command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).run(); setMediaPickerOpen(true); @@ -2230,6 +2234,7 @@ export function PortableTextEditor({ icon: Stack, aliases: ["pattern", "block", "template"], category: msg`Content`, + deferInsertion: true, command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).run(); setSectionPickerOpen(true); @@ -2246,6 +2251,7 @@ export function PortableTextEditor({ icon: resolveIcon(block.icon), aliases: [block.type], category: block.category ?? msg`Embeds`, + deferInsertion: true, command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).run(); setPluginBlockModal(block); @@ -2381,15 +2387,7 @@ export function PortableTextEditor({ const openBlockInsertMenuAt = React.useCallback( (insertPos: number) => { if (!editor) return; - - const selectionPos = insertPos + 1; - const inserted = editor - .chain() - .focus() - .insertContentAt(insertPos, { type: "paragraph" }) - .setTextSelection(selectionPos) - .run(); - if (!inserted) return; + editor.commands.focus(); setSlashMenuState({ isOpen: true, @@ -2397,7 +2395,7 @@ export function PortableTextEditor({ selectedIndex: 0, clientRect: () => { if (editor.isDestroyed) return null; - const coords = editor.view.coordsAtPos(selectionPos); + const coords = editor.view.coordsAtPos(insertPos); const DOMRectCtor = editor.view.dom.ownerDocument.defaultView?.DOMRect; if (!DOMRectCtor) return null; return new DOMRectCtor( @@ -2407,7 +2405,7 @@ export function PortableTextEditor({ coords.bottom - coords.top, ); }, - range: { from: selectionPos, to: selectionPos }, + range: { from: insertPos, to: insertPos }, trigger: "gutter", gutterBlockPos: insertPos, }); @@ -2416,21 +2414,40 @@ export function PortableTextEditor({ ); const closeSlashMenu = React.useCallback(() => { - const state = slashMenuStateRef.current; - if (editor && state.trigger === "gutter" && state.gutterBlockPos !== null) { - const node = editor.state.doc.nodeAt(state.gutterBlockPos); - if (node?.type.name === "paragraph" && node.content.size === 0) { - editor - .chain() - .deleteRange({ - from: state.gutterBlockPos, - to: state.gutterBlockPos + node.nodeSize, - }) - .run(); - } - } setSlashMenuState((prev) => ({ ...prev, isOpen: false, gutterBlockPos: null })); - }, [editor, setSlashMenuState]); + }, [setSlashMenuState]); + + const executeSlashCommand = React.useCallback( + (item: SlashCommandItem, state: SlashMenuState) => { + if (!editor || !state.range) return; + + let range = state.range; + if (state.trigger === "gutter") { + const insertPos = state.gutterBlockPos; + if (insertPos === null) return; + + if (item.deferInsertion) { + pendingBlockInsertPosRef.current = insertPos; + const selectionPos = editor.state.selection.from; + range = { from: selectionPos, to: selectionPos }; + } else { + const selectionPos = insertPos + 1; + const inserted = editor + .chain() + .focus() + .insertContentAt(insertPos, { type: "paragraph" }) + .setTextSelection(selectionPos) + .run(); + if (!inserted) return; + range = { from: selectionPos, to: selectionPos }; + } + } + + item.command({ editor, range }); + setSlashMenuState((prev) => ({ ...prev, isOpen: false, gutterBlockPos: null })); + }, + [editor, setSlashMenuState], + ); React.useEffect(() => { if (!editor || !slashMenuState.isOpen || slashMenuState.trigger !== "gutter") return; @@ -2460,20 +2477,50 @@ export function PortableTextEditor({ const item = state.items[state.selectedIndex]; if (!item || !state.range) return; event.preventDefault(); - item.command({ editor, range: state.range }); - setSlashMenuState((prev) => ({ ...prev, isOpen: false, gutterBlockPos: null })); + executeSlashCommand(item, state); return; } - if (event.key.length === 1 || event.key === "Backspace" || event.key === "Delete") { + if ( + event.key.length === 1 && + !event.ctrlKey && + !event.metaKey && + !event.altKey && + !event.isComposing && + state.gutterBlockPos !== null + ) { + event.preventDefault(); + const selectionPos = state.gutterBlockPos + event.key.length + 1; + editor + .chain() + .focus() + .insertContentAt(state.gutterBlockPos, { + type: "paragraph", + content: [{ type: "text", text: event.key }], + }) + .setTextSelection(selectionPos) + .run(); setSlashMenuState((prev) => ({ ...prev, isOpen: false, gutterBlockPos: null })); + return; + } + + if (event.key === "Backspace" || event.key === "Delete") { + event.preventDefault(); + closeSlashMenu(); } }; const editorElement = editor.view.dom; - editorElement.addEventListener("keydown", handleKeyDown); - return () => editorElement.removeEventListener("keydown", handleKeyDown); - }, [closeSlashMenu, editor, setSlashMenuState, slashMenuState.isOpen, slashMenuState.trigger]); + editorElement.addEventListener("keydown", handleKeyDown, true); + return () => editorElement.removeEventListener("keydown", handleKeyDown, true); + }, [ + closeSlashMenu, + editor, + executeSlashCommand, + setSlashMenuState, + slashMenuState.isOpen, + slashMenuState.trigger, + ]); const handleTouchInsertBlock = React.useCallback(() => { if (!editor) return; @@ -2559,21 +2606,25 @@ export function PortableTextEditor({ if (editor) { // For external providers, src is only used for admin preview // The frontend Image component uses provider + mediaId to generate proper URLs - editor - .chain() - .focus() - .setImage({ - src: item.url, - alt: item.alt || item.filename, - mediaId: item.id, - provider: item.provider || "local", - width: item.width, - height: item.height, - blurhash: item.blurhash, - dominantColor: item.dominantColor, - }) - .run(); + const attrs = { + src: item.url, + alt: item.alt || item.filename, + mediaId: item.id, + provider: item.provider || "local", + width: item.width, + height: item.height, + blurhash: item.blurhash, + dominantColor: item.dominantColor, + }; + const insertPos = pendingBlockInsertPosRef.current; + const chain = editor.chain().focus(); + if (insertPos === null) { + chain.setImage(attrs).run(); + } else { + chain.insertContentAt(insertPos, { type: "image", attrs }).run(); + } } + pendingBlockInsertPosRef.current = null; setMediaPickerOpen(false); }, [editor], @@ -2609,20 +2660,24 @@ export function PortableTextEditor({ .run(); } else { // Inserting a new block - editor - .chain() - .focus() - .insertContent({ - type: "pluginBlock", - attrs: { - blockType: pluginBlockModal.type, - id: typeof id === "string" ? id : "", - data, - }, - }) - .run(); + const content = { + type: "pluginBlock", + attrs: { + blockType: pluginBlockModal.type, + id: typeof id === "string" ? id : "", + data, + }, + }; + const insertPos = pendingBlockInsertPosRef.current; + const chain = editor.chain().focus(); + if (insertPos === null) { + chain.insertContent(content).run(); + } else { + chain.insertContentAt(insertPos, content).run(); + } } + pendingBlockInsertPosRef.current = null; setPluginBlockModal(null); setPluginBlockInitialValues(undefined); editingBlockPosRef.current = null; @@ -2634,12 +2689,9 @@ export function PortableTextEditor({ const handleSlashCommand = React.useCallback( (item: SlashCommandItem) => { const state = slashMenuStateRef.current; - if (editor && state.range) { - item.command({ editor, range: state.range }); - setSlashMenuState((prev) => ({ ...prev, isOpen: false, gutterBlockPos: null })); - } + executeSlashCommand(item, state); }, - [editor, setSlashMenuState], + [executeSlashCommand], ); // Handle section selection - insert section content at cursor @@ -2653,8 +2705,14 @@ export function PortableTextEditor({ : []; const { content: prosemirrorContent } = portableTextToProsemirror(ptContent); - // Insert the content at current cursor position - editor.chain().focus().insertContent(prosemirrorContent).run(); + const insertPos = pendingBlockInsertPosRef.current; + const chain = editor.chain().focus(); + if (insertPos === null) { + chain.insertContent(prosemirrorContent).run(); + } else { + chain.insertContentAt(insertPos, prosemirrorContent).run(); + } + pendingBlockInsertPosRef.current = null; }, [editor], ); @@ -2706,7 +2764,10 @@ export function PortableTextEditor({ {/* Media picker for image insertion */} { + setMediaPickerOpen(open); + if (!open) pendingBlockInsertPosRef.current = null; + }} onSelect={handleImageSelect} mimeTypeFilter="image/" title={t`Select Image`} @@ -2717,6 +2778,7 @@ export function PortableTextEditor({ block={pluginBlockModal} initialValues={pluginBlockInitialValues} onClose={() => { + pendingBlockInsertPosRef.current = null; setPluginBlockModal(null); setPluginBlockInitialValues(undefined); editingBlockPosRef.current = null; @@ -2727,7 +2789,10 @@ export function PortableTextEditor({ {/* Section picker modal */} { + setSectionPickerOpen(open); + if (!open) pendingBlockInsertPosRef.current = null; + }} onSelect={handleSectionSelect} />
@@ -3099,15 +3164,20 @@ function EditorToolbar({ ].filter((button) => button.getClientRects().length > 0); const currentIndex = buttons.findIndex((btn) => btn === document.activeElement); if (currentIndex === -1) return; + const isRtl = getComputedStyle(toolbar).direction === "rtl"; let nextIndex: number | null = null; switch (e.key) { case "ArrowRight": + nextIndex = (currentIndex + (isRtl ? -1 : 1) + buttons.length) % buttons.length; + break; + case "ArrowLeft": + nextIndex = (currentIndex + (isRtl ? 1 : -1) + buttons.length) % buttons.length; + break; case "ArrowDown": nextIndex = (currentIndex + 1) % buttons.length; break; - case "ArrowLeft": case "ArrowUp": nextIndex = (currentIndex - 1 + buttons.length) % buttons.length; break; diff --git a/packages/admin/src/components/editor/DragHandleWrapper.tsx b/packages/admin/src/components/editor/DragHandleWrapper.tsx index 29173f3593..55e1bca8ab 100644 --- a/packages/admin/src/components/editor/DragHandleWrapper.tsx +++ b/packages/admin/src/components/editor/DragHandleWrapper.tsx @@ -17,6 +17,7 @@ import type { Node as PMNode } from "@tiptap/pm/model"; import * as React from "react"; import { cn } from "../../lib/utils"; +import { getLocaleDir } from "../../locales/config.js"; import { BlockMenu } from "./BlockMenu"; interface DragHandleWrapperProps { @@ -29,17 +30,6 @@ interface HoveredNode { pos: number; } -// Extend Editor commands type to include DragHandle commands -declare module "@tiptap/core" { - interface Commands { - dragHandle: { - lockDragHandle: () => ReturnType; - unlockDragHandle: () => ReturnType; - toggleDragHandle: () => ReturnType; - }; - } -} - export function _getDragHandlePlacement(direction: "ltr" | "rtl") { return direction === "rtl" ? ("right-start" as const) : ("left-start" as const); } @@ -48,15 +38,40 @@ export function _getDragHandlePlacement(direction: "ltr" | "rtl") { * DragHandleWrapper - Official TipTap drag handle with BlockMenu integration */ export function DragHandleWrapper({ editor, onInsertBlock }: DragHandleWrapperProps) { - const { t } = useLingui(); - const direction = - editor.view.dom.ownerDocument.defaultView?.getComputedStyle(editor.view.dom).direction === "rtl" - ? "rtl" - : "ltr"; + const { i18n, t } = useLingui(); + const direction = getLocaleDir(i18n.locale); const [hoveredNode, setHoveredNode] = React.useState(null); const [menuOpen, setMenuOpen] = React.useState(false); const [menuAnchor, setMenuAnchor] = React.useState(null); const handleRef = React.useRef(null); + const insertPressLockedRef = React.useRef(false); + + const disableDrag = React.useCallback( + (e: React.PointerEvent) => { + e.stopPropagation(); + if (!insertPressLockedRef.current) { + insertPressLockedRef.current = true; + editor.commands.setMeta("lockDragHandle", true); + } + }, + [editor], + ); + + const restoreDrag = React.useCallback(() => { + if (insertPressLockedRef.current) { + insertPressLockedRef.current = false; + editor.commands.setMeta("lockDragHandle", menuOpen); + } + }, [editor, menuOpen]); + + React.useEffect(() => { + window.addEventListener("pointerup", restoreDrag, true); + window.addEventListener("pointercancel", restoreDrag, true); + return () => { + window.removeEventListener("pointerup", restoreDrag, true); + window.removeEventListener("pointercancel", restoreDrag, true); + }; + }, [restoreDrag]); // Handle click on drag handle to open menu const handleClick = React.useCallback( @@ -74,7 +89,7 @@ export function DragHandleWrapper({ editor, onInsertBlock }: DragHandleWrapperPr setMenuOpen(true); // Lock the drag handle so it stays visible while menu is open - editor.commands.lockDragHandle(); + editor.commands.setMeta("lockDragHandle", true); }, [editor, hoveredNode], ); @@ -94,7 +109,7 @@ export function DragHandleWrapper({ editor, onInsertBlock }: DragHandleWrapperPr const handleCloseMenu = React.useCallback(() => { setMenuOpen(false); setMenuAnchor(null); - editor.commands.unlockDragHandle(); + editor.commands.setMeta("lockDragHandle", false); }, [editor]); // Handle node change from drag handle @@ -130,13 +145,16 @@ export function DragHandleWrapper({ editor, onInsertBlock }: DragHandleWrapperPr onNodeChange={handleNodeChange} computePositionConfig={computePositionConfig} > -
+
+ ) : null, })); vi.mock("../../src/components/editor/DragHandleWrapper", () => ({ @@ -207,15 +235,16 @@ describe("Slash Command Menu", () => { it("opens from the gutter and cancels without inserting a slash", async () => { const { screen, editor, pm } = await renderEditor(); await focusEditor(pm); - const before = editor.getText(); + const before = editor.getJSON(); await screen.getByRole("button", { name: "Test gutter insert" }).click(); await waitForSlashMenu(); expect(editor.getText()).not.toContain("/"); + expect(editor.getJSON()).toEqual(before); await userEvent.keyboard("{Escape}"); await waitForSlashMenuClosed(); - expect(editor.getText()).toBe(before); + expect(editor.getJSON()).toEqual(before); }); it("discards an untouched gutter block when clicking outside the menu", async () => { @@ -231,6 +260,80 @@ describe("Slash Command Menu", () => { expect(editor.getText()).toBe(before); }); + it("does not leave a staging paragraph when a gutter command opens a modal", async () => { + const { screen, editor, pm } = await renderEditor(); + await focusEditor(pm); + const before = editor.getJSON(); + + await screen.getByRole("button", { name: "Test gutter insert" }).click(); + const menu = await waitForSlashMenu(); + const imageCommand = getSlashMenuItems(menu).find((item) => + item.textContent?.includes("Image"), + ); + expect(imageCommand).toBeTruthy(); + + imageCommand?.click(); + await waitForSlashMenuClosed(); + expect(editor.getJSON()).toEqual(before); + }); + + it("materializes a new block when a direct gutter command is selected", async () => { + const { screen, editor, pm } = await renderEditor(); + await focusEditor(pm); + + await screen.getByRole("button", { name: "Test gutter insert" }).click(); + const menu = await waitForSlashMenu(); + getSlashMenuItems(menu)[0]?.click(); + await waitForSlashMenuClosed(); + + const content = editor.getJSON().content; + expect(content?.[1]?.type).toBe("heading"); + expect(content?.[1]?.attrs?.level).toBe(1); + }); + + it("inserts modal-backed gutter content at the requested block position", async () => { + const { screen, editor, pm } = await renderEditor(); + await focusEditor(pm); + + await screen.getByRole("button", { name: "Test gutter insert" }).click(); + const menu = await waitForSlashMenu(); + const sectionCommand = getSlashMenuItems(menu).find((item) => + item.textContent?.includes("Section"), + ); + sectionCommand?.click(); + await screen.getByRole("button", { name: "Select test section" }).click(); + + const content = editor.getJSON().content; + expect(content).toHaveLength(2); + expect(content?.[1]?.content?.[0]?.text).toBe("Inserted section"); + }); + + it("materializes a new gutter paragraph when the user starts typing", async () => { + const { screen, editor, pm } = await renderEditor(); + await focusEditor(pm); + + await screen.getByRole("button", { name: "Test gutter insert" }).click(); + await waitForSlashMenu(); + await userEvent.keyboard("A"); + + await waitForSlashMenuClosed(); + const content = editor.getJSON().content; + expect(content).toHaveLength(2); + expect(content?.[1]?.content?.[0]?.text).toBe("A"); + }); + + it("does not materialize a gutter paragraph for modifier shortcuts", async () => { + const { screen, editor, pm } = await renderEditor(); + await focusEditor(pm); + const before = editor.getJSON(); + + await screen.getByRole("button", { name: "Test gutter insert" }).click(); + await waitForSlashMenu(); + await userEvent.keyboard("{Control>}b{/Control}"); + + expect(editor.getJSON()).toEqual(before); + }); + it("opens when typing / at the start of an empty line", async () => { const { editor, pm } = await renderEditor(); await focusEditor(pm); diff --git a/packages/admin/tests/editor/toolbar.test.tsx b/packages/admin/tests/editor/toolbar.test.tsx index 0f8f18045f..cd02d4b5d7 100644 --- a/packages/admin/tests/editor/toolbar.test.tsx +++ b/packages/admin/tests/editor/toolbar.test.tsx @@ -169,16 +169,19 @@ describe("Toolbar Presence and Structure", () => { it("collapses the supported heading levels into one menu", async () => { const { screen } = await renderEditor(); - await expect.element(getToolbarButton(screen, "Headings")).toBeVisible(); + const trigger = getToolbarButton(screen, "Headings"); + await expect.element(trigger).toBeVisible(); + await expect.element(trigger).toHaveAttribute("aria-haspopup", "menu"); - getToolbarButton(screen, "Headings").element().click(); + trigger.element().click(); await expect.element(screen.getByRole("menuitem", { name: "Heading 1" })).toBeVisible(); await expect.element(screen.getByRole("menuitem", { name: "Heading 2" })).toBeVisible(); await expect.element(screen.getByRole("menuitem", { name: "Heading 3" })).toBeVisible(); expect( - screen.getByRole("menuitem", { name: "Heading 1" }).element().hasAttribute( - "data-emdash-heading-item", - ), + screen + .getByRole("menuitem", { name: "Heading 1" }) + .element() + .hasAttribute("data-emdash-heading-item"), ).toBe(true); const headingLabels = Array.from( @@ -330,22 +333,23 @@ describe("Formatting Button Toggle States", () => { }); }); - it("Heading 1: click toggles aria-pressed to true and changes to h1", async () => { + it("Heading 1: click changes to h1 without exposing toggle semantics", async () => { const { screen, editor } = await renderEditor(); // Focus editor and place cursor (block commands need cursor in a paragraph) editor.commands.focus(); const { trigger, item } = await getHeadingMenuItem(screen, "Heading 1"); - await expect.element(trigger).toHaveAttribute("aria-pressed", "false"); + expect(trigger.element().hasAttribute("aria-pressed")).toBe(false); + await expect.element(trigger).toHaveAttribute("aria-expanded", "true"); item.element().click(); await vi.waitFor(() => { - expect(trigger.element().getAttribute("aria-pressed")).toBe("true"); + expect(trigger.element().hasAttribute("aria-pressed")).toBe(false); expect(editor.isActive("heading", { level: 1 })).toBe(true); }); }); - it("Heading 2: click toggles aria-pressed to true", async () => { + it("Heading 2: click changes to h2", async () => { const { screen, editor } = await renderEditor(); editor.commands.focus(); @@ -353,12 +357,12 @@ describe("Formatting Button Toggle States", () => { item.element().click(); await vi.waitFor(() => { - expect(trigger.element().getAttribute("aria-pressed")).toBe("true"); + expect(trigger.element().hasAttribute("aria-pressed")).toBe(false); expect(editor.isActive("heading", { level: 2 })).toBe(true); }); }); - it("Heading 3: click toggles aria-pressed to true", async () => { + it("Heading 3: click changes to h3", async () => { const { screen, editor } = await renderEditor(); editor.commands.focus(); @@ -366,7 +370,7 @@ describe("Formatting Button Toggle States", () => { item.element().click(); await vi.waitFor(() => { - expect(trigger.element().getAttribute("aria-pressed")).toBe("true"); + expect(trigger.element().hasAttribute("aria-pressed")).toBe(false); expect(editor.isActive("heading", { level: 3 })).toBe(true); }); }); @@ -824,6 +828,21 @@ describe("WAI-ARIA Keyboard Navigation", () => { }); }); + it("inverts horizontal arrow navigation in RTL", async () => { + const { screen } = await renderEditor(); + const toolbar = screen.getByRole("toolbar", { name: "Text formatting" }).element(); + const bold = screen.getByRole("button", { name: "Bold" }); + const italic = screen.getByRole("button", { name: "Italic" }); + toolbar.style.direction = "rtl"; + italic.element().focus(); + + await userEvent.keyboard("{ArrowRight}"); + + await vi.waitFor(() => { + expect(document.activeElement).toBe(bold.element()); + }); + }); + it("Home moves focus to first button", async () => { const { screen } = await renderEditor(); From d06b0aa9ac029607d612b0ca305c03fcd3f732ff Mon Sep 17 00:00:00 2001 From: Noah Pham Date: Tue, 14 Jul 2026 15:03:36 +0100 Subject: [PATCH 04/12] fix(admin): improve portable text editor affordances --- .changeset/refine-portable-text-editor.md | 2 +- .../admin/src/components/ContentEditor.tsx | 2 +- .../src/components/PortableTextEditor.tsx | 45 +++++++++-- .../tests/components/ContentEditor.test.tsx | 2 +- .../tests/editor/PortableTextEditor.test.tsx | 30 ++++---- .../admin/tests/editor/bubble-menu.test.tsx | 76 +++++++++++++++++-- packages/admin/tests/editor/toolbar.test.tsx | 1 + 7 files changed, 127 insertions(+), 31 deletions(-) diff --git a/.changeset/refine-portable-text-editor.md b/.changeset/refine-portable-text-editor.md index dcc22bd466..fe365127d5 100644 --- a/.changeset/refine-portable-text-editor.md +++ b/.changeset/refine-portable-text-editor.md @@ -2,4 +2,4 @@ "@emdash-cms/admin": patch --- -Improves portable text editing with a centered writing column, a responsive single-row toolbar, direct block insertion controls, and an unclipped link popover. +Improves portable text editing with a centered writing column, a responsive toolbar, clearer empty-state guidance, direct block insertion controls, and floating controls that avoid clipping and toolbar overlap. diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index cd19fa197e..98f72e2595 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -1174,7 +1174,7 @@ function FieldRenderer({ (null); // Use a ref for onChange to avoid recreating the editor when the callback changes const onChangeRef = React.useRef(onChange); @@ -2737,13 +2738,14 @@ export function PortableTextEditor({ > {!minimal && ( )} - +
@@ -2803,11 +2805,37 @@ export function PortableTextEditor({ * Bubble Menu - appears when text is selected * Shows inline formatting options and link editing */ -function EditorBubbleMenu({ editor }: { editor: Editor }) { +function EditorBubbleMenu({ + editor, + toolbarRef, +}: { + editor: Editor; + toolbarRef: React.RefObject; +}) { const [showLinkInput, setShowLinkInput] = React.useState(false); const [linkUrl, setLinkUrl] = React.useState(""); const inputRef = React.useRef(null); const { t } = useLingui(); + // The adjacent sticky toolbar is not a clipping ancestor, so Floating UI cannot detect it. + const getCollisionOptions = () => { + const viewport = window.visualViewport; + const viewportTop = viewport?.offsetTop ?? 0; + const viewportLeft = viewport?.offsetLeft ?? 0; + const viewportWidth = viewport?.width ?? window.innerWidth; + const viewportBottom = viewportTop + (viewport?.height ?? window.innerHeight); + const toolbarBottom = toolbarRef.current?.getBoundingClientRect().bottom ?? viewportTop; + const safeTop = Math.min(viewportBottom, Math.max(viewportTop, toolbarBottom)); + + return { + rootBoundary: { + x: viewportLeft, + y: safeTop, + width: viewportWidth, + height: Math.max(0, viewportBottom - safeTop), + }, + padding: 8, + }; + }; // When bubble menu opens with link input, populate the URL React.useEffect(() => { @@ -2852,8 +2880,8 @@ function EditorBubbleMenu({ editor }: { editor: Editor }) { options={{ placement: "top", offset: 8, - flip: true, - shift: true, + flip: getCollisionOptions, + shift: getCollisionOptions, }} className="z-[100] flex items-center gap-0.5 rounded-lg border bg-kumo-base p-1 shadow-lg" > @@ -3058,11 +3086,13 @@ function BubbleButton({ * Arrow keys move focus between buttons, Home/End jump to first/last. */ function EditorToolbar({ + toolbarRef, editor, focusMode, onFocusModeChange, onInsertBlock, }: { + toolbarRef: React.RefObject; editor: Editor; focusMode: FocusMode; onFocusModeChange: (mode: FocusMode) => void; @@ -3072,7 +3102,6 @@ function EditorToolbar({ const [mediaPickerOpen, setMediaPickerOpen] = React.useState(false); const [showLinkPopover, setShowLinkPopover] = React.useState(false); const [linkUrl, setLinkUrl] = React.useState(""); - const toolbarRef = React.useRef(null); const linkInputRef = React.useRef(null); // Subscribe to editor state changes for reactive button states @@ -3214,7 +3243,7 @@ function EditorToolbar({ className="hidden h-8 w-8 flex-none pointer-coarse:flex" onMouseDown={(event) => event.preventDefault()} onClick={onInsertBlock} - aria-label={t`Insert block below`} + aria-label={t`Insert block after current block`} tabIndex={-1} data-touch-block-insert > diff --git a/packages/admin/tests/components/ContentEditor.test.tsx b/packages/admin/tests/components/ContentEditor.test.tsx index 577d4c8fe5..1921a034dd 100644 --- a/packages/admin/tests/components/ContentEditor.test.tsx +++ b/packages/admin/tests/components/ContentEditor.test.tsx @@ -222,7 +222,7 @@ describe("ContentEditor", () => { fields: { content: { kind: "portableText", label: "Content" } }, }); - expect(portableTextProps.current?.placeholder).toBe("Type / for commands"); + expect(portableTextProps.current?.placeholder).toBe("Start writing, or type '/' for commands"); }); describe("block panel + mobile sheet sync", () => { diff --git a/packages/admin/tests/editor/PortableTextEditor.test.tsx b/packages/admin/tests/editor/PortableTextEditor.test.tsx index 95dde6742e..1a9057f749 100644 --- a/packages/admin/tests/editor/PortableTextEditor.test.tsx +++ b/packages/admin/tests/editor/PortableTextEditor.test.tsx @@ -450,11 +450,13 @@ describe("Portable Text ↔ ProseMirror conversion", () => { expect(textContent.trim()).toBe(""); }); - it("teaches slash commands in the default placeholder", async () => { + it("teaches writing and slash commands in the default placeholder", async () => { await render(); const pm = await waitForEditor(); const placeholder = pm.querySelector("[data-placeholder]"); - expect(placeholder?.getAttribute("data-placeholder")).toBe("Type / for commands"); + expect(placeholder?.getAttribute("data-placeholder")).toBe( + "Start writing, or type '/' for commands", + ); }); it("centers the writing column with responsive space for block controls", async () => { @@ -652,11 +654,13 @@ describe("Toolbar", () => { await expect.element(screen.getByRole("button", { name: "Inline Code" })).toBeInTheDocument(); }); - it("has heading buttons", async () => { + it("has a heading menu", async () => { const screen = await renderWithToolbar(); - await expect.element(screen.getByRole("button", { name: "Heading 1" })).toBeInTheDocument(); - await expect.element(screen.getByRole("button", { name: "Heading 2" })).toBeInTheDocument(); - await expect.element(screen.getByRole("button", { name: "Heading 3" })).toBeInTheDocument(); + const trigger = screen.getByRole("button", { name: "Headings" }); + await trigger.click(); + await expect.element(screen.getByRole("menuitem", { name: "Heading 1" })).toBeInTheDocument(); + await expect.element(screen.getByRole("menuitem", { name: "Heading 2" })).toBeInTheDocument(); + await expect.element(screen.getByRole("menuitem", { name: "Heading 3" })).toBeInTheDocument(); }); it("has list buttons", async () => { @@ -740,19 +744,19 @@ describe("Toolbar", () => { ); }); - it("toggles Heading 1 aria-pressed when clicked", async () => { + it("changes the current block to Heading 1 from the heading menu", async () => { const screen = await renderWithToolbar(); const pm = document.querySelector(".ProseMirror") as HTMLElement; await focusEditor(pm); - const h1Btn = screen.getByRole("button", { name: "Heading 1" }); - await expect.element(h1Btn).toHaveAttribute("aria-pressed", "false"); - - await h1Btn.click(); + const trigger = screen.getByRole("button", { name: "Headings" }); + await trigger.click(); + await screen.getByRole("menuitem", { name: "Heading 1" }).click(); await vi.waitFor( - async () => { - await expect.element(h1Btn).toHaveAttribute("aria-pressed", "true"); + () => { + expect(pm.querySelector("h1")).toBeTruthy(); + expect(trigger.element().hasAttribute("aria-pressed")).toBe(false); }, { timeout: 2000 }, ); diff --git a/packages/admin/tests/editor/bubble-menu.test.tsx b/packages/admin/tests/editor/bubble-menu.test.tsx index 505fbc2f90..1ac577f4bf 100644 --- a/packages/admin/tests/editor/bubble-menu.test.tsx +++ b/packages/admin/tests/editor/bubble-menu.test.tsx @@ -104,17 +104,25 @@ const defaultValue = [ }, ]; -async function renderEditor(props: Partial = {}) { +async function renderEditor(props: Partial = {}, scrollContainerTop = 0) { let editorInstance: Editor | null = null; const screen = await render( - { - editorInstance = editor; +
, + > + { + editorInstance = editor; + }} + {...props} + /> +
, ); await vi.waitFor( @@ -192,6 +200,60 @@ describe("Bubble Menu", () => { expect(menu).toBeTruthy(); }); + it("flips below a top-line selection when the sticky toolbar blocks the preferred position", async () => { + const { editor, pm } = await renderEditor(); + await focusAndSelectAll(editor, pm); + + const menu = await waitForBubbleMenu(); + const toolbar = document.querySelector( + '[role="toolbar"][aria-label="Text formatting"]', + ); + expect(toolbar).toBeTruthy(); + + await vi.waitFor(() => { + const selection = window.getSelection(); + expect(selection?.rangeCount).toBe(1); + const selectionRect = selection!.getRangeAt(0).getBoundingClientRect(); + const menuRect = menu.getBoundingClientRect(); + expect(menuRect.top).toBeGreaterThanOrEqual(selectionRect.bottom); + expect(menuRect.top).toBeGreaterThanOrEqual(toolbar!.getBoundingClientRect().bottom); + }); + }); + + it("stays above the selection when there is room below the sticky toolbar", async () => { + const value = ["First line", "Second line", "Third line"].map((text, index) => ({ + _type: "block" as const, + _key: String(index), + style: "normal" as const, + children: [{ _type: "span" as const, _key: `span-${index}`, text }], + })); + const { editor, pm } = await renderEditor({ value }, 58); + pm.focus(); + + let textPosition = 0; + editor.state.doc.descendants((node, position) => { + if (node.isText && node.text === "Third line") { + textPosition = position; + return false; + } + return undefined; + }); + editor + .chain() + .focus() + .setTextSelection({ from: textPosition, to: textPosition + 10 }) + .run(); + + const menu = await waitForBubbleMenu(); + await vi.waitFor(() => { + const selection = window.getSelection(); + expect(selection?.rangeCount).toBe(1); + const selectionRect = selection!.getRangeAt(0).getBoundingClientRect(); + const menuRect = menu.getBoundingClientRect(); + expect(menuRect.bottom).toBeLessThanOrEqual(selectionRect.top); + }); + }); + it("shows formatting buttons: Bold, Italic, Underline, Strikethrough, Code", async () => { const { editor, pm } = await renderEditor(); await focusAndSelectAll(editor, pm); diff --git a/packages/admin/tests/editor/toolbar.test.tsx b/packages/admin/tests/editor/toolbar.test.tsx index cd02d4b5d7..2477ca4d33 100644 --- a/packages/admin/tests/editor/toolbar.test.tsx +++ b/packages/admin/tests/editor/toolbar.test.tsx @@ -239,6 +239,7 @@ describe("Toolbar Presence and Structure", () => { expect(touchInsert).toBeTruthy(); expect(touchInsert?.className).toContain("pointer-coarse:flex"); + expect(touchInsert?.getAttribute("aria-label")).toBe("Insert block after current block"); touchInsert?.click(); await vi.waitFor(() => { expect(document.querySelector("body > div [data-index]")).toBeTruthy(); From d0ed490020b41e1c8020f3d458e2b3c6c224356e Mon Sep 17 00:00:00 2001 From: Noah Pham Date: Tue, 14 Jul 2026 15:24:42 +0100 Subject: [PATCH 05/12] feat(admin): streamline editor toolbar --- .../src/components/PortableTextEditor.tsx | 64 +------------------ .../tests/editor/PortableTextEditor.test.tsx | 11 ++-- .../admin/tests/editor/slash-menu.test.tsx | 23 +++++++ packages/admin/tests/editor/toolbar.test.tsx | 41 +++++------- 4 files changed, 47 insertions(+), 92 deletions(-) diff --git a/packages/admin/src/components/PortableTextEditor.tsx b/packages/admin/src/components/PortableTextEditor.tsx index 38944d4031..7114878b49 100644 --- a/packages/admin/src/components/PortableTextEditor.tsx +++ b/packages/admin/src/components/PortableTextEditor.tsx @@ -3099,7 +3099,6 @@ function EditorToolbar({ onInsertBlock: () => void; }) { const { t } = useLingui(); - const [mediaPickerOpen, setMediaPickerOpen] = React.useState(false); const [showLinkPopover, setShowLinkPopover] = React.useState(false); const [linkUrl, setLinkUrl] = React.useState(""); const linkInputRef = React.useRef(null); @@ -3162,25 +3161,6 @@ function EditorToolbar({ } }; - const handleImageSelect = React.useCallback( - (item: MediaItem) => { - editor - .chain() - .focus() - .setImage({ - src: item.url, - alt: item.alt || item.filename, - mediaId: item.id, - width: item.width, - height: item.height, - blurhash: item.blurhash, - dominantColor: item.dominantColor, - }) - .run(); - }, - [editor], - ); - // Keyboard navigation for toolbar (WAI-ARIA toolbar pattern) const handleKeyDown = React.useCallback((e: React.KeyboardEvent) => { const toolbar = toolbarRef.current; @@ -3231,7 +3211,8 @@ function EditorToolbar({ ref={toolbarRef} role="toolbar" aria-label={t`Text formatting`} - className="sticky -top-6 z-10 border-b bg-kumo-tint p-1 flex flex-nowrap gap-0.5 overflow-x-auto" + className="sticky -top-6 z-10 flex flex-nowrap gap-0.5 overflow-x-auto border-b bg-kumo-tint p-1" + style={{ justifyContent: "safe center" }} onKeyDown={handleKeyDown} > {/* Text formatting */} @@ -3325,15 +3306,6 @@ function EditorToolbar({ >
- setMediaPickerOpen(true)} title={t`Insert Image`}> - - - editor - .chain() - .focus() - .insertContent({ type: "htmlBlock", attrs: { html: "" } }) - .run() - } - title={t`Insert HTML`} - > - - editor.chain().focus().setHorizontalRule().run()} - title={t`Insert Horizontal Rule`} - > -
); } diff --git a/packages/admin/tests/editor/PortableTextEditor.test.tsx b/packages/admin/tests/editor/PortableTextEditor.test.tsx index 1a9057f749..38d956122b 100644 --- a/packages/admin/tests/editor/PortableTextEditor.test.tsx +++ b/packages/admin/tests/editor/PortableTextEditor.test.tsx @@ -682,13 +682,14 @@ describe("Toolbar", () => { await expect.element(screen.getByRole("button", { name: "Align Right" })).toBeInTheDocument(); }); - it("has insert buttons", async () => { + it("keeps insertion-only actions out of the formatting toolbar", async () => { const screen = await renderWithToolbar(); + const toolbar = screen.getByRole("toolbar").element(); await expect.element(screen.getByRole("button", { name: "Insert Link" })).toBeInTheDocument(); - await expect.element(screen.getByRole("button", { name: "Insert Image" })).toBeInTheDocument(); - await expect - .element(screen.getByRole("button", { name: "Insert Horizontal Rule" })) - .toBeInTheDocument(); + expect(toolbar.querySelector('[aria-label="Insert Table"]')).toBeNull(); + expect(toolbar.querySelector('[aria-label="Insert Image"]')).toBeNull(); + expect(toolbar.querySelector('[aria-label="Insert HTML"]')).toBeNull(); + expect(toolbar.querySelector('[aria-label="Insert Horizontal Rule"]')).toBeNull(); }); it("has history buttons (initially disabled)", async () => { diff --git a/packages/admin/tests/editor/slash-menu.test.tsx b/packages/admin/tests/editor/slash-menu.test.tsx index 9e7ae13891..8ff4ab5409 100644 --- a/packages/admin/tests/editor/slash-menu.test.tsx +++ b/packages/admin/tests/editor/slash-menu.test.tsx @@ -362,7 +362,9 @@ describe("Slash Command Menu", () => { expect(titles).toContain("Numbered List"); expect(titles).toContain("Quote"); expect(titles).toContain("Code Block"); + expect(titles).toContain("HTML"); expect(titles).toContain("Divider"); + expect(titles).toContain("Table"); }); it("shows descriptions for each command", async () => { @@ -579,6 +581,27 @@ describe("Slash Command Menu", () => { }); }); + it("inserts an HTML block via slash command", async () => { + const { editor, pm } = await renderEditor(); + await focusEditor(pm); + editor.commands.insertContent("/"); + + const menu = await waitForSlashMenu(); + const htmlBtn = getSlashMenuItems(menu).find( + (btn) => btn.querySelector(".font-medium")?.textContent === "HTML", + ); + expect(htmlBtn).toBeTruthy(); + htmlBtn!.click(); + + await waitForSlashMenuClosed(); + + await vi.waitFor(() => { + const htmlBlock = editor.getJSON().content?.find((node) => node.type === "htmlBlock"); + expect(htmlBlock).toBeDefined(); + expect((htmlBlock as { attrs?: { html?: string } }).attrs?.html).toBe(""); + }); + }); + it("inserts bullet list via slash command", async () => { const { editor, pm } = await renderEditor(); await focusEditor(pm); diff --git a/packages/admin/tests/editor/toolbar.test.tsx b/packages/admin/tests/editor/toolbar.test.tsx index 2477ca4d33..d2528daf72 100644 --- a/packages/admin/tests/editor/toolbar.test.tsx +++ b/packages/admin/tests/editor/toolbar.test.tsx @@ -158,6 +158,14 @@ describe("Toolbar Presence and Structure", () => { await expect.element(toolbar).toHaveAttribute("aria-label", "Text formatting"); }); + it("centers controls when they fit and preserves horizontal overflow", async () => { + const { screen } = await renderEditor(); + const toolbar = screen.getByRole("toolbar", { name: "Text formatting" }).element(); + + expect(toolbar.className).toContain("overflow-x-auto"); + expect(getComputedStyle(toolbar).justifyContent).toBe("safe center"); + }); + it("has all formatting buttons", async () => { const { screen } = await renderEditor(); await expect.element(screen.getByRole("button", { name: "Bold" })).toBeVisible(); @@ -210,14 +218,14 @@ describe("Toolbar Presence and Structure", () => { await expect.element(screen.getByRole("button", { name: "Align Right" })).toBeVisible(); }); - it("has all insert buttons", async () => { + it("keeps insertion-only actions in the block menu", async () => { const { screen } = await renderEditor(); + const toolbar = screen.getByRole("toolbar", { name: "Text formatting" }).element(); await expect.element(screen.getByRole("button", { name: "Insert Link" })).toBeVisible(); - await expect.element(screen.getByRole("button", { name: "Insert Image" })).toBeVisible(); - await expect.element(screen.getByRole("button", { name: "Insert HTML" })).toBeVisible(); - await expect - .element(screen.getByRole("button", { name: "Insert Horizontal Rule" })) - .toBeVisible(); + expect(toolbar.querySelector('[aria-label="Insert Table"]')).toBeNull(); + expect(toolbar.querySelector('[aria-label="Insert Image"]')).toBeNull(); + expect(toolbar.querySelector('[aria-label="Insert HTML"]')).toBeNull(); + expect(toolbar.querySelector('[aria-label="Insert Horizontal Rule"]')).toBeNull(); }); it("renders the link editor outside the horizontally scrolling toolbar", async () => { @@ -600,26 +608,7 @@ describe("Undo/Redo", () => { }); // ============================================================================= -// 5. HTML Block Insertion -// ============================================================================= - -describe("HTML Block Insertion", () => { - it("clicking Insert HTML inserts an empty HTML block", async () => { - const { screen, editor } = await renderEditor(); - editor.commands.focus("end"); - - getToolbarButton(screen, "Insert HTML").element().click(); - - await vi.waitFor(() => { - const htmlBlock = editor.getJSON().content?.find((node) => node.type === "htmlBlock"); - expect(htmlBlock).toBeDefined(); - expect((htmlBlock as { attrs?: { html?: string } }).attrs?.html).toBe(""); - }); - }); -}); - -// ============================================================================= -// 6. Link Insertion (Toolbar Popover) +// 5. Link Insertion (Toolbar Popover) // ============================================================================= describe("Link Insertion", () => { From c6ffd6e1ff82c556bf33b586ed232ae9ce097584 Mon Sep 17 00:00:00 2001 From: Noah Pham Date: Wed, 15 Jul 2026 17:26:46 +0100 Subject: [PATCH 06/12] fix(admin): harden portable text editor menus --- .../src/components/PortableTextEditor.tsx | 522 ++++++++++++------ .../admin/tests/editor/bubble-menu.test.tsx | 227 ++++++++ .../admin/tests/editor/slash-menu.test.tsx | 51 ++ packages/admin/tests/editor/toolbar.test.tsx | 38 +- 4 files changed, 663 insertions(+), 175 deletions(-) diff --git a/packages/admin/src/components/PortableTextEditor.tsx b/packages/admin/src/components/PortableTextEditor.tsx index 7114878b49..263b05b449 100644 --- a/packages/admin/src/components/PortableTextEditor.tsx +++ b/packages/admin/src/components/PortableTextEditor.tsx @@ -11,7 +11,7 @@ * - Floating menu on empty lines */ -import { Button, Dialog, Input, Popover, Select, Switch } from "@cloudflare/kumo"; +import { Button, Dialog, Input, Popover, Select, Switch, Toolbar, Tooltip } from "@cloudflare/kumo"; import { DndContext, KeyboardSensor, @@ -80,10 +80,12 @@ import { TableHeader } from "@tiptap/extension-table-header"; import { TableRow } from "@tiptap/extension-table-row"; import TextAlign from "@tiptap/extension-text-align"; import Typography from "@tiptap/extension-typography"; +import { AllSelection, TextSelection } from "@tiptap/pm/state"; +import { CellSelection } from "@tiptap/pm/tables"; import { useEditor, EditorContent, useEditorState, type Editor } from "@tiptap/react"; import { BubbleMenu } from "@tiptap/react/menus"; import StarterKit from "@tiptap/starter-kit"; -import Suggestion from "@tiptap/suggestion"; +import Suggestion, { exitSuggestion } from "@tiptap/suggestion"; import * as React from "react"; import { createPortal } from "react-dom"; @@ -107,6 +109,14 @@ import { import { MediaPickerModal } from "./MediaPickerModal"; import { SectionPickerModal } from "./SectionPickerModal"; +const INLINE_BUBBLE_MENU_KEY = "emdashInlineBubbleMenu"; +const TABLE_BUBBLE_MENU_KEY = "emdashTableBubbleMenu"; + +type BubbleMenuCollisionOptions = () => { + rootBoundary: { x: number; y: number; width: number; height: number }; + padding: number; +}; + // Import converters from inline module since we can't import from emdash package // These will be duplicated here until we set up proper package exports @@ -1131,6 +1141,7 @@ interface SlashMenuState { range: Range | null; trigger: "slash" | "gutter"; gutterBlockPos: number | null; + dismissedSlashFrom: number | null; } /** @@ -1145,6 +1156,14 @@ function createSlashCommandsExtension(options: { return Extension.create({ name: "slashCommands", + onTransaction() { + const state = getState(); + if (state.dismissedSlashFrom === null) return; + const to = Math.min(state.dismissedSlashFrom + 1, this.editor.state.doc.content.size); + if (this.editor.state.doc.textBetween(state.dismissedSlashFrom, to) !== "/") { + onStateChange((prev) => ({ ...prev, dismissedSlashFrom: null })); + } + }, addProseMirrorPlugins() { return [ @@ -1157,6 +1176,7 @@ function createSlashCommandsExtension(options: { item.command({ editor, range }); }, items: ({ query }) => filterCommands(query), + allow: ({ range }) => getState().dismissedSlashFrom !== range.from, render: () => { return { onStart: (props) => { @@ -1168,6 +1188,7 @@ function createSlashCommandsExtension(options: { range: props.range, trigger: "slash", gutterBlockPos: null, + dismissedSlashFrom: null, }); }, onUpdate: (props) => { @@ -1179,11 +1200,17 @@ function createSlashCommandsExtension(options: { range: props.range, trigger: "slash", gutterBlockPos: null, + dismissedSlashFrom: null, })); }, onKeyDown: (props) => { if (props.event.key === "Escape") { - onStateChange((prev) => ({ ...prev, isOpen: false })); + onStateChange((prev) => ({ + ...prev, + isOpen: false, + dismissedSlashFrom: props.range.from, + })); + exitSuggestion(props.view); return true; } @@ -2134,6 +2161,27 @@ export function PortableTextEditor({ const placeholderRef = React.useRef(placeholder ?? t`Start writing, or type '/' for commands`); placeholderRef.current = placeholder ?? t`Start writing, or type '/' for commands`; const toolbarRef = React.useRef(null); + const floatingRootRef = React.useRef(null); + const appendBubbleMenu = React.useCallback(() => floatingRootRef.current!, []); + const getBubbleMenuCollisionOptions = React.useCallback(() => { + const viewport = window.visualViewport; + const viewportTop = viewport?.offsetTop ?? 0; + const viewportLeft = viewport?.offsetLeft ?? 0; + const viewportWidth = viewport?.width ?? window.innerWidth; + const viewportBottom = viewportTop + (viewport?.height ?? window.innerHeight); + const toolbarBottom = toolbarRef.current?.getBoundingClientRect().bottom ?? viewportTop; + const safeTop = Math.min(viewportBottom, Math.max(viewportTop, toolbarBottom)); + + return { + rootBoundary: { + x: viewportLeft, + y: safeTop, + width: viewportWidth, + height: Math.max(0, viewportBottom - safeTop), + }, + padding: 8, + }; + }, []); // Use a ref for onChange to avoid recreating the editor when the callback changes const onChangeRef = React.useRef(onChange); @@ -2176,6 +2224,7 @@ export function PortableTextEditor({ range: null, trigger: "slash", gutterBlockPos: null, + dismissedSlashFrom: null, }); // Ref to access current state synchronously in keyboard handlers. @@ -2388,9 +2437,15 @@ export function PortableTextEditor({ const openBlockInsertMenuAt = React.useCallback( (insertPos: number) => { if (!editor) return; + const state = slashMenuStateRef.current; + const activeSlashFrom = state.trigger === "slash" && state.isOpen ? state.range?.from : null; + if (activeSlashFrom != null) { + setSlashMenuState((prev) => ({ ...prev, dismissedSlashFrom: activeSlashFrom })); + } + exitSuggestion(editor.view); editor.commands.focus(); - setSlashMenuState({ + setSlashMenuState((prev) => ({ isOpen: true, items: filterCommandsRef.current(""), selectedIndex: 0, @@ -2409,14 +2464,25 @@ export function PortableTextEditor({ range: { from: insertPos, to: insertPos }, trigger: "gutter", gutterBlockPos: insertPos, - }); + dismissedSlashFrom: prev.dismissedSlashFrom, + })); }, [editor, setSlashMenuState], ); const closeSlashMenu = React.useCallback(() => { - setSlashMenuState((prev) => ({ ...prev, isOpen: false, gutterBlockPos: null })); - }, [setSlashMenuState]); + const state = slashMenuStateRef.current; + setSlashMenuState((prev) => ({ + ...prev, + isOpen: false, + gutterBlockPos: null, + dismissedSlashFrom: + state.trigger === "slash" ? (state.range?.from ?? null) : prev.dismissedSlashFrom, + })); + if (editor && state.trigger === "slash") { + exitSuggestion(editor.view); + } + }, [editor, setSlashMenuState]); const executeSlashCommand = React.useCallback( (item: SlashCommandItem, state: SlashMenuState) => { @@ -2546,6 +2612,27 @@ export function PortableTextEditor({ return undefined; }, [editor, onEditorReady]); + React.useEffect(() => { + const viewport = window.visualViewport; + if (!editor || !viewport) return; + + const updateBubbleMenuPositions = () => { + if (editor.isDestroyed) return; + editor.view.dispatch( + editor.state.tr + .setMeta(INLINE_BUBBLE_MENU_KEY, "updatePosition") + .setMeta(TABLE_BUBBLE_MENU_KEY, "updatePosition"), + ); + }; + + viewport.addEventListener("resize", updateBubbleMenuPositions); + viewport.addEventListener("scroll", updateBubbleMenuPositions); + return () => { + viewport.removeEventListener("resize", updateBubbleMenuPositions); + viewport.removeEventListener("scroll", updateBubbleMenuPositions); + }; + }, [editor]); + // Register plugin blocks into editor storage so the node view can look up metadata React.useEffect(() => { if (editor) { @@ -2727,76 +2814,87 @@ export function PortableTextEditor({ } return ( -
- {!minimal && ( - - )} - - -
- - {editable && } -
- {!minimal && } - - {/* Slash command menu */} - - setSlashMenuState((prev) => ({ ...prev, selectedIndex: index })) - } +
+ - - {/* Media picker for image insertion */} - { - setMediaPickerOpen(open); - if (!open) pendingBlockInsertPosRef.current = null; - }} - onSelect={handleImageSelect} - mimeTypeFilter="image/" - title={t`Select Image`} + +
+ {!minimal && ( + + )} +
+ + {editable && } +
+ {!minimal && } + + {/* Slash command menu */} + + setSlashMenuState((prev) => ({ ...prev, selectedIndex: index })) + } + /> - {/* Plugin block insertion/editing modal */} - { - pendingBlockInsertPosRef.current = null; - setPluginBlockModal(null); - setPluginBlockInitialValues(undefined); - editingBlockPosRef.current = null; - }} - onInsert={handlePluginBlockInsert} - /> + {/* Media picker for image insertion */} + { + setMediaPickerOpen(open); + if (!open) pendingBlockInsertPosRef.current = null; + }} + onSelect={handleImageSelect} + mimeTypeFilter="image/" + title={t`Select Image`} + /> - {/* Section picker modal */} - { - setSectionPickerOpen(open); - if (!open) pendingBlockInsertPosRef.current = null; - }} - onSelect={handleSectionSelect} - /> + {/* Plugin block insertion/editing modal */} + { + pendingBlockInsertPosRef.current = null; + setPluginBlockModal(null); + setPluginBlockInitialValues(undefined); + editingBlockPosRef.current = null; + }} + onInsert={handlePluginBlockInsert} + /> + + {/* Section picker modal */} + { + setSectionPickerOpen(open); + if (!open) pendingBlockInsertPosRef.current = null; + }} + onSelect={handleSectionSelect} + /> +
); } @@ -2807,36 +2905,28 @@ export function PortableTextEditor({ */ function EditorBubbleMenu({ editor, - toolbarRef, + appendTo, + getCollisionOptions, }: { editor: Editor; - toolbarRef: React.RefObject; + appendTo: () => HTMLElement; + getCollisionOptions: BubbleMenuCollisionOptions; }) { const [showLinkInput, setShowLinkInput] = React.useState(false); const [linkUrl, setLinkUrl] = React.useState(""); const inputRef = React.useRef(null); const { t } = useLingui(); - // The adjacent sticky toolbar is not a clipping ancestor, so Floating UI cannot detect it. - const getCollisionOptions = () => { - const viewport = window.visualViewport; - const viewportTop = viewport?.offsetTop ?? 0; - const viewportLeft = viewport?.offsetLeft ?? 0; - const viewportWidth = viewport?.width ?? window.innerWidth; - const viewportBottom = viewportTop + (viewport?.height ?? window.innerHeight); - const toolbarBottom = toolbarRef.current?.getBoundingClientRect().bottom ?? viewportTop; - const safeTop = Math.min(viewportBottom, Math.max(viewportTop, toolbarBottom)); - - return { - rootBoundary: { - x: viewportLeft, - y: safeTop, - width: viewportWidth, - height: Math.max(0, viewportBottom - safeTop), - }, - padding: 8, - }; - }; - + const activeMarks = useEditorState({ + editor, + selector: ({ editor: activeEditor }) => ({ + bold: activeEditor.isActive("bold"), + italic: activeEditor.isActive("italic"), + underline: activeEditor.isActive("underline"), + strike: activeEditor.isActive("strike"), + code: activeEditor.isActive("code"), + link: activeEditor.isActive("link"), + }), + }); // When bubble menu opens with link input, populate the URL React.useEffect(() => { if (showLinkInput) { @@ -2877,12 +2967,32 @@ function EditorBubbleMenu({ return ( ({ + ...getCollisionOptions(), + apply: ({ availableWidth, elements }) => { + elements.floating.style.maxWidth = `${Math.max(0, availableWidth)}px`; + elements.floating.style.overflowX = "auto"; + }, + }), + }} + shouldShow={({ editor: activeEditor, element, state, view }) => { + const { selection } = state; + return ( + activeEditor.isEditable && + (selection instanceof TextSelection || selection instanceof AllSelection) && + !selection.empty && + (view.hasFocus() || element.contains(document.activeElement)) + ); }} + data-emdash-inline-bubble-menu className="z-[100] flex items-center gap-0.5 rounded-lg border bg-kumo-base p-1 shadow-lg" > {showLinkInput ? ( @@ -2907,7 +3017,7 @@ function EditorBubbleMenu({ > - {editor.isActive("link") && ( + {activeMarks.link && ( @@ -3168,7 +3359,7 @@ function EditorToolbar({ const buttons = [ ...toolbar.querySelectorAll( - 'button:not([disabled]):not([data-touch-block-insert]), [role="button"]:not([disabled]):not([data-touch-block-insert])', + 'button:not([disabled]), [role="button"]:not([disabled])', ), ].filter((button) => button.getClientRects().length > 0); const currentIndex = buttons.findIndex((btn) => btn === document.activeElement); @@ -3225,7 +3416,6 @@ function EditorToolbar({ onMouseDown={(event) => event.preventDefault()} onClick={onInsertBlock} aria-label={t`Insert block after current block`} - tabIndex={-1} data-touch-block-insert >