diff --git a/apps/web/src/components/editor/mobile-block-handles.browser.spec.ts b/apps/web/src/components/editor/mobile-block-handles.browser.spec.ts index d4a3477b..0fd8c93d 100644 --- a/apps/web/src/components/editor/mobile-block-handles.browser.spec.ts +++ b/apps/web/src/components/editor/mobile-block-handles.browser.spec.ts @@ -75,6 +75,11 @@ test("mobile block handles follow taps and move nested blocks with stable identi if (!size) throw new Error("Missing touch handle"); expect(size.width).toBeGreaterThanOrEqual(44); expect(size.height).toBeGreaterThanOrEqual(44); + const allIds = await editor + .locator("[data-block-id]") + .evaluateAll((nodes) => + nodes.map((node) => node.getAttribute("data-block-id")).sort(), + ); const id = await beta.evaluate( (el) => el.closest("[data-block-id]")?.dataset.blockId, ); @@ -108,13 +113,28 @@ test("mobile block handles follow taps and move nested blocks with stable identi start.x + start.width / 2, start.y + start.height / 2, ); - await page.waitForTimeout(300); // dnd-kit requires a deliberate hold before touch dragging. + await touch( + handle, + "touchmove", + start.x + start.width / 2 + 8, + start.y + start.height / 2 + 8, + ); await touch(handle, "touchmove", end.x + 16, end.y + end.height - 2); await expect( page.locator(".better-editor-block-drop-indicator"), ).toBeVisible(); await touch(handle, "touchend", end.x + 16, end.y + end.height - 2); await expect(editor.locator(":scope > [data-block-id]")).toHaveCount(3); + expect( + await editor + .locator("[data-block-id]") + .evaluateAll((nodes) => + nodes.map((node) => node.getAttribute("data-block-id")).sort(), + ), + ).toEqual(allIds); + await expect( + editor.locator('[data-better-editor-body="paragraph"]'), + ).toHaveText(["Alpha", "Gamma", "Beta"]); await expect(editor.locator(":scope > [data-block-id]").last()).toContainText( "Beta", ); diff --git a/apps/web/src/components/editor/table-reveal.browser.spec.ts b/apps/web/src/components/editor/table-reveal.browser.spec.ts new file mode 100644 index 00000000..4a2737d7 --- /dev/null +++ b/apps/web/src/components/editor/table-reveal.browser.spec.ts @@ -0,0 +1,141 @@ +import { expect, test } from "~/testing/browser/fixtures"; + +test.use({ journalPagination: true }); +test("added columns reveal themselves without moving the document vertically", async ({ + page, + context, +}, info) => { + await context.addCookies([ + { name: "journl-editor", url: "http://127.0.0.1:3100", value: "tiptap" }, + ]); + await page.goto("/journal"); + const create = page.getByRole("button", { exact: true, name: "New page" }); + if (!(await create.isVisible())) + await page + .getByRole("button", { exact: true, name: "Toggle Sidebar" }) + .click(); + const previous = page.url(); + await create.click(); + await expect(page).not.toHaveURL(previous); + await page.keyboard.press("Escape"); + const editor = page.locator(".better-editor-tiptap"); + await editor.fill("/table"); + await page.getByRole("option", { exact: true, name: "Table" }).click(); + const wrapper = editor.locator(".better-editor-table-wrapper"); + const cells = editor.locator("tr").first().locator("td"); + const vertical = () => + wrapper.evaluate((element) => { + const values: number[] = []; + for (let node: Element | null = element; node; node = node.parentElement) + values.push(node.scrollTop); + return values; + }); + const initial = await vertical(); + const visibleColumn = async (index: number) => { + await expect + .poll(() => + cells.nth(index).evaluate((cell) => { + const viewport = cell + .closest(".better-editor-table-wrapper") + ?.getBoundingClientRect(); + const rect = cell.getBoundingClientRect(); + return viewport + ? rect.left >= viewport.left - 1 && rect.right <= viewport.right + 1 + : false; + }), + ) + .toBe(true); + }; + for (let count = 3; count < 8; count++) { + const last = editor.locator("td").last(); + await last.hover({ position: { x: 2, y: 2 } }); + await last.hover(); + const add = page.getByRole("button", { exact: true, name: "Add column" }); + if (info.project.name === "mobile-webkit") await add.tap(); + else await add.click(); + await expect(cells).toHaveCount(count + 1); + await visibleColumn(count); + } + await expect + .poll(() => wrapper.evaluate((element) => element.scrollLeft)) + .toBeGreaterThan(0); + await expect.poll(vertical).toEqual(initial); + const last = editor.locator("td").last(); + await last.hover({ position: { x: 2, y: 2 } }); + await last.hover(); + const add = page.getByRole("button", { exact: true, name: "Add column" }); + await add.focus(); + await page.keyboard.press("Enter"); + await expect(cells).toHaveCount(9); + await visibleColumn(8); + await cells.first().hover({ position: { x: 2, y: 2 } }); + await cells.first().hover(); + await page + .getByRole("button", { exact: true, name: "Column actions" }) + .click(); + await page + .getByRole("menuitem", { exact: true, name: "Insert column left" }) + .click(); + await expect(cells).toHaveCount(10); + await visibleColumn(0); + await expect.poll(vertical).toEqual(initial); + // A merged header must not substitute its entire width for one column. + await cells.first().click(); + await cells.nth(7).click({ modifiers: ["Shift"] }); + await expect(editor.locator(".selectedCell")).toHaveCount(8); + await page + .getByRole("button", { exact: true, name: "More formatting" }) + .click(); + await page + .getByRole("menuitem", { exact: true, name: "Merge cells" }) + .click(); + await expect(editor.locator('td[colspan="8"]')).toHaveCount(1); + const lower = editor.locator("tr").nth(1).locator("td"); + for (const [index, direction] of [ + [6, "right"], + [1, "left"], + ] as const) { + await lower.nth(index).click(); + await page.keyboard.insertText(direction); + for (let i = 0; i < direction.length; i++) + await page.keyboard.press("Shift+ArrowLeft"); + await page + .getByRole("button", { exact: true, name: "More formatting" }) + .click(); + await wrapper.evaluate((element) => { + element.scrollLeft = 480; + }); + await page + .getByRole("menuitem", { + exact: true, + name: `Insert column ${direction}`, + }) + .click(); + await expect(lower).toHaveCount(11); + const added = lower.nth(direction === "right" ? index + 1 : index); + await expect + .poll(() => + added.evaluate((cell) => { + const viewport = cell + .closest(".better-editor-table-wrapper") + ?.getBoundingClientRect(); + const rect = cell.getBoundingClientRect(); + return ( + !!viewport && + rect.left >= viewport.left - 1 && + rect.right <= viewport.right + 1 + ); + }), + ) + .toBe(true); + await expect.poll(vertical).toEqual(initial); + await editor.focus(); + await page.keyboard.press("ControlOrMeta+z"); + await expect(lower).toHaveCount(10); + } + await expect(page.locator("[data-save-status]")).toContainText("Saved"); + await page.reload(); + await expect(cells).toHaveCount(3); + await expect(editor.locator('td[colspan="8"]')).toHaveCount(1); + await expect(lower).toHaveCount(10); +}); diff --git a/apps/web/src/components/editor/touch-gesture.browser.spec.ts b/apps/web/src/components/editor/touch-gesture.browser.spec.ts new file mode 100644 index 00000000..197d8b28 --- /dev/null +++ b/apps/web/src/components/editor/touch-gesture.browser.spec.ts @@ -0,0 +1,387 @@ +import type { CDPSession } from "@playwright/test"; +import { expect, test } from "~/testing/browser/fixtures"; + +test.use({ journalPagination: true }); +test.beforeEach(async ({ page, context }) => { + await context.addCookies([ + { name: "journl-editor", url: "http://127.0.0.1:3100", value: "tiptap" }, + ]); + await page.goto("/journal"); + const create = page.getByRole("button", { exact: true, name: "New page" }); + if (!(await create.isVisible())) + await page + .getByRole("button", { exact: true, name: "Toggle Sidebar" }) + .click(); + const previous = page.url(); + await create.click(); + await expect(page).not.toHaveURL(previous); + await page.keyboard.press("Escape"); +}); + +async function finger( + session: CDPSession, + type: "touchStart" | "touchMove" | "touchEnd", + x = 0, + y = 0, +) { + await session.send("Input.dispatchTouchEvent", { + touchPoints: type === "touchEnd" ? [] : [{ id: 1, x, y }], + type, + }); +} + +for (const width of [390, 1024]) { + test(`trusted touch at ${width}px moves once, closes actions, and never creates a native copy`, async ({ + page, + context, + }, info) => { + test.skip( + info.project.name !== "desktop-chromium", + "CDP provides trusted touch input; WebKit has separate touch acceptance", + ); + await page.setViewportSize({ height: 720, width }); + const editor = page.locator(".better-editor-tiptap"); + await editor.fill("Alpha"); + await page.keyboard.press("Enter"); + await page.keyboard.insertText("Beta"); + await page.keyboard.press("Enter"); + await page.keyboard.insertText("Gamma"); + const rows = editor.locator('[data-better-editor-body="paragraph"]'); + await expect(rows).toHaveText(["Alpha", "Beta", "Gamma"]); + const ids = await editor + .locator("[data-block-id]") + .evaluateAll((nodes) => + nodes.map((node) => node.getAttribute("data-block-id")), + ); + const session = await context.newCDPSession(page); + await session.send("Emulation.setTouchEmulationEnabled", { + enabled: true, + maxTouchPoints: 2, + }); + // A second contact cancels the gesture regardless of which finger lifts first. + for (const releaseOriginal of [false, true]) { + await rows.first().scrollIntoViewIfNeeded(); + const row = await rows.first().boundingBox(); + if (!row) throw new Error("Missing touch source"); + await finger(session, "touchStart", row.x + 20, row.y + 12); + await finger(session, "touchEnd"); + const grip = page.getByRole("button", { + exact: true, + name: "Block actions", + }); + await expect(grip).toBeVisible(); + await grip.scrollIntoViewIfNeeded(); + const box = await grip.boundingBox(); + const last = await rows.last().boundingBox(); + if (!box || !last) throw new Error("Missing cancellation geometry"); + const first = { + id: 1, + x: box.x + box.width / 2, + y: box.y + box.height / 2, + }; + await session.send("Input.dispatchTouchEvent", { + touchPoints: [first], + type: "touchStart", + }); + await finger(session, "touchMove", first.x + 16, first.y + 16); + await expect(grip).toHaveAttribute("aria-pressed", "true"); + first.x = last.x + 30; + first.y = last.y + last.height - 2; + await finger(session, "touchMove", first.x, first.y); + await expect( + page.locator(".better-editor-block-drop-indicator"), + ).toBeVisible(); + const second = { id: 2, x: first.x + 30, y: first.y }; + await session.send("Input.dispatchTouchEvent", { + touchPoints: [first, second], + type: "touchStart", + }); + await expect( + page.locator(".better-editor-block-drop-indicator"), + ).toHaveCount(0); + await session.send("Input.dispatchTouchEvent", { + touchPoints: [releaseOriginal ? second : first], + type: "touchEnd", + }); + await finger(session, "touchEnd"); + await expect(rows).toHaveText(["Alpha", "Beta", "Gamma"]); + expect( + await editor + .locator("[data-block-id]") + .evaluateAll((nodes) => + nodes.map((node) => node.getAttribute("data-block-id")), + ), + ).toEqual(ids); + } + const source = await rows.first().boundingBox(); + if (!source) throw new Error("Missing source paragraph"); + await finger(session, "touchStart", source.x + 20, source.y + 12); + await finger(session, "touchEnd"); + const handle = page.getByRole("button", { + exact: true, + name: "Block actions", + }); + await expect(handle).toBeVisible(); + // Touch typing and viewport changes retain the selected block's controls. + await editor.focus(); + await page.keyboard.insertText("!"); + await expect( + page.getByRole("button", { exact: true, name: "Add block" }), + ).toBeVisible(); + await page.keyboard.press("Backspace"); + await page.setViewportSize({ height: 520, width }); + await expect( + page.getByRole("button", { exact: true, name: "Block actions" }), + ).toBeVisible(); + await page.setViewportSize({ height: 720, width }); + + await finger(session, "touchStart", source.x + 20, source.y + 12); + await finger(session, "touchEnd"); + await handle.scrollIntoViewIfNeeded(); + let grip = await handle.boundingBox(); + if (!grip) throw new Error("Missing touch handle"); + await finger( + session, + "touchStart", + grip.x + grip.width / 2, + grip.y + grip.height / 2, + ); + await finger(session, "touchEnd"); + const duplicate = page.getByRole("menuitem", { + exact: true, + name: "Duplicate block", + }); + await expect(duplicate).toBeVisible(); + await handle.scrollIntoViewIfNeeded(); + grip = await handle.boundingBox(); + const target = await rows.last().boundingBox(); + if (!grip || !target) throw new Error("Missing touch drag geometry"); + const x = grip.x + grip.width / 2; + const y = grip.y + grip.height / 2; + await finger(session, "touchStart", x, y); + await finger(session, "touchMove", x + 16, y + 16); + await expect(handle).toHaveAttribute("aria-pressed", "true"); + await expect(duplicate).toBeHidden(); + // A native dragstart may precede the browser's cancellation notification. + // It must be cancelled before it can leave a ProseMirror clipboard slice. + expect( + await handle.evaluate((element) => { + const event = new DragEvent("dragstart", { + bubbles: true, + cancelable: true, + dataTransfer: new DataTransfer(), + }); + return element.dispatchEvent(event); + }), + ).toBe(false); + expect( + await editor.evaluate( + (element) => + (element as HTMLElement & { editor: { view: { dragging: unknown } } }) + .editor.view.dragging, + ), + ).toBeNull(); + await finger( + session, + "touchMove", + target.x + 30, + target.y + target.height - 2, + ); + await expect( + page.locator(".better-editor-block-drop-indicator"), + ).toBeVisible(); + await finger(session, "touchEnd"); + await expect(rows).toHaveText(["Beta", "Gamma", "Alpha"]); + expect( + await editor + .locator("[data-block-id]") + .evaluateAll((nodes) => + nodes.map((node) => node.getAttribute("data-block-id")), + ), + ).toEqual([ids[1], ids[2], ids[0]]); + // A cancelled table-axis gesture must never fall through to clipboard copy. + await editor.evaluate((element) => { + const dataTransfer = new DataTransfer(); + dataTransfer.setData( + "application/x-better-editor-table-axis", + JSON.stringify({ axis: "row", id: "removed-table", index: 0 }), + ); + dataTransfer.setData("text/html", "

