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..900b918cf 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,141 @@ 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" }); + let row = page.locator("[data-phone-tab-row]"); + await row.evaluate(element => + Promise.all(element.getAnimations({ subtree: true }).map(animation => animation.finished)) + ); + let resting = (await row.boundingBox())!; + expect(Math.round(resting.y + resting.height)).toBe(500); + await insert.tap(); + let panel = page.getByRole("group", { name: "Insert block" }); + await expect(panel).toBeFocused(); + await expect(insert).toHaveAttribute("aria-expanded", "true"); + // Until the keyboard has gone, the panel takes no space: the bar stays on the keyboard + // rather than growing up the screen over the document. + expect(await row.boundingBox()).toEqual(resting); + // 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(); + // The table's own actions sit above the bar, so Undo stays in reach. + let actions = page.getByRole("toolbar", { name: "Table actions" }); + await expect(actions).toBeVisible(); + let [actionsBox, barBox] = await Promise.all([actions.boundingBox(), dock.boundingBox()]); + expect(actionsBox!.y + actionsBox!.height).toBeLessThanOrEqual(barBox!.y); + } 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("!"); + + // A tap in the prose takes the caret there and the keyboard back, with no tab bar between. + await dock.getByRole("button", { name: "Text style" }).tap(); + await setVisualViewport(page, { event: "resize", height: 844 }); + await expect(panel).toBeVisible(); + await content(page).getByText("Members lose").tap(); + await expect(content(page)).toBeFocused(); + await expect(dock).toBeVisible(); + await expect(page.getByRole("navigation", { name: "Workspace view" })).toBeHidden(); + await setVisualViewport(page, { event: "resize", height: 500 }); + await expect(panel).toHaveCount(0); + await expect(dock).toBeVisible(); + + // 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(); + } +}); + +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/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.test.ts b/packages/editor/src/history.test.ts index e714e0a6f..47131c361 100644 --- a/packages/editor/src/history.test.ts +++ b/packages/editor/src/history.test.ts @@ -37,7 +37,7 @@ import { registry, } from "@chopin/dialect"; -import { registerPlanHistory, rehearse, typing } from "./history"; +import { listenToHistory, registerPlanHistory, rehearse, typing } from "./history"; import type { PlanHistory, Refusal } from "./history"; import type { Binding, Provider } from "@lexical/yjs"; @@ -612,3 +612,27 @@ describe("plan history under random editing", () => { }); } }); + +describe("history steps", () => { + it("reports nothing to step to until the history registers, then follows its stacks", async () => { + let doc = new Y.Doc(); + let me = bound(doc); + importPlan(me.editor, "Start here.\n", { registry: REGISTRY }); + let heard: Array<{ undo: boolean; redo: boolean }> = []; + let stop = listenToHistory(me.editor, steps => heard.push(steps)); + expect(heard.at(-1)).toEqual({ undo: false, redo: false }); + + let history = registerPlanHistory(me.editor, me.binding, { captureTimeout: 0 }); + type(me.editor, " Mine."); + await settle(); + expect(heard.at(-1)).toEqual({ undo: true, redo: false }); + + undo(me.editor); + await settle(); + expect(heard.at(-1)).toEqual({ undo: false, redo: true }); + + history.dispose(); + expect(heard.at(-1)).toEqual({ undo: false, redo: false }); + stop(); + }); +}); diff --git a/packages/editor/src/history.ts b/packages/editor/src/history.ts index 6e85c86c1..adb81e91a 100644 --- a/packages/editor/src/history.ts +++ b/packages/editor/src/history.ts @@ -290,6 +290,41 @@ 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> } +>(); + +function historyOf(editor: LexicalEditor) { + let history = histories.get(editor); + if (!history) histories.set(editor, history = { changed: new Set() }); + return history; +} + +export type HistorySteps = { undo: boolean; redo: boolean }; + +/** + * Follow whether undo and redo have a step to take. Until the editor's shared history is + * registered there is nothing to step to; a listener added before then hears when it arrives. + */ +export function listenToHistory( + editor: LexicalEditor, + listener: (steps: HistorySteps) => void, +): () => void { + let history = historyOf(editor); + let report = () => { + let manager = history.manager; + listener({ + undo: !!manager && manager.undoStack.length > 0, + redo: !!manager && manager.redoStack.length > 0, + }); + }; + report(); + history.changed.add(report); + return () => history.changed.delete(report); +} + /** Why an undo did not happen, as the person is told. */ export type Refusal = "others" | "change"; @@ -316,6 +351,15 @@ export function registerPlanHistory( trackedOrigins: new Set([binding]), captureTimeout, }); + let history = historyOf(editor); + history.manager = manager; + let notify = () => { + for (let listener of history.changed) listener(); + }; + manager.on("stack-item-added", notify); + manager.on("stack-item-popped", notify); + manager.on("stack-cleared", notify); + notify(); let step = (direction: "undo" | "redo") => { if (!editor.isEditable()) return false; @@ -328,6 +372,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; @@ -360,6 +405,10 @@ export function registerPlanHistory( stopUndo(); stopRedo(); manager.destroy(); + if (history.manager === manager) { + history.manager = undefined; + notify(); + } }, }; } 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..2b071a589 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; } @@ -5566,7 +5564,7 @@ body:has(.plan-comment-sheet-popup[data-swiping]) display: flex; flex: 1; align-items: center; - justify-content: space-between; + /* An even rhythm from the start, as native keyboard bars have, at any width. */ gap: calc(var(--spacing) / 2); min-width: 0; overflow-x: auto; @@ -5624,6 +5622,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 +5636,70 @@ 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. */ +/* 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); + overflow-y: auto; + overscroll-behavior: contain; + background: var(--color-ground); + -webkit-user-select: none; + user-select: none; +} + +.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); +} + +.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/table/action-toolbar.tsx b/packages/editor/src/table/action-toolbar.tsx index 3755f4f8c..314e285ff 100644 --- a/packages/editor/src/table/action-toolbar.tsx +++ b/packages/editor/src/table/action-toolbar.tsx @@ -10,6 +10,7 @@ import { import { $getSelection, $isRangeSelection } from "lexical"; import { planScroller } from "../scroll"; +import { aboveDock } from "../toolbar/dock-clearance"; import { placeSurface } from "../toolbar/placement"; import { editorSurfaceViewport, listenToEditorGeometry } from "../toolbar/surface"; import { @@ -81,7 +82,8 @@ export function TableActionToolbar( let element = surface.current; let cell = selected && editor.getElementByKey(selected.cell); if (!element || !cell) return; - let viewport = editorSurfaceViewport(editor); + // Above a phone's docked formatting, so the sheet never covers Undo after a table goes in. + let viewport = aboveDock(editorSurfaceViewport(editor)); let width = Math.max(0, viewport.width - 16); let bottom = viewport.top + viewport.height; // The previous group's maxHeight can constrain a newly expanded action panel. 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..ccb63aebb --- /dev/null +++ b/packages/editor/src/toolbar/dock-panel.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from "bun:test"; + +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); + // 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"); + // Task list is a text style, offered there instead. + expect(plain).not.toContain("tasks"); + 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", + ]); +}); + +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 new file mode 100644 index 000000000..287fcd090 --- /dev/null +++ b/packages/editor/src/toolbar/dock-panel.tsx @@ -0,0 +1,119 @@ +/** + * 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 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 { + 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 = { + 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(); + } +} + +/** Kinds of list belong with the other text styles, not with the blocks. */ +const STYLED = new Set(["tasks"]); + +/** The slash menu's blocks, and Research where the host offers it. */ +export function insertChoices(research: boolean): PanelChoice[] { + return availableCommands("") + .filter(command => !STYLED.has(command.id)) + .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..9c70ddb40 100644 --- a/packages/editor/src/toolbar/dock.tsx +++ b/packages/editor/src/toolbar/dock.tsx @@ -1,52 +1,105 @@ /** * 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 { covered, insertChoices, keyboardCover, styleChoices, uncovered } 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 waiting for the keyboard to go down or come back if the viewport never says so. */ +const KEYBOARD_TIMEOUT = 1000; + +/** 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: false, redo: false }); 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 }); + /** + * `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); let sync = useCallback(() => { editor.getEditorState().read(() => { @@ -55,9 +108,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 +122,63 @@ 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]); + + 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; + useEffect(() => { + if (phase !== "opening" && phase !== "returning") return; + let settle = () => + phase === "opening" + ? setPanel(current => current && { ...current, phase: "open" }) + : setPanel(undefined); + let check = () => { + 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); + check(); + return () => { + clearTimeout(timer); + stop(); + }; + }, [panelHeight, phase]); + + // The caret's block stays above the panel, as it would above the keyboard. + let shown = phase === "open"; + useLayoutEffect(() => { + 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 = 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; + 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, shown]); + let commentable = !!onComment && state.selected; useLayoutEffect(() => { let element = scroller.current; @@ -86,98 +198,251 @@ export function FormatDock( observer.disconnect(); element.removeEventListener("scroll", measure); }; - }, [commentable]); + }, [commentable, state.selected]); - let heading = HEADINGS.has(state.block); - let listed = state.block === "bullet"; + let openPanel = (kind: DockPanel) => { + 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(() => { + 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(current => + current && current.phase !== "returning" + ? { ...current, kind } + : { kind, block, phase: next } + ) + ); + // Focus leaves the editor, so iOS lowers the keyboard and the panel shows in its place. + panelElement.current?.focus({ preventScroll: true }); + }; + + /** 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, phase: "returning" })); + 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 + ? ( + <> + {/* First, so the narrowest phone never scrolls it out of reach. */} + {commentable && ( + onComment?.()} + > + + + )} + 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)} + > + + + + ) + : ( + <> + 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.phase === "returning") return; + if (next instanceof Node && event.currentTarget.parentElement?.contains(next)) return; + 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; + event.preventDefault(); + returnToEditor(); + }} + onMouseDown={event => event.preventDefault()} + ref={panelElement} + role="group" + style={panelHeight ? { height: uncovered(panelHeight, cover) } : 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 +450,8 @@ function DockButton( ) { return (