From 29d979f60186fcba8fa51ec75d818a15f6cf3d41 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:05:04 +0100 Subject: [PATCH 1/4] Dock phone formatting to the keyboard as one contextual bar On a phone the formatting dock was a floating pill beside a floating Done button, with its last commands scrolled out of sight and Insert opening a small popover far from the thumb. It now runs edge to edge across the top of the keyboard, with Done at its trailing end, and follows what the writer is doing: a caret gets Insert, Text style, Undo and Redo; a selection gets Bold, Italic, Strikethrough, Inline code, Link and Comment. Hide keyboard stays pinned. Insert and Text style let the keyboard go on purpose and show their choices at its height, then give the caret back where it was, so the keyboard returns. Undo and Redo follow the shared history's stacks. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/phone-workspace.css | 67 ++- apps/web/src/room-workspace.tsx | 9 +- e2e/phone-read-mode.e2e.ts | 140 ++++-- e2e/responsive-child-phone.e2e.ts | 3 +- packages/editor/src/history.ts | 39 ++ packages/editor/src/plan-editor.tsx | 5 + packages/editor/src/styles.css | 78 +++- .../editor/src/toolbar/dock-panel.test.ts | 38 ++ packages/editor/src/toolbar/dock-panel.tsx | 100 +++++ packages/editor/src/toolbar/dock.tsx | 414 ++++++++++++++---- packages/editor/src/toolbar/index.tsx | 10 +- packages/editor/src/widget-options.ts | 2 + packages/icons/src/index.ts | 2 + packages/icons/src/text-style.tsx | 14 + packages/icons/src/undo.tsx | 23 + packages/visuals/theme.css | 3 + 16 files changed, 802 insertions(+), 145 deletions(-) create mode 100644 packages/editor/src/toolbar/dock-panel.test.ts create mode 100644 packages/editor/src/toolbar/dock-panel.tsx create mode 100644 packages/icons/src/text-style.tsx create mode 100644 packages/icons/src/undo.tsx diff --git a/apps/web/src/phone-workspace.css b/apps/web/src/phone-workspace.css index 140ec09b5..98066ff5e 100644 --- a/apps/web/src/phone-workspace.css +++ b/apps/web/src/phone-workspace.css @@ -78,14 +78,12 @@ /* * While formatting docks above the keyboard, the caret's block stops short of the dock. The - * workspace already ends at the keyboard (`--app-height`), so only the dock's row is added. + * workspace already ends at the keyboard (`--app-height`), so only the dock's bar is added. */ .workspace-root[data-workspace-phone]:has(.phone-tab-row[data-phone-tab-toolbar]) .plan [data-plan-scroll] { - scroll-padding-bottom: calc( - var(--phone-tab-bar-height) + var(--phone-tab-bar-offset) + var(--spacing) * 3 - ); + scroll-padding-bottom: calc(var(--phone-dock-height) + var(--spacing) * 3); } /* Focus and find-in-page scrolling stop below the top bar, never under it. */ @@ -266,7 +264,6 @@ } .phone-tab-bar, -.phone-tab-toolbar, .phone-tab-action { background: var(--color-glass); -webkit-backdrop-filter: var(--glass-filter); @@ -290,25 +287,65 @@ display: none; } -/* Editing on a phone: formatting takes the tabs' place and rides above the keyboard. */ +/* + * Editing on a phone: formatting takes the tabs' place as one bar across the top of the + * keyboard, edge to edge like a native keyboard accessory, with Done at its trailing end. An + * Insert or Text style panel stands below the bar where the keyboard was. + */ :root[data-keyboard-open] .phone-tab-row[data-phone-tab-toolbar] { visibility: visible; transform: none; } -.phone-tab-toolbar { - flex: 1; - min-width: 0; - height: var(--phone-tab-bar-height); - border-radius: var(--radius-full); - overflow: hidden; +.phone-tab-row[data-phone-tab-toolbar] { + bottom: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + width: 100%; + gap: 0; + padding-inline: env(safe-area-inset-left) calc(var(--spacing) + env(safe-area-inset-right)); + /* Opaque, as a keyboard's own bar is: prose sliding under the icons would read as clutter. */ + background: var(--color-page); + border-top: var(--edge-width) solid var(--color-edge); animation: phone-tab-toolbar-in var(--tab-slide-dur) var(--tab-slide-ease); } +/* The host and the editor's portal add no boxes: the bar, its panel and Done share one grid. */ +.phone-tab-toolbar { + display: contents; +} + +.phone-tab-row[data-phone-tab-toolbar] .plan-format-panel { + grid-row: 2; + grid-column: 1 / -1; + margin-inline: calc(-1 * env(safe-area-inset-left)) + calc(-1 * (var(--spacing) + env(safe-area-inset-right))); +} + +/* A filled check inside a full-size target: the same Done as the bar's round button. */ +.phone-tab-row[data-phone-tab-toolbar] > .phone-tab-action { + grid-row: 1; + grid-column: 2; + width: var(--control-target-coarse); + height: var(--control-target-coarse); + padding: var(--spacing); + background-clip: content-box; + /* The bar's edge hugs the smaller fill, where its brand colour disappears into it. */ + outline-offset: calc(-1 * (var(--spacing) + var(--edge-width))); + -webkit-backdrop-filter: none; + backdrop-filter: none; + box-shadow: none; +} + +.phone-tab-row[data-phone-tab-toolbar] > .phone-tab-action > svg { + width: calc(var(--spacing) * 5); + height: calc(var(--spacing) * 5); +} + @keyframes phone-tab-toolbar-in { from { - opacity: 0; - transform: translateY(calc(var(--spacing) * 3)) scale(0.98); + transform: translateY(100%); } } @@ -528,7 +565,7 @@ transition: none; } - .phone-tab-toolbar { + .phone-tab-row[data-phone-tab-toolbar] { animation: none; } } diff --git a/apps/web/src/room-workspace.tsx b/apps/web/src/room-workspace.tsx index 5fa0e5728..96b01c69b 100644 --- a/apps/web/src/room-workspace.tsx +++ b/apps/web/src/room-workspace.tsx @@ -358,6 +358,8 @@ export function RoomWorkspace( // survives tab changes and reconnects, and the next document opens reading again. let [editing, setEditing] = useState(() => opensEditing({ renaming: titleEdit === "new" })); let [formatDock, setFormatDock] = useState(null); + // The dock's Insert or Text style panel stands where the keyboard was. + let [formatPanel, setFormatPanel] = useState(false); let keyboardOpen = useKeyboardOpen(); useEffect(() => { let listen = (event: Event) => { @@ -992,7 +994,9 @@ export function RoomWorkspace( // What this room's own editor shows: a child is on screen only while its parent's Document is. let shown = childRoom ? !!childChrome?.visible : planVisible && !childOpen; // Only the document's own keyboard gets formatting: never Chat, a comment reply, or the title. - let docked = editMode === "editing" && keyboardOpen && shown && editorFocused; + // A panel lets the keyboard go on purpose, so it keeps the dock up in the keyboard's place. + let docked = editMode === "editing" && shown + && (formatPanel || (keyboardOpen && editorFocused)); let toggleEditing = () => { // Synchronous, so the editor takes focus inside the tap and iOS raises the keyboard. flushSync(() => { @@ -1174,6 +1178,7 @@ export function RoomWorkspace( disclosureMotion={motionContract("collapse")} editMode={editMode} formatDock={docked ? childRoom ? childChrome?.formatDock : formatDock : undefined} + onFormatPanel={setFormatPanel} motionImmediately={settleMotionImmediately} onScrollTop={setPlanScrollTop} onState={setPlanState} @@ -1361,7 +1366,7 @@ export function RoomWorkspace( )} // Formatting rides above the keyboard while typing. With the keyboard down the tabs // return, and a selection gets the ordinary bubble instead. - phoneToolbar={barEdit?.docked &&
} + phoneToolbar={barEdit?.docked &&
} identity={room} mode={mode} presence={} diff --git a/e2e/phone-read-mode.e2e.ts b/e2e/phone-read-mode.e2e.ts index e4f05d9de..5c3ab38fe 100644 --- a/e2e/phone-read-mode.e2e.ts +++ b/e2e/phone-read-mode.e2e.ts @@ -143,24 +143,66 @@ test("formatting docks above the keyboard while editing", async ({ baseURL, brow await expect(dock).toBeVisible(); await expect(page.getByRole("navigation", { name: "Workspace view" })).toBeHidden(); await expect(done(page)).toBeVisible(); - // Layout size, not the box: the dock rises in with a brief scale. - for (let control of await dock.getByRole("button").all()) { + // One bar across the top of the keyboard, edge to edge, with Done at its trailing end. + let row = page.locator("[data-phone-tab-row]"); + await row.evaluate(element => + Promise.all(element.getAnimations({ subtree: true }).map(animation => animation.finished)) + ); + let bar = (await row.boundingBox())!; + expect(bar.x).toBe(0); + expect(bar.width).toBe(390); + expect(Math.round(bar.y + bar.height)).toBe(500); + let doneBox = (await done(page).boundingBox())!; + expect(doneBox.x + doneBox.width).toBeGreaterThan(390 - 12); + expect(doneBox.y).toBeGreaterThanOrEqual(bar.y); + // Layout size, not the box. + for (let control of await row.getByRole("button").all()) { let size = await control.evaluate(element => { let html = element as HTMLElement; return Math.min(html.offsetWidth, html.offsetHeight); }); expect(size).toBeGreaterThanOrEqual(44); } - let bottom = await dock.evaluate(element => element.getBoundingClientRect().bottom); - expect(bottom).toBeLessThanOrEqual(500); + // A caret gets blocks, styles and history. + for (let name of ["Insert block", "Text style", "Undo", "Redo", "Hide keyboard"]) { + await expect(dock.getByRole("button", { name, exact: true })).toBeVisible(); + } + await expect(dock.getByRole("button", { name: "Bold" })).toHaveCount(0); + + await page.keyboard.type("loud "); + await page.keyboard.press("ArrowLeft"); + for (let step = 0; step < 4; step++) await page.keyboard.press("Shift+ArrowLeft"); + // A selection gets marks, a link and a comment instead. + for ( + let name of [ + "Bold", + "Italic", + "Strikethrough", + "Inline code", + "Link", + "Comment on this passage", + "Hide keyboard", + ] + ) { + await expect(dock.getByRole("button", { name, exact: true })).toBeVisible(); + } + await expect(dock.getByRole("button", { name: "Undo" })).toHaveCount(0); await dock.getByRole("button", { name: "Bold" }).tap(); - await page.keyboard.type("loud"); - await expect(content(page).locator("strong, b").filter({ hasText: "loud" })).toHaveCount(1); + let loud = content(page).locator("strong, b").filter({ hasText: "loud" }); + await expect(loud).toHaveCount(1); await expect(content(page)).toBeFocused(); // One formatting surface at a time. await expect(page.getByRole("toolbar", { name: "Text formatting" })).toHaveCount(0); + // Undo and redo act on this writer's edits without taking the keyboard down. + await page.keyboard.press("ArrowRight"); + await dock.getByRole("button", { name: "Undo" }).tap(); + await expect(loud).toHaveCount(0); + await expect(content(page)).toBeFocused(); + await dock.getByRole("button", { name: "Redo" }).tap(); + await expect(loud).toHaveCount(1); + // The way out is pinned, never scrolled off the row. let hide = dock.getByRole("button", { name: "Hide keyboard" }); let pinned = await hide.evaluate(element => { @@ -170,20 +212,6 @@ test("formatting docks above the keyboard while editing", async ({ baseURL, brow }); expect(pinned).toBe(true); - // A writer can comment while the keyboard is up. - await content(page).getByText("Members lose").evaluate(element => { - let text = document.createTreeWalker(element, NodeFilter.SHOW_TEXT).nextNode()!; - let range = document.createRange(); - range.setStart(text, 0); - range.setEnd(text, 7); - getSelection()!.removeAllRanges(); - getSelection()!.addRange(range); - }); - await expect(dock.getByRole("button", { name: "Comment on this passage" })).toBeVisible(); - // Collapse the selection; the typing above may have split the second paragraph's words. - await page.keyboard.press("ArrowRight"); - await expect(dock.getByRole("button", { name: "Comment on this passage" })).toHaveCount(0); - await hide.tap(); await expect(content(page)).not.toBeFocused(); await setVisualViewport(page, { event: "resize", height: 844 }); @@ -299,7 +327,7 @@ test("only the document's own keyboard gets the formatting dock", async ({ baseU } }); -test("Insert block starts a new paragraph and keeps its menu above the dock", async ({ baseURL, browser, room, seed }) => { +test("Insert stands in for the keyboard and gives the caret back", async ({ baseURL, browser, room, seed }) => { await seed(SOURCE); let { close, page } = await openIsolatedRoom(browser, baseURL!, room, "ana", PHONE, fakeKeyboard); try { @@ -307,15 +335,69 @@ test("Insert block starts a new paragraph and keeps its menu above the dock", as await pencil(page).tap(); await setVisualViewport(page, { event: "resize", height: 500 }); let dock = page.getByRole("toolbar", { name: "Formatting" }); - await dock.getByRole("button", { name: "Insert block" }).tap(); + let insert = dock.getByRole("button", { name: "Insert block" }); + await insert.tap(); + let panel = page.getByRole("group", { name: "Insert block" }); + await expect(panel).toBeFocused(); + await expect(insert).toHaveAttribute("aria-expanded", "true"); + // Focus left the editor, so the keyboard goes; the panel takes its place at its height. + await setVisualViewport(page, { event: "resize", height: 844 }); + await expect(dock).toBeVisible(); + await expect(page.getByRole("navigation", { name: "Workspace view" })).toBeHidden(); + let [panelBox, dockBox] = await Promise.all([panel.boundingBox(), dock.boundingBox()]); + expect(Math.round(panelBox!.y + panelBox!.height)).toBe(844); + expect(Math.round(panelBox!.height)).toBe(344); + expect(Math.round(dockBox!.y + dockBox!.height)).toBeLessThanOrEqual(Math.round(panelBox!.y)); + for (let choice of await panel.getByRole("button").all()) { + expect((await choice.boundingBox())!.height).toBeGreaterThanOrEqual(44); + } + // The caret's paragraph stays in sight above the panel. + let paragraph = content(page).locator("p").filter({ hasText: "Second paragraph" }); + expect((await paragraph.boundingBox())!.y + 12).toBeLessThanOrEqual(dockBox!.y); + + await panel.getByRole("button", { name: "Table" }).tap(); + await expect(content(page)).toBeFocused(); + await expect(content(page).locator("table")).toHaveCount(1); + // The paragraph keeps its words: the table goes after it, not into it. await expect(content(page)).toContainText("Second paragraph for the caret."); - let menu = page.getByRole("listbox", { name: "Insert block" }); - await expect(menu).toBeVisible(); - let [menuBottom, dockTop] = await Promise.all([ - menu.evaluate(element => element.getBoundingClientRect().bottom), - dock.evaluate(element => element.getBoundingClientRect().top), - ]); - expect(menuBottom).toBeLessThanOrEqual(dockTop); + await setVisualViewport(page, { event: "resize", height: 500 }); + await expect(panel).toHaveCount(0); + await expect(dock).toBeVisible(); + } finally { + await close(); + } +}); + +test("Text style turns the caret's block into a heading", async ({ baseURL, browser, room, seed }) => { + await seed(SOURCE); + let { close, page } = await openIsolatedRoom(browser, baseURL!, room, "ana", PHONE, fakeKeyboard); + try { + await page.getByText("Second paragraph").tap(); + await pencil(page).tap(); + await setVisualViewport(page, { event: "resize", height: 500 }); + let dock = page.getByRole("toolbar", { name: "Formatting" }); + await dock.getByRole("button", { name: "Text style" }).tap(); + let panel = page.getByRole("group", { name: "Text style" }); + await expect(panel.getByRole("button", { name: "Text", exact: true })) + .toHaveAttribute("aria-pressed", "true"); + // Escape, or the same button again, returns to the keyboard unchanged. + await page.keyboard.press("Escape"); + await expect(content(page)).toBeFocused(); + await expect(panel).toHaveCount(0); + + await dock.getByRole("button", { name: "Text style" }).tap(); + await setVisualViewport(page, { event: "resize", height: 844 }); + await panel.getByRole("button", { name: "Heading 2" }).tap(); + await expect(content(page)).toBeFocused(); + await expect(content(page).locator("h2")).toHaveText("Second paragraph for the caret."); + await page.keyboard.type("!"); + await expect(content(page).locator("h2")).toContainText("!"); + + // Leaving the panel for anywhere else closes it and the dock with it. + await dock.getByRole("button", { name: "Insert block" }).tap(); + await dock.getByRole("button", { name: "Hide keyboard" }).tap(); + await expect(dock).toHaveCount(0); + await expect(page.getByRole("navigation", { name: "Workspace view" })).toBeVisible(); } finally { await close(); } diff --git a/e2e/responsive-child-phone.e2e.ts b/e2e/responsive-child-phone.e2e.ts index 0bb616ed3..091ea31a4 100644 --- a/e2e/responsive-child-phone.e2e.ts +++ b/e2e/responsive-child-phone.e2e.ts @@ -181,8 +181,9 @@ test("a child's formatting docks above the keyboard in the parent's bar", async let dock = page.getByRole("toolbar", { name: "Formatting" }); await expect(dock).toBeVisible(); await expect(page.getByRole("navigation", { name: "Workspace view" })).toBeHidden(); - await dock.getByRole("button", { name: "Bold" }).tap(); await page.keyboard.type("loud"); + for (let step = 0; step < 4; step++) await page.keyboard.press("Shift+ArrowLeft"); + await dock.getByRole("button", { name: "Bold" }).tap(); await expect(childText(surface).locator("strong, b").filter({ hasText: "loud" })) .toHaveCount(1); await expect(childText(surface)).toBeFocused(); diff --git a/packages/editor/src/history.ts b/packages/editor/src/history.ts index 6e85c86c1..f0a081057 100644 --- a/packages/editor/src/history.ts +++ b/packages/editor/src/history.ts @@ -290,6 +290,35 @@ export function rehearse( } } +/** Each editor's history, so a control outside the binding can show what it can step to. */ +const histories = new WeakMap< + LexicalEditor, + { manager: Y.UndoManager; changed: Set<() => void> } +>(); + +export type HistorySteps = { undo: boolean; redo: boolean }; + +/** + * Follow whether undo and redo have a step to take. An editor without a shared history yet + * reports both as available; the commands themselves are harmless when there is nothing. + */ +export function listenToHistory( + editor: LexicalEditor, + listener: (steps: HistorySteps) => void, +): () => void { + let history = histories.get(editor); + if (!history) { + listener({ undo: true, redo: true }); + return () => {}; + } + let { changed, manager } = history; + let report = () => + listener({ undo: manager.undoStack.length > 0, redo: manager.redoStack.length > 0 }); + report(); + changed.add(report); + return () => changed.delete(report); +} + /** Why an undo did not happen, as the person is told. */ export type Refusal = "others" | "change"; @@ -316,6 +345,14 @@ export function registerPlanHistory( trackedOrigins: new Set([binding]), captureTimeout, }); + let changed = new Set<() => void>(); + let notify = () => { + for (let listener of changed) listener(); + }; + manager.on("stack-item-added", notify); + manager.on("stack-item-popped", notify); + manager.on("stack-cleared", notify); + histories.set(editor, { manager, changed }); let step = (direction: "undo" | "redo") => { if (!editor.isEditable()) return false; @@ -328,6 +365,7 @@ export function registerPlanHistory( // Only the refused step goes. Older ones still reverse cleanly or are // refused in turn, since each is rehearsed against the room as it is. stack.splice(-verdict.steps); + notify(); onRefused?.(verdict.reason === "others" ? "others" : "change"); } return true; @@ -359,6 +397,7 @@ export function registerPlanHistory( dispose() { stopUndo(); stopRedo(); + if (histories.get(editor)?.manager === manager) histories.delete(editor); manager.destroy(); }, }; diff --git a/packages/editor/src/plan-editor.tsx b/packages/editor/src/plan-editor.tsx index 5adb2cab1..cecd57062 100644 --- a/packages/editor/src/plan-editor.tsx +++ b/packages/editor/src/plan-editor.tsx @@ -94,6 +94,8 @@ export type PlanEditorProps = { editMode?: EditMode; /** Host element for the docked formatting toolbar while editing on a phone. */ formatDock?: HTMLElement | null; + /** Told while the docked formatting's panel holds the keyboard's place; keep the dock up. */ + onFormatPanel?: (open: boolean) => void; /** * Where the plan's questionnaires are published. * @@ -165,6 +167,7 @@ export function PlanEditor( disclosureMotion, editMode, formatDock, + onFormatPanel, motionImmediately, onScrollTop, onState, @@ -494,6 +497,7 @@ export function PlanEditor( editMode, blocked, formatDock, + onFormatPanel, self: user.name, synced: state.synced, }), @@ -532,6 +536,7 @@ export function PlanEditor( editMode, blocked, formatDock, + onFormatPanel, state.synced, ], ); diff --git a/packages/editor/src/styles.css b/packages/editor/src/styles.css index cc4e0be5f..813e5d98b 100644 --- a/packages/editor/src/styles.css +++ b/packages/editor/src/styles.css @@ -5553,10 +5553,8 @@ body:has(.plan-comment-sheet-popup[data-swiping]) .plan-format-dock { display: flex; align-items: center; - gap: calc(var(--spacing) / 2); min-width: 0; - height: 100%; - padding-inline: var(--spacing); + height: var(--phone-dock-height); -webkit-user-select: none; user-select: none; } @@ -5567,8 +5565,9 @@ body:has(.plan-comment-sheet-popup[data-swiping]) flex: 1; align-items: center; justify-content: space-between; - gap: calc(var(--spacing) / 2); min-width: 0; + /* A wide landscape bar keeps its commands together at the start rather than strewn across it. */ + max-width: var(--phone-tab-row-max); overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none; @@ -5624,6 +5623,11 @@ body:has(.plan-comment-sheet-popup[data-swiping]) color var(--duration-fast) var(--ease-out); } +/* Hide keyboard sits at the trailing end, beside Done, however wide the bar. */ +.plan-format-dock > .plan-format-dock-button { + margin-inline-start: auto; +} + .plan-format-dock-button:active:not(:disabled), .plan-format-dock-button[aria-pressed="true"] { background: var(--color-selected); @@ -5633,3 +5637,69 @@ body:has(.plan-comment-sheet-popup[data-swiping]) .plan-format-dock-button:disabled { opacity: var(--opacity-quiet); } + +/* Insert and Text style: the choices stand where the keyboard was, at its height. */ +.plan-format-panel { + box-sizing: border-box; + height: var(--phone-format-panel-height); + padding: calc(var(--spacing) * 3) calc(var(--spacing) * 3) + calc(var(--spacing) * 3 + env(safe-area-inset-bottom)); + overflow-y: auto; + overscroll-behavior: contain; + border-top: var(--edge-width) solid var(--color-edge); + background: var(--color-ground); + -webkit-user-select: none; + user-select: none; +} + +.plan-format-panel-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: calc(var(--spacing) * 2); +} + +.plan-format-panel-choice { + display: flex; + align-items: center; + gap: calc(var(--spacing) * 2); + min-width: 0; + min-height: calc(var(--control-target-coarse) + var(--spacing)); + padding-inline: calc(var(--spacing) * 2.5); + border-radius: var(--radius-md); + background: var(--color-page); + outline: var(--edge-width) solid var(--color-edge); + outline-offset: calc(-1 * var(--edge-width)); + color: var(--color-text-primary); + font-size: var(--text-sm); + text-align: left; + touch-action: manipulation; + -webkit-touch-callout: none; + transition: background-color var(--duration-fast) var(--ease-out); +} + +.plan-format-panel-choice:active:not(:disabled) { + background: var(--color-selected); +} + +.plan-format-panel-choice[aria-pressed="true"] { + background: var(--color-brand-wash); + color: var(--color-brand-ink); +} + +.plan-format-panel-choice:disabled { + opacity: var(--opacity-quiet); +} + +.plan-format-panel-glyph { + display: grid; + flex: none; + place-items: center; + width: calc(var(--spacing) * 6); + color: var(--color-text-secondary); + font-size: var(--text-base); + line-height: 1; +} + +.plan-format-panel-choice[aria-pressed="true"] .plan-format-panel-glyph { + color: inherit; +} diff --git a/packages/editor/src/toolbar/dock-panel.test.ts b/packages/editor/src/toolbar/dock-panel.test.ts new file mode 100644 index 000000000..20dab4860 --- /dev/null +++ b/packages/editor/src/toolbar/dock-panel.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from "bun:test"; + +import { insertChoices, keyboardCover, styleChoices } from "./dock-panel"; + +test("a panel takes the height the software keyboard covered", () => { + expect(keyboardCover(844, { top: 0, height: 500 })).toBe(344); + // A page the browser scrolled up under the keyboard still measures from the layout bottom. + expect(keyboardCover(844, { top: 40, height: 460 })).toBe(344); +}); + +test("browser toolbars and missing viewports are not a keyboard", () => { + expect(keyboardCover(844, { top: 0, height: 780 })).toBeUndefined(); + expect(keyboardCover(844, undefined)).toBeUndefined(); +}); + +test("Insert offers the slash menu's blocks, and Research only where the host has it", () => { + let plain = insertChoices(false).map(choice => choice.id); + expect(plain).toContain("table"); + expect(plain).not.toContain("research"); + expect(insertChoices(true).map(choice => choice.id).at(-1)).toBe("research"); +}); + +test("Text style marks the caret's current block", () => { + let choices = styleChoices("h2"); + expect(choices.filter(choice => choice.current).map(choice => choice.label)).toEqual([ + "Heading 2", + ]); + expect(choices.map(choice => choice.label)).toEqual([ + "Text", + "Heading 1", + "Heading 2", + "Heading 3", + "Quote", + "Bulleted list", + "Numbered list", + "Task list", + ]); +}); diff --git a/packages/editor/src/toolbar/dock-panel.tsx b/packages/editor/src/toolbar/dock-panel.tsx new file mode 100644 index 000000000..fe7b3e831 --- /dev/null +++ b/packages/editor/src/toolbar/dock-panel.tsx @@ -0,0 +1,100 @@ +/** + * The choices behind the phone dock's Insert and Text style buttons. + * + * A web page cannot replace the software keyboard, so the panel takes the keyboard's place + * instead: the dock lets the keyboard go and shows the panel at its height, and a choice gives + * the caret back to the editor, which brings the keyboard back up. + */ + +import { CheckIcon, ListIcon } from "@chopin/icons"; +import { INSERT_CHECK_LIST_COMMAND } from "@lexical/list"; +import { $createParagraphNode, $getSelection, $isRangeSelection } from "lexical"; + +import { convertBlock } from "./bubble"; +import { availableCommands } from "./slash"; +import { nativeSelectionRect } from "./surface"; + +import type { ReactNode } from "react"; +import type { LexicalEditor } from "lexical"; +import type { Block } from "./bubble"; + +export type DockPanel = "insert" | "style"; + +/** Taller than any browser toolbar, shorter than any software keyboard. */ +const KEYBOARD_MIN = 120; + +/** How much of the layout viewport the software keyboard covers, if it is up. */ +export function keyboardCover( + innerHeight: number, + visual: { top: number; height: number } | undefined, +): number | undefined { + if (!visual) return undefined; + let covered = Math.round(innerHeight - visual.top - visual.height); + return covered >= KEYBOARD_MIN ? covered : undefined; +} + +export type PanelChoice = { + id: string; + label: string; + glyph: ReactNode; + /** Set for a style: whether the caret's block already has it. */ + current?: boolean; + run: (editor: LexicalEditor) => void; +}; + +/** A new block goes after one with words in it, as the slash menu's does, never inside them. */ +function $openBlock() { + let selection = $getSelection(); + if (!$isRangeSelection(selection)) return; + let block = selection.anchor.getNode().getTopLevelElement(); + if (block && block.getTextContentSize() > 0) { + let paragraph = $createParagraphNode(); + block.insertAfter(paragraph); + paragraph.select(); + } +} + +/** The slash menu's blocks, and Research where the host offers it. */ +export function insertChoices(research: boolean): PanelChoice[] { + return availableCommands("") + .filter(command => command.kind === "insert" || research) + .map(command => ({ + id: command.id, + label: command.label, + glyph: , + run: editor => { + editor.update($openBlock, { discrete: true }); + if (command.kind === "insert") return command.run(editor); + // Nothing was typed to start it, so there is no trigger to consume. + let anchor = nativeSelectionRect(); + if (anchor) command.run(editor, { anchor, consume: () => true }); + }, + })); +} + +/** Each glyph is drawn the way the style looks, so the panel reads like a type specimen. */ +const STYLES: Array<{ id: Block; label: string; glyph: ReactNode }> = [ + { id: "paragraph", label: "Text", glyph: T }, + { id: "h1", label: "Heading 1", glyph: H1 }, + { id: "h2", label: "Heading 2", glyph: H2 }, + { id: "h3", label: "Heading 3", glyph: H3 }, + { id: "quote", label: "Quote", glyph: “ }, + { id: "bullet", label: "Bulleted list", glyph: }, + { id: "number", label: "Numbered list", glyph: 1. }, + { id: "task", label: "Task list", glyph: }, +]; + +export function styleChoices(current: Block): PanelChoice[] { + return STYLES.map(style => ({ + ...style, + current: style.id === current, + run: editor => { + if (style.id === current) return; + if (style.id === "task") { + editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined); + return; + } + convertBlock(editor, current, style.id); + }, + })); +} diff --git a/packages/editor/src/toolbar/dock.tsx b/packages/editor/src/toolbar/dock.tsx index ff3077f0a..a5b4162d0 100644 --- a/packages/editor/src/toolbar/dock.tsx +++ b/packages/editor/src/toolbar/dock.tsx @@ -1,52 +1,98 @@ /** * Formatting docked above a phone's keyboard while editing. * - * The same commands as the selection bubble, in a fixed row the thumb can reach, so a phone - * never shows two formatting surfaces at once. Nothing here may take focus: the caret it acts on - * would go with it, and on a phone so would the keyboard. + * The row follows what the writer is doing, as native editors' keyboard bars do: with only a + * caret it offers blocks, text styles and history; with a selection it offers marks, a link and + * a comment. Nothing in the row may take focus: the caret it acts on would go with it, and on a + * phone so would the keyboard. + * + * Insert and Text style are the exception. They let the keyboard go on purpose and show their + * choices at its height, then a choice gives the caret back. See `dock-panel.tsx`. */ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { flushSync } from "react-dom"; import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; -import { KeyboardIcon, LinkIcon, ListIcon, MessagePlusIcon, PlusIcon } from "@chopin/icons"; -import { $getSelection, $isRangeSelection, FORMAT_TEXT_COMMAND } from "lexical"; +import { currentViewport, listenToViewportChanges } from "@chopin/viewport"; +import { + CodeIcon, + KeyboardIcon, + LinkIcon, + MessagePlusIcon, + PlusIcon, + RedoIcon, + TextStyleIcon, + UndoIcon, +} from "@chopin/icons"; +import { + $getNodeByKey, + $getSelection, + $isRangeSelection, + $setSelection, + FORMAT_TEXT_COMMAND, + REDO_COMMAND, + UNDO_COMMAND, +} from "lexical"; -import { $block, convertBlock } from "./bubble"; +import { listenToHistory } from "../history"; +import { $block } from "./bubble"; +import { insertChoices, keyboardCover, styleChoices } from "./dock-panel"; import { $linkAt, OPEN_LINK_EDITOR_COMMAND } from "./link"; -import { OPEN_SLASH_MENU_COMMAND } from "./slash"; import type { ReactNode } from "react"; +import type { RangeSelection } from "lexical"; import type { Block } from "./bubble"; +import type { DockPanel, PanelChoice } from "./dock-panel"; type DockState = { bold: boolean; italic: boolean; + strikethrough: boolean; + code: boolean; block: Block; - /** Some text is selected, so it can be linked or commented on. */ + /** Some text is selected, so it can be marked, linked or commented on. */ selected: boolean; - /** A link can be added over a selection, or edited with the caret inside one. */ - linkable: boolean; + /** The caret sits inside a link, which can be edited without selecting it. */ + inLink: boolean; }; const IDLE: DockState = { bold: false, italic: false, + strikethrough: false, + code: false, block: "paragraph", selected: false, - linkable: false, + inLink: false, }; -/** Blocks the Heading button treats as already being a heading, so it turns them back. */ -const HEADINGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]); +/** Gives up a choice's hold on the keyboard's space if the keyboard never comes back. */ +const RETURN_TIMEOUT = 800; + +/** Space kept between the caret's block and the top of an open panel. */ +const CARET_CLEARANCE = 12; export function FormatDock( - { disabled, onComment }: { disabled?: boolean; onComment?: () => void }, + { disabled, onComment, onPanel, research = false }: { + disabled?: boolean; + onComment?: () => void; + /** Told while a panel holds the keyboard's place, so the host keeps the dock up. */ + onPanel?: (open: boolean) => void; + research?: boolean; + }, ) { let [editor] = useLexicalComposerContext(); let [state, setState] = useState(IDLE); + let [history, setHistory] = useState({ undo: true, redo: true }); let scroller = useRef(null); /** Which edges of the row have buttons scrolled past them; those edges fade. */ let [edges, setEdges] = useState({ start: false, end: false }); + /** `returning` keeps the panel's space while the keyboard comes back over it. */ + let [panel, setPanel] = useState<{ kind: DockPanel; block: Block; returning?: boolean }>(); + let [panelHeight, setPanelHeight] = useState(); + let panelElement = useRef(null); + let toolbar = useRef(null); + let saved = useRef(null); let sync = useCallback(() => { editor.getEditorState().read(() => { @@ -55,9 +101,11 @@ export function FormatDock( setState({ bold: selection.hasFormat("bold"), italic: selection.hasFormat("italic"), + strikethrough: selection.hasFormat("strikethrough"), + code: selection.hasFormat("code"), block: $block(selection.anchor.getNode()), selected: !selection.isCollapsed(), - linkable: !selection.isCollapsed() || !!$linkAt(selection), + inLink: selection.isCollapsed() && !!$linkAt(selection), }); }); }, [editor]); @@ -67,6 +115,47 @@ export function FormatDock( return editor.registerUpdateListener(sync); }, [editor, sync]); + useEffect(() => listenToHistory(editor, setHistory), [editor]); + + let open = !!panel; + useEffect(() => { + onPanel?.(open); + }, [onPanel, open]); + useEffect(() => () => onPanel?.(false), [onPanel]); + + // A choice hands the caret back; the panel keeps its space until the keyboard is up again. + let returning = !!panel?.returning; + useEffect(() => { + if (!returning) return; + let release = () => setPanel(undefined); + let check = () => { + if (keyboardCover(innerHeight, currentViewport())) release(); + }; + let timer = setTimeout(release, RETURN_TIMEOUT); + let stop = listenToViewportChanges(check); + // A keyboard that never went down, such as one the browser kept up, has nothing to wait for. + check(); + return () => { + clearTimeout(timer); + stop(); + }; + }, [returning]); + + // The caret's block stays above the panel, as it would above the keyboard. + useLayoutEffect(() => { + if (!open) return; + // Measured where the bar will rest once the keyboard has gone, not where it is now. + let bar = toolbar.current?.offsetHeight; + let height = panelElement.current?.offsetHeight; + let top = bar === undefined || height === undefined ? undefined : innerHeight - height - bar; + let key = saved.current?.anchor.key; + let block = key ? editor.getElementByKey(key)?.closest(".plan-content > *") : undefined; + let scroll = block?.closest("[data-plan-scroll]"); + if (top === undefined || !block || !scroll) return; + let overlap = block.getBoundingClientRect().bottom + CARET_CLEARANCE - top; + if (overlap > 0) scroll.scrollTop += overlap; + }, [editor, open]); + let commentable = !!onComment && state.selected; useLayoutEffect(() => { let element = scroller.current; @@ -86,98 +175,235 @@ export function FormatDock( observer.disconnect(); element.removeEventListener("scroll", measure); }; - }, [commentable]); + }, [commentable, state.selected]); + + let openPanel = (kind: DockPanel) => { + if (panel?.kind === kind && !panel.returning) return returnToEditor(); + let keyboard = keyboardCover(innerHeight, currentViewport()); + if (keyboard) setPanelHeight(keyboard); + if (!panel) { + saved.current = editor.getEditorState().read(() => { + let selection = $getSelection(); + return $isRangeSelection(selection) ? selection.clone() : null; + }); + } + // The editor may let go of its selection once it loses focus; keep what the caret was in. + let block = panel?.block ?? state.block; + flushSync(() => setPanel({ kind, block })); + // Focus leaves the editor, so iOS lowers the keyboard and the panel shows in its place. + panelElement.current?.focus({ preventScroll: true }); + }; - let heading = HEADINGS.has(state.block); - let listed = state.block === "bullet"; + /** Give the caret back where it was, inside the tap, so iOS raises the keyboard again. */ + let returnToEditor = (choice?: PanelChoice) => { + let root = editor.getRootElement(); + let selection = saved.current; + flushSync(() => setPanel(current => current && { ...current, returning: true })); + root?.focus({ preventScroll: true }); + editor.update(() => { + if ( + selection && $getNodeByKey(selection.anchor.key) && $getNodeByKey(selection.focus.key) + ) { + $setSelection(selection.clone()); + } + }, { discrete: true }); + saved.current = null; + choice?.run(editor); + }; + + let hideKeyboard = () => { + setPanel(undefined); + saved.current = null; + let active = document.activeElement; + if (active instanceof HTMLElement) active.blur(); + }; + + let choices = panel?.kind === "insert" + ? insertChoices(research) + : panel?.kind === "style" + ? styleChoices(panel.block) + : []; + let panelLabel = panel?.kind === "insert" ? "Insert block" : "Text style"; return ( -
event.preventDefault()} - onPointerDown={event => event.preventDefault()} - role="toolbar" - > + <>
event.preventDefault()} + onPointerDown={event => event.preventDefault()} + ref={toolbar} + role="toolbar" > - editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold")} - pressed={state.bold} - > - B - - editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic")} - pressed={state.italic} - > - I - - convertBlock(editor, state.block, heading ? "paragraph" : "h2")} - pressed={heading} +
- H - - convertBlock(editor, state.block, listed ? "paragraph" : "bullet")} - pressed={listed} - > - - - editor.dispatchCommand(OPEN_LINK_EDITOR_COMMAND, undefined)} - > - - - {commentable && ( - onComment?.()}> - - - )} - editor.dispatchCommand(OPEN_SLASH_MENU_COMMAND, undefined)} - > - + {state.selected + ? ( + <> + editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold")} + pressed={state.bold} + > + B + + editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic")} + pressed={state.italic} + > + I + + editor.dispatchCommand(FORMAT_TEXT_COMMAND, "strikethrough")} + pressed={state.strikethrough} + > + S + + editor.dispatchCommand(FORMAT_TEXT_COMMAND, "code")} + pressed={state.code} + > + + + editor.dispatchCommand(OPEN_LINK_EDITOR_COMMAND, undefined)} + > + + + {commentable && ( + onComment?.()} + > + + + )} + + ) + : ( + <> + openPanel("insert")} + > + + + openPanel("style")} + > + + + editor.dispatchCommand(UNDO_COMMAND, undefined)} + > + + + editor.dispatchCommand(REDO_COMMAND, undefined)} + > + + + {state.inLink && ( + editor.dispatchCommand(OPEN_LINK_EDITOR_COMMAND, undefined)} + > + + + )} + + )} +
+ {/* Pinned outside the scrolling row, so the way out is never cut off. */} + +
- {/* Pinned outside the scrolling row, so the way out is never cut off. */} - { - let active = document.activeElement; - if (active instanceof HTMLElement) active.blur(); - }} - > - - -
+ {panel && ( +
{ + // Leaving for anywhere but the dock, such as a tap on the page, closes it. + let next = event.relatedTarget; + if (panel.returning) return; + if (next instanceof Node && event.currentTarget.parentElement?.contains(next)) return; + setPanel(undefined); + saved.current = null; + }} + onKeyDown={event => { + if (event.key !== "Escape") return; + event.preventDefault(); + returnToEditor(); + }} + onMouseDown={event => event.preventDefault()} + ref={panelElement} + role="group" + style={panelHeight ? { height: panelHeight } : undefined} + tabIndex={-1} + > +
+ {choices.map(choice => ( + + ))} +
+
+ )} + ); } function DockButton( - { children, disabled, label, onPress, pressed }: { + { children, controls, disabled, expanded, label, onPress, pressed }: { children: ReactNode; + controls?: string; disabled?: boolean; + expanded?: boolean; label: string; onPress: () => void; pressed?: boolean; @@ -185,6 +411,8 @@ function DockButton( ) { return (
{panel && (
{ // Leaving for anywhere but the dock, such as a tap on the page, closes it. let next = event.relatedTarget; - if (panel.returning) return; + if (panel.phase === "returning") return; if (next instanceof Node && event.currentTarget.parentElement?.contains(next)) return; - setPanel(undefined); saved.current = null; + // A tap in the prose brings the keyboard back: hold the space until it arrives. + let root = editor.getRootElement(); + if (next instanceof Node && root?.contains(next)) { + setPanel(current => current && { ...current, phase: "returning" }); + } else { + setPanel(undefined); + } }} onKeyDown={event => { if (event.key !== "Escape") return; @@ -372,7 +398,7 @@ export function FormatDock( onMouseDown={event => event.preventDefault()} ref={panelElement} role="group" - style={panelHeight ? { height: panelHeight } : undefined} + style={panelHeight && panel.phase !== "opening" ? { height: panelHeight } : undefined} tabIndex={-1} >
From c9deaf7b949e351a5dde0a038dc85ba6d709dab2 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:55:37 +0100 Subject: [PATCH 3/4] Hold the phone bar still while the keyboard travels Browsers that report the keyboard's travel every frame, Android Chrome among them, showed the whole panel while the keyboard still covered part of it, so the bar overshot for a few frames on open and close. The panel now takes only the space the keyboard has uncovered, measured each frame, and gives its space back only once the keyboard is fully up. Co-Authored-By: Claude Opus 5.5 --- e2e/phone-read-mode.e2e.ts | 46 +++++++++++++++++++ packages/editor/src/styles.css | 15 ++---- .../editor/src/toolbar/dock-panel.test.ts | 12 ++++- packages/editor/src/toolbar/dock-panel.tsx | 21 +++++++-- packages/editor/src/toolbar/dock.tsx | 31 +++++++++---- 5 files changed, 101 insertions(+), 24 deletions(-) diff --git a/e2e/phone-read-mode.e2e.ts b/e2e/phone-read-mode.e2e.ts index 0562aaaaa..900b918cf 100644 --- a/e2e/phone-read-mode.e2e.ts +++ b/e2e/phone-read-mode.e2e.ts @@ -428,3 +428,49 @@ test("Text style turns the caret's block into a heading", async ({ baseURL, brow await close(); } }); + +test("the bar holds still while the keyboard travels frame by frame", async ({ baseURL, browser, room, seed }) => { + await seed(SOURCE); + let { close, page } = await openIsolatedRoom(browser, baseURL!, room, "ana", PHONE, fakeKeyboard); + try { + await page.getByText("Second paragraph").tap(); + await pencil(page).tap(); + await setVisualViewport(page, { event: "resize", height: 500 }); + let dock = page.getByRole("toolbar", { name: "Formatting" }); + await dock.evaluate(element => + Promise.all( + element.closest("[data-phone-tab-row]")!.getAnimations({ subtree: true }) + .map(animation => animation.finished), + ) + ); + let top = () => + dock.evaluate(element => + new Promise(resolve => + requestAnimationFrame(() => resolve(element.getBoundingClientRect().top)) + ) + ); + let resting = await top(); + // Some browsers, Android Chrome among them, report the keyboard's travel every frame. + let travel = async (heights: number[]) => { + for (let height of heights) { + await setVisualViewport(page, { event: "resize", height }); + expect(Math.abs((await top()) - resting)).toBeLessThanOrEqual(1); + } + }; + let down = [500, 520, 560, 612, 680, 732, 776, 810, 832, 844]; + + await dock.getByRole("button", { name: "Insert block" }).tap(); + expect(Math.abs((await top()) - resting)).toBeLessThanOrEqual(1); + await travel(down); + let panel = page.getByRole("group", { name: "Insert block" }); + expect(Math.round((await panel.boundingBox())!.height)).toBe(344); + + await page.keyboard.press("Escape"); + await expect(content(page)).toBeFocused(); + await travel(down.toReversed()); + await expect(panel).toHaveCount(0); + expect(Math.abs((await top()) - resting)).toBeLessThanOrEqual(1); + } finally { + await close(); + } +}); diff --git a/packages/editor/src/styles.css b/packages/editor/src/styles.css index 7529268c9..2b071a589 100644 --- a/packages/editor/src/styles.css +++ b/packages/editor/src/styles.css @@ -5638,28 +5638,21 @@ body:has(.plan-comment-sheet-popup[data-swiping]) } /* Insert and Text style: the choices stand where the keyboard was, at its height. */ +/* Sized to what the keyboard has uncovered, down to nothing, so its edge and padding sit inside. */ .plan-format-panel { box-sizing: border-box; height: var(--phone-format-panel-height); - padding: calc(var(--spacing) * 3) calc(var(--spacing) * 3) - calc(var(--spacing) * 3 + env(safe-area-inset-bottom)); overflow-y: auto; overscroll-behavior: contain; - border-top: var(--edge-width) solid var(--color-edge); background: var(--color-ground); -webkit-user-select: none; user-select: none; } -/* Out of the layout until the keyboard has gone, so the bar never rises over it. */ -.plan-format-panel[data-phase="opening"] { - height: 0; - padding-block: 0; - border-top-width: 0; - overflow: hidden; -} - .plan-format-panel-grid { + padding: calc(var(--spacing) * 3) calc(var(--spacing) * 3) + calc(var(--spacing) * 3 + env(safe-area-inset-bottom)); + border-top: var(--edge-width) solid var(--color-edge); display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: calc(var(--spacing) * 2); diff --git a/packages/editor/src/toolbar/dock-panel.test.ts b/packages/editor/src/toolbar/dock-panel.test.ts index f2994d7d8..ccb63aebb 100644 --- a/packages/editor/src/toolbar/dock-panel.test.ts +++ b/packages/editor/src/toolbar/dock-panel.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; -import { insertChoices, keyboardCover, styleChoices } from "./dock-panel"; +import { covered, insertChoices, keyboardCover, styleChoices, uncovered } from "./dock-panel"; test("a panel takes the height the software keyboard covered", () => { expect(keyboardCover(844, { top: 0, height: 500 })).toBe(344); @@ -38,3 +38,13 @@ test("Text style marks the caret's current block", () => { "Task list", ]); }); + +test("the panel fills only what the keyboard has uncovered, frame by frame", () => { + expect(covered(844, { top: 0, height: 500 })).toBe(344); + expect(covered(844, { top: 0, height: 900 })).toBe(0); + expect(uncovered(344, 344)).toBe(0); + expect(uncovered(344, 232)).toBe(112); + expect(uncovered(344, 0)).toBe(344); + // A taller keyboard than the panel was measured for never makes it negative. + expect(uncovered(344, 400)).toBe(0); +}); diff --git a/packages/editor/src/toolbar/dock-panel.tsx b/packages/editor/src/toolbar/dock-panel.tsx index 4f43b5a5b..287fcd090 100644 --- a/packages/editor/src/toolbar/dock-panel.tsx +++ b/packages/editor/src/toolbar/dock-panel.tsx @@ -23,14 +23,29 @@ export type DockPanel = "insert" | "style"; /** Taller than any browser toolbar, shorter than any software keyboard. */ const KEYBOARD_MIN = 120; +/** How much of the layout viewport the keyboard covers right now, however little. */ +export function covered( + innerHeight: number, + visual: { top: number; height: number } | undefined, +): number { + return visual ? Math.max(0, Math.round(innerHeight - visual.top - visual.height)) : 0; +} + /** How much of the layout viewport the software keyboard covers, if it is up. */ export function keyboardCover( innerHeight: number, visual: { top: number; height: number } | undefined, ): number | undefined { - if (!visual) return undefined; - let covered = Math.round(innerHeight - visual.top - visual.height); - return covered >= KEYBOARD_MIN ? covered : undefined; + let cover = covered(innerHeight, visual); + return cover >= KEYBOARD_MIN ? cover : undefined; +} + +/** + * The panel's height while the keyboard moves: only the space the keyboard has uncovered, so + * the bar above them stays where it rests however the browser reports the keyboard's travel. + */ +export function uncovered(panel: number, cover: number): number { + return Math.min(panel, Math.max(0, panel - cover)); } export type PanelChoice = { diff --git a/packages/editor/src/toolbar/dock.tsx b/packages/editor/src/toolbar/dock.tsx index b30c1c1c5..9c70ddb40 100644 --- a/packages/editor/src/toolbar/dock.tsx +++ b/packages/editor/src/toolbar/dock.tsx @@ -36,7 +36,7 @@ import { import { listenToHistory } from "../history"; import { $block } from "./bubble"; -import { insertChoices, keyboardCover, styleChoices } from "./dock-panel"; +import { covered, insertChoices, keyboardCover, styleChoices, uncovered } from "./dock-panel"; import { $linkAt, OPEN_LINK_EDITOR_COMMAND } from "./link"; import type { ReactNode } from "react"; @@ -88,14 +88,15 @@ export function FormatDock( /** Which edges of the row have buttons scrolled past them; those edges fade. */ let [edges, setEdges] = useState({ start: false, end: false }); /** - * `opening` keeps the panel out of the layout until the keyboard has gone, or the bar would - * grow up the screen over the keyboard. `returning` keeps its space while the keyboard comes - * back over it. + * `opening` waits for the keyboard to go and `returning` for it to come back. Throughout, the + * panel is only as tall as what the keyboard has uncovered, so the bar never leaves its place. */ let [panel, setPanel] = useState< { kind: DockPanel; block: Block; phase: "opening" | "open" | "returning" } >(); let [panelHeight, setPanelHeight] = useState(); + /** What the keyboard covers now, followed frame by frame while a panel is up. */ + let [cover, setCover] = useState(0); let panelElement = useRef(null); let toolbar = useRef(null); let saved = useRef(null); @@ -129,6 +130,14 @@ export function FormatDock( }, [onPanel, open]); useEffect(() => () => onPanel?.(false), [onPanel]); + useEffect(() => { + if (!open) return; + // Synchronous, so the panel and the workspace's height change in the same frame. + let measure = () => flushSync(() => setCover(covered(innerHeight, currentViewport()))); + measure(); + return listenToViewportChanges(measure); + }, [open]); + // The panel waits for the keyboard on both sides: to go before it takes its space, and to come // back before it gives the space up. let phase = panel?.phase; @@ -139,8 +148,11 @@ export function FormatDock( ? setPanel(current => current && { ...current, phase: "open" }) : setPanel(undefined); let check = () => { - let up = !!keyboardCover(innerHeight, currentViewport()); - if (up === (phase === "returning")) settle(); + let viewport = currentViewport(); + let up = !!keyboardCover(innerHeight, viewport); + // Returning waits for the whole keyboard, or the bar would drop by what is still uncovered. + let whole = !panelHeight || covered(innerHeight, viewport) >= panelHeight - 1; + if (phase === "opening" ? !up : up && whole) settle(); }; let timer = setTimeout(settle, KEYBOARD_TIMEOUT); let stop = listenToViewportChanges(check); @@ -149,7 +161,7 @@ export function FormatDock( clearTimeout(timer); stop(); }; - }, [phase]); + }, [panelHeight, phase]); // The caret's block stays above the panel, as it would above the keyboard. let shown = phase === "open"; @@ -157,7 +169,7 @@ export function FormatDock( if (!shown) return; // Measured where the bar will rest once the keyboard has gone, not where it is now. let bar = toolbar.current?.offsetHeight; - let height = panelElement.current?.offsetHeight; + let height = panelHeight ?? panelElement.current?.offsetHeight; let top = bar === undefined || height === undefined ? undefined : innerHeight - height - bar; let key = saved.current?.anchor.key; let block = key ? editor.getElementByKey(key)?.closest(".plan-content > *") : undefined; @@ -192,6 +204,7 @@ export function FormatDock( if (panel?.kind === kind && panel.phase !== "returning") return returnToEditor(); let keyboard = keyboardCover(innerHeight, currentViewport()); if (keyboard) setPanelHeight(keyboard); + setCover(covered(innerHeight, currentViewport())); let next = keyboard ? "opening" as const : "open" as const; if (!panel) { saved.current = editor.getEditorState().read(() => { @@ -398,7 +411,7 @@ export function FormatDock( onMouseDown={event => event.preventDefault()} ref={panelElement} role="group" - style={panelHeight && panel.phase !== "opening" ? { height: panelHeight } : undefined} + style={panelHeight ? { height: uncovered(panelHeight, cover) } : undefined} tabIndex={-1} >
From 16fb436548cc0c8e88c8d17654b8696ccb296403 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:57:34 +0100 Subject: [PATCH 4/4] Renew design-contract review exceptions for the phone editing toolbar - packages/icons/src/undo.tsx, packages/icons/src/text-style.tsx: new icons forward IconProps to LineIcon exactly like list.tsx; static SVG paths only, so they get the same reviewed props-forwarding exception. - packages/editor/src/plan-editor.tsx: only adds an optional onFormatPanel callback prop and passes it through to the toolbar options and memo deps; the reviewed className composition is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../exceptions/dynamic-editor.json | 2 +- .../exceptions/dynamic-packages.json | 35 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/scripts/design-contract/exceptions/dynamic-editor.json b/scripts/design-contract/exceptions/dynamic-editor.json index 2b2561a5a..59ba29577 100644 --- a/scripts/design-contract/exceptions/dynamic-editor.json +++ b/scripts/design-contract/exceptions/dynamic-editor.json @@ -361,7 +361,7 @@ 2 ] ], - "sourceHash": "37474a2d7f35a9d3ac00f2e9cc05a55e528a463de3572bdf9c45a2e3ab96db1c" + "sourceHash": "ec32a97839334779a7f7303aafd7248a5c15b5d4ce0d8f0070c14c08dfce7e8d" }, { "file": "packages/editor/src/collaboration.tsx", diff --git a/scripts/design-contract/exceptions/dynamic-packages.json b/scripts/design-contract/exceptions/dynamic-packages.json index 0699ec7dc..d99b9b56d 100644 --- a/scripts/design-contract/exceptions/dynamic-packages.json +++ b/scripts/design-contract/exceptions/dynamic-packages.json @@ -426,6 +426,41 @@ ], "sourceHash": "4f7d0232af2d03d5d8df9a226b48388c6a8011473ee53c2138568d77b8945bc3" }, + { + "file": "packages/icons/src/undo.tsx", + "reason": "Icon wrapper forwards SVG accessibility, sizing, and caller styling props. Literal style/class call sites are scanned; no ambient rendering code is executed.", + "cases": [ + [ + "dynamic", + "*", + "props", + "unresolved JSX props > UndoIcon > ", + 1 + ], + [ + "dynamic", + "*", + "props", + "unresolved JSX props > RedoIcon > ", + 1 + ] + ], + "sourceHash": "d3011674899a2058c523a11a3846c8cb3dce65be5cf972c9cd75a2ef95ae088b" + }, + { + "file": "packages/icons/src/text-style.tsx", + "reason": "Icon wrapper forwards SVG accessibility, sizing, and caller styling props. Literal style/class call sites are scanned; no ambient rendering code is executed.", + "cases": [ + [ + "dynamic", + "*", + "props", + "unresolved JSX props > TextStyleIcon > ", + 1 + ] + ], + "sourceHash": "399f3d192bc61cae7904baf1d5d814ce711651efec54e691f73e85e72fe4cb5c" + }, { "file": "packages/visuals/src/ui/mini-bars.tsx", "reason": "Public visual primitive forwards typed DOM/Base UI props. Literal style/class call sites remain scanned; runtime caller values need browser review.",