Unowned table copy

"); + dataTransfer.setData("text/plain", "Unowned table copy"); + const rect = element.getBoundingClientRect(); + element.dispatchEvent( + new DragEvent("drop", { + bubbles: true, + cancelable: true, + clientX: rect.left + 20, + clientY: rect.top + 10, + dataTransfer, + }), + ); + }); + await expect(rows).toHaveText(["Beta", "Gamma", "Alpha"]); + // Exercise a late click after dnd-kit's own 50ms capture suppression ends. + await page.waitForTimeout(75); + await handle.dispatchEvent("click", { detail: 1 }); + await expect(duplicate).toBeHidden(); + await expect(rows).toHaveCount(3); + await editor.focus(); + await page.keyboard.press("ControlOrMeta+z"); + await expect(rows).toHaveText(["Alpha", "Beta", "Gamma"]); + await page.keyboard.press("ControlOrMeta+Shift+z"); + await expect(rows).toHaveText(["Beta", "Gamma", "Alpha"]); + await expect(page.locator("[data-save-status]")).toContainText("Saved"); + await page.reload(); + await expect(rows).toHaveText(["Beta", "Gamma", "Alpha"]); + expect( + await editor + .locator("[data-block-id]") + .evaluateAll((nodes) => + nodes.map((node) => node.getAttribute("data-block-id")), + ), + ).toEqual([ids[1], ids[2], ids[0]]); + }); +} + +test("touch auto-scroll keeps the destination under the finger and preserves every block", async ({ + page, + context, +}, info) => { + test.skip( + info.project.name !== "desktop-chromium", + "CDP trusted touch input", + ); + await page.setViewportSize({ height: 720, width: 390 }); + const editor = page.locator(".better-editor-tiptap"); + await editor.fill("Line 0"); + for (let i = 1; i < 30; i++) { + await page.keyboard.press("Enter"); + await page.keyboard.insertText(`Line ${i}`); + } + const rows = editor.locator('[data-better-editor-body="paragraph"]'); + await expect(rows).toHaveCount(30); + const ids = await editor + .locator("[data-block-id]") + .evaluateAll((nodes) => + nodes.map((node) => node.getAttribute("data-block-id")), + ); + await rows.first().scrollIntoViewIfNeeded(); + const session = await context.newCDPSession(page); + await session.send("Emulation.setTouchEmulationEnabled", { + enabled: true, + maxTouchPoints: 1, + }); + const first = await rows.first().boundingBox(); + if (!first) throw new Error("Missing first row"); + await finger(session, "touchStart", first.x + 20, first.y + 12); + await finger(session, "touchEnd"); + const handle = page.getByRole("button", { + exact: true, + name: "Block actions", + }); + await handle.scrollIntoViewIfNeeded(); + const grip = await handle.boundingBox(); + if (!grip) throw new Error("Missing drag handle"); + const scroller = await editor.evaluateHandle((element) => { + for ( + let parent = element.parentElement; + parent; + parent = parent.parentElement + ) { + if ( + parent.scrollHeight > parent.clientHeight && + /auto|scroll/.test(getComputedStyle(parent).overflowY) + ) + return parent; + } + throw new Error("Missing editor scroll container"); + }); + const initial = await scroller.evaluate((element) => element.scrollTop); + const bounds = await scroller.evaluate((element) => + element.getBoundingClientRect().toJSON(), + ); + await finger( + session, + "touchStart", + grip.x + grip.width / 2, + grip.y + grip.height / 2, + ); + await finger( + session, + "touchMove", + grip.x + grip.width / 2 + 16, + grip.y + grip.height / 2 + 16, + ); + await expect(handle).toHaveAttribute("aria-pressed", "true"); + await finger( + session, + "touchMove", + first.x + 30, + Math.min(700, bounds.bottom - 12), + ); + await expect + .poll(() => scroller.evaluate((element) => element.scrollTop)) + .toBeGreaterThan(initial + 80); + const y = Math.max( + bounds.top + 40, + (bounds.top + Math.min(bounds.bottom, 720)) / 2, + ); + await finger(session, "touchMove", first.x + 30, y); + const guide = page.locator(".better-editor-block-drop-indicator"); + await expect(guide).toBeVisible(); + await expect + .poll(async () => + Math.abs( + ((await guide.boundingBox())?.y ?? Number.POSITIVE_INFINITY) - y, + ), + ) + .toBeLessThan(35); + const destination = await rows.evaluateAll((nodes, y) => { + for (const node of nodes) { + const rect = node.getBoundingClientRect(); + if (y >= rect.top && y <= rect.bottom) + return { + after: y >= rect.top + rect.height / 2, + id: node.closest("[data-block-id]")?.getAttribute("data-block-id"), + }; + } + return null; + }, y); + if (!destination?.id) throw new Error("Missing row under touch destination"); + const expected = ids.slice(1); + expected.splice( + expected.indexOf(destination.id) + Number(destination.after), + 0, + ids[0] ?? null, + ); + await finger(session, "touchEnd"); + await expect(rows).toHaveCount(30); + const moved = await editor + .locator("[data-block-id]") + .evaluateAll((nodes) => + nodes.map((node) => node.getAttribute("data-block-id")), + ); + expect([...moved].sort()).toEqual([...ids].sort()); + expect(moved).toEqual(expected); + await editor.focus(); + await page.keyboard.press("ControlOrMeta+z"); + await expect(rows.first()).toHaveText("Line 0"); + expect( + await editor + .locator("[data-block-id]") + .evaluateAll((nodes) => + nodes.map((node) => node.getAttribute("data-block-id")), + ), + ).toEqual(ids); + await page.keyboard.press("ControlOrMeta+Shift+z"); + await expect(page.locator("[data-save-status]")).toContainText("Saved"); + await page.reload(); + await expect(rows).toHaveCount(30); + expect( + await editor + .locator("[data-block-id]") + .evaluateAll((nodes) => + nodes.map((node) => node.getAttribute("data-block-id")), + ), + ).toEqual(moved); +}); diff --git a/packages/better-editor/package.json b/packages/better-editor/package.json index 8bf83cc8..8ce4b245 100644 --- a/packages/better-editor/package.json +++ b/packages/better-editor/package.json @@ -40,6 +40,7 @@ "./ui/table-actions": "./src/ui/table-actions.ts", "./ui/table-controls": "./src/ui/table-controls.tsx", "./ui/table-handles": "./src/ui/table-handles.tsx", + "./ui/table-reveal": "./src/ui/table-reveal.ts", "./ui/toolbar": "./src/ui/toolbar.tsx", "./ui/touch-block-drag": "./src/ui/touch-block-drag.tsx", "./ui/use-document-editor": "./src/ui/use-document-editor.ts", diff --git a/packages/better-editor/src/engine/editor.ts b/packages/better-editor/src/engine/editor.ts index 748a0a02..bdce60bc 100644 --- a/packages/better-editor/src/engine/editor.ts +++ b/packages/better-editor/src/engine/editor.ts @@ -84,6 +84,13 @@ export function createDocumentEditor({ view.composing || event.isComposing || event.keyCode === 229, }, handleDrop: (view, event, _slice, moved) => { + // A stale table-axis gesture must not become a clipboard copy. + if ( + event.dataTransfer?.types.includes( + "application/x-better-editor-table-axis", + ) + ) + return true; if (moved) return false; const url = getPlainUrlForAutomaticReference({ html: event.dataTransfer?.getData("text/html") ?? "", diff --git a/packages/better-editor/src/ui/code-block.tsx b/packages/better-editor/src/ui/code-block.tsx index b4cb8258..2a7de6d6 100644 --- a/packages/better-editor/src/ui/code-block.tsx +++ b/packages/better-editor/src/ui/code-block.tsx @@ -39,6 +39,7 @@ const LanguageMenu = memo(function LanguageMenu({ className="better-editor-code-controls" contentEditable={false} data-tiptap-controls="" + data-tiptap-owner={editor.instanceId} > + + ) : null; + } + try { + await act(async () => root.render()); + const control = host.querySelector("input"); + await act(async () => control?.focus()); + await act(async () => + control?.dispatchEvent( + new KeyboardEvent("keydown", { bubbles: true, key: "Tab" }), + ), + ); + await act(async () => + control?.dispatchEvent(new InputEvent("beforeinput", { bubbles: true })), + ); + expect(control?.isConnected).toBe(true); + expect(document.activeElement).toBe(control); + await act(async () => outside.focus()); + expect(host.querySelector("input")).toBeNull(); + } finally { + await act(async () => root.unmount()); + editor.destroy(); + surface.remove(); + host.remove(); + outside.remove(); + } +}); diff --git a/packages/better-editor/src/ui/handle-visibility.ts b/packages/better-editor/src/ui/handle-visibility.ts index 69dd4741..6dc584a6 100644 --- a/packages/better-editor/src/ui/handle-visibility.ts +++ b/packages/better-editor/src/ui/handle-visibility.ts @@ -1,62 +1,84 @@ import type { Editor } from "@tiptap/core"; import { useEffect, useState } from "react"; -/** Handles are a pointer affordance. Editing hides them until a deliberate - * pointer movement or tap; no timer can make them reappear under a parked mouse. - * Activity is shared across journal editors. Open menus and gestures retain - * their own lock until completion. */ +/** Mouse hover is passive; touch and focused controls are explicit editor + * interactions. Scrolling repositions controls, never substitutes for editing. */ export function useHandleVisibility(editor: Editor | undefined) { const [visible, setVisible] = useState(true); useEffect(() => { - if (!editor) return; + if (!editor?.isEditable) return; const root = editor.view.dom; + let pointerType = window.matchMedia?.("(pointer: coarse)").matches + ? "touch" + : "mouse"; let pointer: { x: number; y: number } | undefined; - let pointerType = "mouse"; + let overControls = false; + const ownControls = (target: EventTarget | null) => + target instanceof Element && + target + .closest("[data-tiptap-controls][data-tiptap-owner]") + ?.getAttribute("data-tiptap-owner") === String(editor.instanceId); const hide = () => setVisible(false); + const edit = () => { + if (pointerType !== "touch" && !ownControls(document.activeElement)) { + overControls = false; + hide(); + } + }; const scroll = () => { - // Touch has no hover to bring controls back after a scroll. Keep the - // selected block's controls anchored; typing still hides them until a tap. if ( pointerType !== "touch" && - window.matchMedia("(hover: hover) and (pointer: fine)").matches + !overControls && + !ownControls(document.activeElement) ) hide(); }; const move = (event: PointerEvent) => { pointerType = event.pointerType; if (pointerType === "touch") return; + overControls = ownControls(event.target); const next = { x: event.clientX, y: event.clientY }; if (!pointer || Math.hypot(next.x - pointer.x, next.y - pointer.y) >= 4) { pointer = next; if (!editor.view.composing) setVisible(true); } }; - const tap = (event: PointerEvent) => { + const down = (event: PointerEvent) => { pointerType = event.pointerType; - if (!editor.view.composing) setVisible(true); + overControls = ownControls(event.target); + if ( + overControls || + (event.target instanceof Node && root.contains(event.target)) + ) { + if (!editor.view.composing) setVisible(true); + } else hide(); + }; + const focus = (event: FocusEvent) => { + if (ownControls(event.target)) setVisible(true); + else if (event.target instanceof Node && !root.contains(event.target)) + hide(); }; - const inputs = ["keydown", "beforeinput", "paste", "compositionstart"]; - for (const event of inputs) document.addEventListener(event, hide, true); - root.addEventListener("pointerdown", tap); const blur = ({ event }: { event: FocusEvent }) => { - // Moving focus into the editor's controls is still an editor interaction. - // Hiding their portal here removes a pointer trigger before its click. - if ( - event.relatedTarget instanceof Element && - event.relatedTarget.closest("[data-tiptap-controls]") - ) - return; + if (ownControls(event.relatedTarget)) return; + // Mobile keyboard/viewport transitions can blur with no new focus target. + // An explicit outside pointer/focus interaction still dismisses controls. + if (pointerType === "touch" && event.relatedTarget === null) return; hide(); }; + const inputs = ["beforeinput", "paste", "compositionstart"]; + for (const event of inputs) document.addEventListener(event, edit, true); editor.on("blur", blur); + document.addEventListener("pointerdown", down, true); document.addEventListener("pointermove", move); + document.addEventListener("focusin", focus); window.addEventListener("scroll", scroll, true); return () => { for (const event of inputs) - document.removeEventListener(event, hide, true); - root.removeEventListener("pointerdown", tap); + document.removeEventListener(event, edit, true); editor.off("blur", blur); + document.removeEventListener("pointerdown", down, true); document.removeEventListener("pointermove", move); + document.removeEventListener("focusin", focus); window.removeEventListener("scroll", scroll, true); }; }, [editor]); diff --git a/packages/better-editor/src/ui/node-views.tsx b/packages/better-editor/src/ui/node-views.tsx index 8cc25d44..cb203560 100644 --- a/packages/better-editor/src/ui/node-views.tsx +++ b/packages/better-editor/src/ui/node-views.tsx @@ -288,6 +288,7 @@ function ResolvedReference({ { return restoreEditorFocus(view.editor); }} @@ -339,6 +340,7 @@ function ResolvedReference({ {title} + typeof window !== "undefined" && + window.matchMedia("(pointer: coarse)").matches, + ); + const touchOwner = useRef(touchPointer); + const consumedClick = useRef(false); + const dragClosedMenu = useRef(false); const mobile = compact || touchPointer; const [id, setIdState] = useState(); const activeId = useRef(undefined); @@ -56,7 +63,12 @@ export function TiptapSideMenu({ const floating = useFloating({ middleware: [ offset(4), - shift({ crossAxis: true, mainAxis: mobile, padding: 4 }), + shift({ + altBoundary: true, + crossAxis: true, + mainAxis: mobile, + padding: 4, + }), hide({ strategy: "referenceHidden" }), ], placement: "left-start", @@ -79,9 +91,15 @@ export function TiptapSideMenu({ useEffect(() => { const root = editor.view.dom; const move = (event: PointerEvent) => { - if (event.pointerType === "mouse" && touchPointer) setTouchPointer(false); if ( - mobile || + event.pointerType === "mouse" && + touchPointer && + !pressed && + !frozen.current + ) + setTouchPointer(false); + if ( + touchPointer || event.pointerType === "touch" || open || pressed || @@ -148,7 +166,14 @@ export function TiptapSideMenu({ editor.on("focus", focus); if (mobile) editor.on("selectionUpdate", focus); const tap = (event: PointerEvent) => { - if (event.pointerType !== "touch" || !(event.target instanceof Element)) + if ( + event.pointerType !== "touch" || + !(event.target instanceof Element) || + open || + pressed || + frozen.current || + editor.view.dragging + ) return; setTouchPointer(true); const row = event.target.closest( @@ -179,7 +204,7 @@ export function TiptapSideMenu({ const body = node.querySelector("[data-better-editor-body]") ?? node; floating.refs.setPositionReference({ - contextElement: body, + contextElement: node, getBoundingClientRect() { const rect = body.getBoundingClientRect(); const style = getComputedStyle(body); @@ -192,12 +217,14 @@ export function TiptapSideMenu({ ? editor.view.dom.getBoundingClientRect().left + (Number.parseFloat(getComputedStyle(editor.view.dom).paddingLeft) || 0) - : rect.left; + : (body + .closest(".better-editor-table-wrapper") + ?.getBoundingClientRect().left ?? rect.left); return new DOMRect( left, rect.top + padding + (lineHeight - handleHeight) / 2 - 2, rect.width, - handleHeight, + mobile ? Math.max(handleHeight, rect.height) : handleHeight, ); }, }); @@ -263,9 +290,13 @@ export function TiptapSideMenu({ { frozen.current = value; + if (value) { + consumedClick.current = true; + dragClosedMenu.current = true; + setOpen(false); + } else setPressed(false); }} select={() => { select(); @@ -275,6 +306,7 @@ export function TiptapSideMenu({ createPortal(
{ - if (value) select(); + if (value) { + dragClosedMenu.current = false; + select(); + } setOpen(value); }} > @@ -299,6 +334,8 @@ export function TiptapSideMenu({ asChild onPointerDown={(event) => { event.preventBaseUIHandler(); + consumedClick.current = false; + touchOwner.current = event.pointerType === "touch"; setPressed(true); }} onMouseDown={(event) => { @@ -309,7 +346,18 @@ export function TiptapSideMenu({ }} onClick={(event) => { event.preventBaseUIHandler(); - if (!open) select(); + if ( + frozen.current || + (event.detail > 0 && consumedClick.current) + ) { + event.preventDefault(); + event.stopPropagation(); + return; + } + if (!open) { + dragClosedMenu.current = false; + select(); + } setOpen(!open); }} > @@ -324,9 +372,17 @@ export function TiptapSideMenu({ setTouchPointer(event.pointerType === "touch") } ref={handle.setActivatorNodeRef} - {...(mobile ? handle.attributes : {})} - {...(mobile ? handle.listeners : {})} + {...handle.attributes} + {...handle.listeners} onDragStart={(event) => { + // A cancelled native start must not leave a clipboard slice + // behind while dnd-kit owns this touch gesture. + if (touchOwner.current || frozen.current) { + event.preventDefault(); + return; + } + consumedClick.current = true; + dragClosedMenu.current = true; const target = select(); const selection = editor.state.selection; const slice = selection.content(); @@ -342,6 +398,9 @@ export function TiptapSideMenu({ editor.view.dragging = { move: !(isMacOS() ? event.altKey : event.ctrlKey), slice, + ...(selection instanceof NodeSelection + ? { node: selection } + : {}), }; setOpen(false); }} @@ -355,8 +414,11 @@ export function TiptapSideMenu({ { - return restoreEditorFocus(editor); + return dragClosedMenu.current + ? false + : restoreEditorFocus(editor); }} > { if (state !== editor.state) editor.view.dispatch(editor.state.tr.setSelection(state.selection)); - command(editor.state, (tr) => applyTableEdit(editor, tr), editor.view); + const rect = selectedRect(editor.state); + const added = command === addColumnBefore ? rect.left : rect.right; + const changed = command( + editor.state, + (tr) => applyTableEdit(editor, tr), + editor.view, + ); editor.view.focus(); + if (changed && (command === addColumnBefore || command === addColumnAfter)) + revealTableColumn(editor, target.id, added); }; const commands: [string, string, Command, TableAxis?][] = [ [ diff --git a/packages/better-editor/src/ui/table-handles.tsx b/packages/better-editor/src/ui/table-handles.tsx index 37b8800c..aeb0bf1b 100644 --- a/packages/better-editor/src/ui/table-handles.tsx +++ b/packages/better-editor/src/ui/table-handles.tsx @@ -43,6 +43,7 @@ import { import { removeSelectedBlock } from "../engine/writing"; import { EditorHint, EditorMenuItem, restoreEditorFocus } from "./controls"; import { TableMenuItems } from "./table-controls"; +import { revealTableColumn } from "./table-reveal"; import { useTiptapState } from "./writing-controls"; type ActiveTable = { @@ -318,7 +319,11 @@ export function TiptapTableHandles({ editor.view.dispatch(editor.state.tr.setSelection(selection)); }; return createPortal( -
+
{(["row", "column"] as const).map((axis) => ( { return restoreEditorFocus(editor); }} @@ -691,6 +697,8 @@ function TableExtendRail({ .setTime(current.time) .setMeta("better-editor-table-extension", current.time), ); + if (axis === "column" && count > current.count) + revealTableColumn(editor, active.id, count - 1); } }} onPointerUp={() => { @@ -703,12 +711,15 @@ function TableExtendRail({ axis, current.count + 1, ); - if (tr) + if (tr) { editor.view.dispatch( tr .setTime(current.time) .setMeta("better-editor-table-extension", current.time), ); + if (axis === "column") + revealTableColumn(editor, active.id, current.count); + } } end(); }} @@ -726,7 +737,11 @@ function TableExtendRail({ axis, count + 1, ); - if (tr) applyTableEdit(editor, tr); + if (tr) { + applyTableEdit(editor, tr); + if (axis === "column") + revealTableColumn(editor, active.id, count); + } } editor.view.focus(); } diff --git a/packages/better-editor/src/ui/table-reveal.spec.ts b/packages/better-editor/src/ui/table-reveal.spec.ts new file mode 100644 index 00000000..f3087f55 --- /dev/null +++ b/packages/better-editor/src/ui/table-reveal.spec.ts @@ -0,0 +1,81 @@ +// @vitest-environment jsdom +import { parseBlock } from "@acme/document"; +import { Editor } from "@tiptap/core"; +import { expect, test, vi } from "vitest"; +import { documentExtensions } from "../engine/schema"; +import { toTiptapDocument } from "../serialization/tiptap"; +import { revealTableColumn } from "./table-reveal"; + +for (const unmergedRow of [true, false]) { + test(`column reveal measures a column within a wide span (${unmergedRow ? "unmerged row" : "all merged"})`, () => { + const merged = { content: [], props: { colspan: 9 }, type: "tableCell" }; + const editor = new Editor({ + content: toTiptapDocument([ + parseBlock({ + content: { + columnWidths: Array(9).fill(120), + rows: [ + { cells: [merged] }, + { + cells: unmergedRow + ? Array(9).fill({ content: [], type: "tableCell" }) + : [merged], + }, + ], + type: "tableContent", + }, + id: "table", + type: "table", + }), + ]).toJSON(), + element: document.createElement("div"), + extensions: documentExtensions(), + }); + let measure: FrameRequestCallback | undefined; + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + measure = callback; + return 1; + }); + const table = editor.view.dom.querySelector("table"); + if (!table?.parentElement) throw new Error("Missing table view"); + const wrapper = table.parentElement; + wrapper.classList.add("better-editor-table-wrapper"); + Object.defineProperty(wrapper, "clientWidth", { value: 360 }); + vi.spyOn(wrapper, "getBoundingClientRect").mockReturnValue( + new DOMRect(0, 0, 360, 100), + ); + for (const row of table.rows) { + let column = 0; + for (const cell of row.cells) { + const left = column * 120; + const width = cell.colSpan * 120; + vi.spyOn(cell, "getBoundingClientRect").mockImplementation( + () => new DOMRect(left - wrapper.scrollLeft, 0, width, 30), + ); + column += cell.colSpan; + } + } + try { + for (const [column, expected] of [ + [7, 600], + [1, 120], + ] as const) { + wrapper.scrollLeft = 480; + revealTableColumn(editor, "table", column); + measure?.(0); + expect(wrapper.scrollLeft).toBe(expected); + expect(wrapper.scrollTop).toBe(0); + } + // A removed/replaced document invalidates an earlier layout callback. + wrapper.scrollLeft = 480; + revealTableColumn(editor, "table", 7); + editor.commands.clearContent(); + measure?.(0); + expect(wrapper.scrollLeft).toBe(480); + } finally { + editor.destroy(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + } + }); +} diff --git a/packages/better-editor/src/ui/table-reveal.ts b/packages/better-editor/src/ui/table-reveal.ts new file mode 100644 index 00000000..d69d3cb5 --- /dev/null +++ b/packages/better-editor/src/ui/table-reveal.ts @@ -0,0 +1,56 @@ +import type { Editor } from "@tiptap/core"; +import { TableMap } from "@tiptap/pm/tables"; +import { blockAt } from "../engine/identity"; +import { tableColumnWidth } from "../engine/table-actions"; + +/** Reveal the added column without vertical scrolling. WebKit gives col + * elements empty boxes, so use the narrowest cell covering this column. */ +export function revealTableColumn(editor: Editor, id: string, column: number) { + const doc = editor.state.doc; + requestAnimationFrame(() => { + // Undo, replacement or a newer edit invalidates this pending reveal. + if (editor.isDestroyed || editor.state.doc !== doc) return; + try { + const target = blockAt(doc, id); + const table = target.node.firstChild; + if (!table) return; + const map = TableMap.get(table); + if (column < 0 || column >= map.width) return; + let offset = map.map[column]; + if (offset === undefined) return; + let span = map.findCell(offset); + for (let row = 1; row < map.height && span.right - span.left > 1; row++) { + const candidate = map.map[row * map.width + column]; + if (candidate === undefined) continue; + const rect = map.findCell(candidate); + if (rect.right - rect.left < span.right - span.left) { + offset = candidate; + span = rect; + } + } + const cell = editor.view.nodeDOM(target.pos + 2 + offset); + if (!(cell instanceof HTMLElement)) return; + const wrapper = cell.closest(".better-editor-table-wrapper"); + if (!wrapper) return; + const bounds = cell.getBoundingClientRect(); + // If every row is merged here, apportion the rendered span using the + // native table's column widths rather than revealing the entire span. + const widths = Array.from({ length: span.right - span.left }, (_, i) => + tableColumnWidth(table, span.left + i), + ); + const total = widths.reduce((sum, width) => sum + width, 0); + const before = widths + .slice(0, column - span.left) + .reduce((sum, width) => sum + width, 0); + const start = bounds.left + (bounds.width * before) / total; + const end = + start + (bounds.width * (widths[column - span.left] ?? 0)) / total; + const left = wrapper.getBoundingClientRect().left + wrapper.clientLeft; + const right = left + wrapper.clientWidth; + wrapper.scrollLeft += + start < left ? start - left : Math.max(0, end - right); + } catch { + // The target may have disappeared before layout was measured. + } + }); +} diff --git a/packages/better-editor/src/ui/toolbar.tsx b/packages/better-editor/src/ui/toolbar.tsx index e03fac6d..49f8d11b 100644 --- a/packages/better-editor/src/ui/toolbar.tsx +++ b/packages/better-editor/src/ui/toolbar.tsx @@ -116,7 +116,10 @@ function BlockTypeMenu({ editor }: { editor: Editor }) { Turn into - + {choices.map((choice) => ( { const from = event.relatedTarget; @@ -455,6 +459,7 @@ export function TiptapToolbar({ { setRequested(false); return restoreEditorFocus(editor); @@ -587,6 +592,7 @@ export function TiptapToolbar({ { return restoreEditorFocus(editor); }} @@ -627,6 +633,7 @@ export function TiptapToolbar({ { return restoreEditorFocus(editor); }} @@ -707,6 +714,7 @@ export function TiptapToolbar({ { return restoreEditorFocus(editor); }} diff --git a/packages/better-editor/src/ui/touch-block-drag.tsx b/packages/better-editor/src/ui/touch-block-drag.tsx index 6f2b26dd..ea760ee6 100644 --- a/packages/better-editor/src/ui/touch-block-drag.tsx +++ b/packages/better-editor/src/ui/touch-block-drag.tsx @@ -1,13 +1,13 @@ "use client"; import { DndContext, - type DragMoveEvent, TouchSensor, useDraggable, useSensor, useSensors, } from "@dnd-kit/core"; import type { Editor } from "@tiptap/core"; +import { closeHistory } from "@tiptap/pm/history"; import { NodeSelection } from "@tiptap/pm/state"; import { type ReactNode, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; @@ -88,15 +88,13 @@ type Handle = Pick< function DraggableHandle({ editor, id, - enabled, children, }: { editor: Editor; id: string; - enabled: boolean; children: (handle: Handle) => ReactNode; }) { - const handle = useDraggable({ disabled: !enabled, id }); + const handle = useDraggable({ id }); useEffect(() => { try { const node = editor.view.nodeDOM(blockAt(editor.state.doc, id).pos); @@ -113,38 +111,75 @@ function DraggableHandle({ export function TouchBlockDrag({ editor, id, - enabled, select, onActiveChange, children, }: { editor: Editor; id: string; - enabled: boolean; select: () => void; onActiveChange: (active: boolean) => void; children: (handle: Handle) => ReactNode; }) { + const ids = useRef([]); + const point = useRef<{ x: number; y: number } | null>(null); + const cancelled = useRef(false); + const stopTracking = useRef<(() => void) | undefined>(undefined); + const [drop, setDrop] = useState(null); + useEffect(() => () => stopTracking.current?.(), []); const sensors = useSensors( useSensor(TouchSensor, { - activationConstraint: { delay: 250, tolerance: 8 }, + activationConstraint: { distance: 6 }, + onActivation({ event }) { + stopTracking.current?.(); + const start = (event as TouchEvent).touches[0]; + if (!start) return; + cancelled.current = false; + point.current = { x: start.clientX, y: start.clientY }; + const doc = (event.target as Node).ownerDocument ?? document; + const track = (event: TouchEvent) => { + // TouchSensor ends on any finger's release. Once another contact joins, + // this single-finger gesture must stay cancelled through that end. + if (event.touches.length > 1 || event.type === "touchcancel") { + cancelled.current = true; + point.current = null; + setDrop(null); + } + const touch = Array.from(event.changedTouches).find( + (touch) => touch.identifier === start.identifier, + ); + if (touch && !cancelled.current) + point.current = { x: touch.clientX, y: touch.clientY }; + if (event.type === "touchend" || event.type === "touchcancel") + stopTracking.current?.(); + }; + // Capture runs before the library's move/end listeners. Client positions + // stay in viewport space; dnd-kit deltas also contain scroll adjustment. + for (const type of [ + "touchstart", + "touchmove", + "touchend", + "touchcancel", + ] as const) + doc.addEventListener(type, track, { capture: true, passive: true }); + stopTracking.current = () => { + for (const type of [ + "touchstart", + "touchmove", + "touchend", + "touchcancel", + ] as const) + doc.removeEventListener(type, track, true); + stopTracking.current = undefined; + }; + }, }), ); - const ids = useRef([]); - const [drop, setDrop] = useState(null); - const resolve = (event: DragMoveEvent) => { - const touch = - "touches" in event.activatorEvent - ? (event.activatorEvent as TouchEvent).touches[0] - : undefined; - if (!touch) return null; + const resolve = () => { + const current = point.current; + if (!current) return null; try { - return destination( - editor, - ids.current, - touch.clientX + event.delta.x, - touch.clientY + event.delta.y, - ); + return destination(editor, ids.current, current.x, current.y); } catch { return null; } @@ -153,19 +188,26 @@ export function TouchBlockDrag({ { + editor.view.dragging = null; onActiveChange(true); select(); ids.current = selectedBlocks(editor.state).map((block) => block.id); }} - onDragMove={(event) => setDrop(resolve(event))} + onDragMove={() => setDrop(resolve())} onDragCancel={() => { - onActiveChange(false); + stopTracking.current?.(); + point.current = null; + editor.view.dragging = null; ids.current = []; setDrop(null); + onActiveChange(false); }} - onDragEnd={(event) => { + onDragEnd={() => { + const target = resolve(); + stopTracking.current?.(); + point.current = null; + editor.view.dragging = null; onActiveChange(false); - const target = resolve(event); const moving = ids.current; ids.current = []; setDrop(null); @@ -189,8 +231,11 @@ export function TouchBlockDrag({ return operation; }); editor.view.dispatch( - editBlocks(editor.state.tr, operations, moving[0]).scrollIntoView(), + closeHistory( + editBlocks(editor.state.tr, operations, moving[0]), + ).scrollIntoView(), ); + editor.view.dispatch(closeHistory(editor.state.tr)); } catch { /* A concurrent removal or invalid destination cancels the gesture. */ } @@ -204,11 +249,11 @@ export function TouchBlockDrag({ }, screenReaderInstructions: { draggable: - "Hold the block handle, then drag to move. Use the block menu to move without dragging.", + "Drag the block handle to move. Use the block menu to move without dragging.", }, }} > - + {children} {drop && diff --git a/packages/better-editor/src/ui/writing-controls.tsx b/packages/better-editor/src/ui/writing-controls.tsx index 16f77cd6..36a48db5 100644 --- a/packages/better-editor/src/ui/writing-controls.tsx +++ b/packages/better-editor/src/ui/writing-controls.tsx @@ -269,6 +269,7 @@ export function TiptapContextMenu({ editor }: { editor?: Editor }) { /> { return restoreEditorFocus(editor); }} @@ -336,6 +337,7 @@ export function TiptapSlashMenu({ editor }: { editor?: Editor }) { ref={popup} id={menuId} data-tiptap-controls="" + data-tiptap-owner={editor.instanceId} role="listbox" aria-label="Block commands" className="fixed z-50 w-64 overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md"