From 367cb8368d3ef9046123d8b0bebd0cfc8114c41b Mon Sep 17 00:00:00 2001 From: Thorsten Ruf <55167+thorufur@users.noreply.github.com> Date: Fri, 16 Oct 2026 05:24:46 +0200 Subject: [PATCH] feat(web): add a table context menu with copy and paste (#579) * feat: add table context menu with copy and paste * fix(web): preserve native input and test table context menu regressions * fix: harden table context actions and desktop clipboard --- apps/desktop/src/main/clipboard-read.mjs | 62 +++ apps/desktop/src/main/clipboard-read.test.mjs | 176 ++++++++ apps/desktop/src/main/index.mjs | 14 +- apps/desktop/src/preload/index.cjs | 10 + apps/web/src/components/EditorPane.tsx | 9 +- .../src/components/EditorTableContextMenu.tsx | 220 ++++++++++ apps/web/src/components/EditorTableMenu.tsx | 55 +-- .../web/src/lib/editor-table-actions.test.mjs | 135 ++++++ apps/web/src/lib/editor-table-actions.ts | 41 ++ .../src/lib/editor-table-clipboard.test.mjs | 49 +++ apps/web/src/lib/editor-table-clipboard.ts | 37 ++ .../web/src/lib/editor-table-context.test.mjs | 109 +++++ apps/web/src/lib/editor-table-context.ts | 39 ++ apps/web/src/vite-env.d.ts | 1 + .../browser/editor-table-context.browser.tsx | 385 ++++++++++++++++++ tests/browser/editor-table-context.html | 9 + tests/browser/tsconfig.json | 4 + 17 files changed, 1310 insertions(+), 45 deletions(-) create mode 100644 apps/desktop/src/main/clipboard-read.mjs create mode 100644 apps/desktop/src/main/clipboard-read.test.mjs create mode 100644 apps/web/src/components/EditorTableContextMenu.tsx create mode 100644 apps/web/src/lib/editor-table-actions.test.mjs create mode 100644 apps/web/src/lib/editor-table-actions.ts create mode 100644 apps/web/src/lib/editor-table-clipboard.test.mjs create mode 100644 apps/web/src/lib/editor-table-clipboard.ts create mode 100644 apps/web/src/lib/editor-table-context.test.mjs create mode 100644 apps/web/src/lib/editor-table-context.ts create mode 100644 tests/browser/editor-table-context.browser.tsx create mode 100644 tests/browser/editor-table-context.html create mode 100644 tests/browser/tsconfig.json diff --git a/apps/desktop/src/main/clipboard-read.mjs b/apps/desktop/src/main/clipboard-read.mjs new file mode 100644 index 00000000..60496863 --- /dev/null +++ b/apps/desktop/src/main/clipboard-read.mjs @@ -0,0 +1,62 @@ +import { isAllowedAppNavigation } from "./window-open-policy.mjs"; + +const MAX_TEXT_CLIPBOARD_BYTES = 8 * 1024 * 1024; +const MAX_IMAGE_CLIPBOARD_BYTES = 32 * 1024 * 1024; + +/** Return only editor paste formats; never forward OS clipboard/file/bookmark formats. */ +export const readEditorClipboard = async (clipboard) => { + const items = await clipboard.read(); + let error; + const readText = async (type) => { + let bytes = 0; + const parts = []; + for (const item of items) { + if (!item.types.includes(type)) continue; + const blob = await item.getType(type); + bytes += blob.size; + if (bytes > MAX_TEXT_CLIPBOARD_BYTES) throw new RangeError("Clipboard text exceeds size limit"); + parts.push(await blob.text()); + } + return parts.join(type === "text/plain" ? "\n" : ""); + }; + for (const type of ["text/html", "text/plain"]) { + try { + const value = await readText(type); + if (value) return { html: type === "text/html" ? value : "", text: type === "text/plain" ? value : "", images: [] }; + } catch (cause) { error = cause; } + } + const images = []; + let imageBytes = 0; + for (const item of items) { + if (!item.types.includes("image/png")) continue; + const blob = await item.getType("image/png"); + imageBytes += blob.size; + if (imageBytes > MAX_IMAGE_CLIPBOARD_BYTES) throw new RangeError("Clipboard images exceed size limit"); + if (blob.size) images.push(new Uint8Array(await blob.arrayBuffer())); + } + if (!images.length && error) throw error; + return { html: "", text: "", images }; +}; + +/** Explicit paste reads require the focused app's main frame before and after the async snapshot. */ +export const registerClipboardReadIpc = (ipcMain, { clipboard, getMainWindow, getRendererUrl }) => { + const authorize = (event) => { + const window = getMainWindow(); + const sender = event?.sender; + if (!window || window.isDestroyed() + || sender !== window.webContents || sender.isDestroyed() + || event.senderFrame !== sender.mainFrame + || !window.isVisible() || window.isMinimized() + || !window.isFocused() || !sender.isFocused() + || !isAllowedAppNavigation(event.senderFrame.url, getRendererUrl())) { + throw new Error("Clipboard read forbidden"); + } + }; + ipcMain.handle("desktop:read-clipboard", async (event, ...args) => { + if (args.length !== 0) throw new Error("Clipboard read forbidden"); + authorize(event); + const snapshot = await readEditorClipboard(clipboard); + authorize(event); + return snapshot; + }); +}; diff --git a/apps/desktop/src/main/clipboard-read.test.mjs b/apps/desktop/src/main/clipboard-read.test.mjs new file mode 100644 index 00000000..4200bccf --- /dev/null +++ b/apps/desktop/src/main/clipboard-read.test.mjs @@ -0,0 +1,176 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { runInNewContext } from "node:vm"; +import { readEditorClipboard, registerClipboardReadIpc } from "./clipboard-read.mjs"; + +const item = (data) => ({ + types: Object.keys(data), + async getType(type) { + const content = data[type]; + if (content instanceof Error) throw content; + return content instanceof Blob ? content : new Blob([content], { type }); + }, +}); +const snapshot = (data) => readEditorClipboard({ read: async () => [item(data)] }); + +describe("editor native clipboard snapshots", () => { + test("HTML wins without reading duplicate images or plain text", async () => { + expect(await snapshot({ "text/html": "

Alpha

", "text/plain": new Error("Unreadable"), "image/png": new Error("Unreadable") })) + .toEqual({ html: "

Alpha

", text: "", images: [] }); + }); + test("falls back from unreadable HTML to literal plain text", async () => { + expect(await snapshot({ "text/html": new Error("Unreadable"), "text/plain": "\ntext", "image/png": new Error("Unreadable") })) + .toEqual({ html: "", text: "\ntext", images: [] }); + }); + test("preserves multiple native text items with line breaks", async () => { + expect(await readEditorClipboard({ read: async () => [item({ "text/plain": "A\tB" }), item({ "text/plain": "C\nD" })] })) + .toEqual({ html: "", text: "A\tB\nC\nD", images: [] }); + }); + test("returns image-only PNG bytes rather than file paths or bookmarks", async () => { + const png = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]); + const result = await snapshot({ "image/png": png, "electron application/bookmark": new Error("Must not read"), "electron application/filename": new Error("Must not read") }); + expect(result.html).toBe(""); + expect(result.text).toBe(""); + expect(result.images).toHaveLength(1); + expect(result.images[0]).toBeInstanceOf(Uint8Array); + expect(result.images[0]).toEqual(png); + }); + test("empty and unsupported clipboard formats are a no-op", async () => { + expect(await readEditorClipboard({ read: async () => [] })).toEqual({ html: "", text: "", images: [] }); + expect(await snapshot({ "electron application/filename": new Error("Must not read") })).toEqual({ html: "", text: "", images: [] }); + }); + test("preserves all PNG clipboard items in their native order", async () => { + const first = Uint8Array.from([137, 80, 78, 71, 1]); + const second = Uint8Array.from([137, 80, 78, 71, 2]); + expect(await readEditorClipboard({ read: async () => [item({ "image/png": first }), item({ "image/png": second })] })) + .toEqual({ html: "", text: "", images: [first, second] }); + }); + test("enforces the image limit across all PNG items rather than per file", async () => { + const large = new Blob([new Uint8Array(16 * 1024 * 1024 + 1)]); + await expect(readEditorClipboard({ read: async () => [item({ "image/png": large }), item({ "image/png": large })] })) + .rejects.toThrow("Clipboard images exceed size limit"); + }); + test("propagates unavailable snapshots and enforces IPC size limits", async () => { + await expect(snapshot({ "text/html": new Error("Unreadable") })).rejects.toThrow("Unreadable"); + await expect(readEditorClipboard({ read: async () => { throw new Error("Read denied"); } })).rejects.toThrow("Read denied"); + await expect(snapshot({ "text/plain": new Blob([new Uint8Array(8 * 1024 * 1024 + 1)]) })).rejects.toThrow("Clipboard text exceeds size limit"); + await expect(snapshot({ "image/png": new Blob([new Uint8Array(32 * 1024 * 1024 + 1)]) })).rejects.toThrow("Clipboard images exceed size limit"); + expect(await snapshot({ "text/html": new Blob([new Uint8Array(8 * 1024 * 1024 + 1)]), "text/plain": "Fallback" })) + .toEqual({ html: "", text: "Fallback", images: [] }); + }); +}); + +describe("native clipboard snapshot authorization", () => { + const fixture = (rendererUrl = "edgeever-app://app/index.html") => { + const handlers = new Map(); + let reads = 0; + let getItems = async () => [item({ "text/plain": "Native snapshot" })]; + const mainFrame = { url: rendererUrl }; + const sender = { mainFrame, isDestroyed: () => false, isFocused: () => true }; + const window = { + webContents: sender, isDestroyed: () => false, isVisible: () => true, + isMinimized: () => false, isFocused: () => true, + }; + let currentWindow = window; + registerClipboardReadIpc({ handle: (channel, handler) => handlers.set(channel, handler) }, { + clipboard: { read: () => { reads++; return getItems(); } }, + getMainWindow: () => currentWindow, getRendererUrl: () => rendererUrl, + }); + return { + read: handlers.get("desktop:read-clipboard"), sender, window, + event: { sender, senderFrame: mainFrame }, count: () => reads, + setWindow: (value) => { currentWindow = value; }, + setItems: (value) => { getItems = value; }, + }; + }; + test("returns an explicit snapshot without dispatching a native paste event", async () => { + const context = fixture(); + expect(await context.read(context.event)).toEqual({ html: "", text: "Native snapshot", images: [] }); + expect(context.count()).toBe(1); + }); + test("rejects foreign senders, subframes, missing frames and absent windows before reading", async () => { + const context = fixture(); + for (const event of [undefined, { sender: {}, senderFrame: context.sender.mainFrame }, { sender: context.sender, senderFrame: { url: context.sender.mainFrame.url } }, { sender: context.sender }]) + await expect(context.read(event)).rejects.toThrow("Clipboard read forbidden"); + context.setWindow(null); + await expect(context.read(context.event)).rejects.toThrow("Clipboard read forbidden"); + expect(context.count()).toBe(0); + }); + test("rejects background, minimized, hidden and destroyed windows before reading", async () => { + for (const [object, method, value] of [["window", "isFocused", false], ["sender", "isFocused", false], ["window", "isVisible", false], ["window", "isMinimized", true], ["window", "isDestroyed", true], ["sender", "isDestroyed", true]]) { + const context = fixture(); + context[object][method] = () => value; + await expect(context.read(context.event)).rejects.toThrow("Clipboard read forbidden"); + expect(context.count()).toBe(0); + } + }); + test("permits app navigation and rejects untrusted renderer origins", async () => { + for (const rendererUrl of ["edgeever-app://app/index.html", "http://127.0.0.1:5173/", "file:///app/web/index.html"]) { + const context = fixture(rendererUrl); + context.sender.mainFrame.url = `${rendererUrl}#/memo/test`; + await context.read(context.event); + expect(context.count()).toBe(1); + for (const url of ["edgeever-app://evil/index.html", "https://example.org/", "http://127.0.0.1:5173@evil.example/", "file:///etc/passwd", "edgeever-resource://resource/html"]) { + context.sender.mainFrame.url = url; + await expect(context.read(context.event)).rejects.toThrow("Clipboard read forbidden"); + } + expect(context.count()).toBe(1); + } + }); + test("accepts no renderer-controlled clipboard payload or requested formats", async () => { + const context = fixture(); + for (const payload of [undefined, null, "text/plain", { html: "

attacker

" }, Uint8Array.from([1, 2])]) + await expect(context.read(context.event, payload)).rejects.toThrow("Clipboard read forbidden"); + expect(context.count()).toBe(0); + }); + test("rechecks focus, frame, origin and lifecycle before returning an async snapshot", async () => { + for (const invalidate of [ + (context) => { context.window.isFocused = () => false; }, + (context) => { context.sender.isFocused = () => false; }, + (context) => { context.window.isVisible = () => false; }, + (context) => { context.window.isMinimized = () => true; }, + (context) => { context.window.isDestroyed = () => true; }, + (context) => { context.sender.isDestroyed = () => true; }, + (context) => { context.sender.mainFrame = { url: context.sender.mainFrame.url }; }, + (context) => { context.sender.mainFrame.url = "https://attacker.example/"; }, + (context) => { context.setWindow(null); }, + ]) { + const context = fixture(); + context.setItems(async () => { invalidate(context); return [item({ "text/plain": "Sensitive snapshot" })]; }); + await expect(context.read(context.event)).rejects.toThrow("Clipboard read forbidden"); + expect(context.count()).toBe(1); + } + }); + test("preload requires isolated user activation and normalizes PNG bytes", async () => { + const source = readFileSync(new URL("../preload/index.cjs", import.meta.url), "utf8"); + let bridge; + const calls = []; + const userActivation = { isActive: false }; + runInNewContext(source, { + navigator: { userActivation }, + require: () => ({ + contextBridge: { exposeInMainWorld: (_name, value) => { bridge = value; } }, + ipcRenderer: { + sendSync: () => undefined, + invoke: (...args) => { calls.push(args); return Promise.resolve({ html: "", text: "", images: [{ type: "Buffer", data: [1, 2, 3] }] }); }, + }, + }), + }); + await expect(bridge.readClipboard()).rejects.toThrow("Clipboard read requires user activation"); + expect(calls).toEqual([]); + userActivation.isActive = true; + const result = await bridge.readClipboard({ format: "text/html" }); + expect(result.images).toHaveLength(1); + expect([...result.images[0]]).toEqual([1, 2, 3]); + expect(calls).toEqual([["desktop:read-clipboard"]]); + expect(bridge.pasteClipboard).toBeUndefined(); + }); + test("keeps renderer permissions denied and removes the native paste-event command", () => { + const main = readFileSync(new URL("./index.mjs", import.meta.url), "utf8"); + const preload = readFileSync(new URL("../preload/index.cjs", import.meta.url), "utf8"); + expect(main).toContain("registerClipboardReadIpc(ipcMain"); + expect(main).toContain("setPermissionRequestHandler((_webContents, _permission, callback) => callback(false))"); + expect(preload).toContain('ipcRenderer.invoke("desktop:read-clipboard")'); + expect(main + preload).not.toContain("desktop:paste-clipboard"); + }); +}); diff --git a/apps/desktop/src/main/index.mjs b/apps/desktop/src/main/index.mjs index 1437d2bc..56865a25 100644 --- a/apps/desktop/src/main/index.mjs +++ b/apps/desktop/src/main/index.mjs @@ -35,6 +35,7 @@ import { showWindow } from "./window-visibility.mjs"; import { globalShortcutAccelerator, normalizeGlobalShortcutBinding, replaceGlobalShortcut, toggleMainWindow } from "./global-shortcut.mjs"; import { trayIconPath } from "./tray-icon.mjs"; import { writeImageClipboard, writeRichClipboard, writeTextClipboard } from "./clipboard-write.mjs"; +import { registerClipboardReadIpc } from "./clipboard-read.mjs"; import { captureScreenToNote, createScreenshotCaptureGuard, screenshotImportIpcPayload, writeScreenshotTempPath } from "./screenshot-capture.mjs"; import { LocalDataResetError, scheduleMacLocalDataReset } from "./local-data-reset.mjs"; import { buildDesktopDiagnosticIssueUrl, normalizeDesktopDiagnostic } from "./desktop-diagnostics.mjs"; @@ -1479,6 +1480,10 @@ const attachEditContextMenu = (contents) => { }); }; +const desktopRendererEntryUrl = () => app.isPackaged && !process.env.EDGE_EVER_DESKTOP_WEB_URL + ? (usePrivateAppProtocol ? DESKTOP_APP_ENTRY_URL : pathToFileURL(join(process.resourcesPath, "web/index.html")).href) + : webUrl; + const createWindow = async () => { const state = await readWindowState(); mainWindow = new BrowserWindow({ @@ -1568,9 +1573,7 @@ const createWindow = async () => { void writeDiagnostic("renderer.responsive"); }); - const rendererEntryUrl = app.isPackaged && !process.env.EDGE_EVER_DESKTOP_WEB_URL - ? (usePrivateAppProtocol ? DESKTOP_APP_ENTRY_URL : pathToFileURL(join(process.resourcesPath, "web/index.html")).href) - : webUrl; + const rendererEntryUrl = desktopRendererEntryUrl(); const guardNavigation = (event, url) => { if (isAllowedAppNavigation(url, rendererEntryUrl)) return; event.preventDefault(); @@ -1767,6 +1770,11 @@ const startApplication = async () => { ipcMain.handle("desktop:copy-text", (_event, value) => writeTextClipboard(clipboard, value)); ipcMain.handle("desktop:copy-html", (_event, input) => writeRichClipboard(clipboard, ClipboardItem, input)); ipcMain.handle("desktop:copy-image", (_event, bytes) => writeImageClipboard(clipboard, ClipboardItem, bytes)); + registerClipboardReadIpc(ipcMain, { + clipboard, + getMainWindow: () => mainWindow, + getRendererUrl: desktopRendererEntryUrl, + }); ipcMain.handle("desktop:set-session-token", async (event, value) => { if (event.sender !== mainWindow?.webContents || event.senderFrame !== event.sender.mainFrame || instanceChangePending) throw new Error("Session update forbidden"); await saveDesktopSessionToken(value); diff --git a/apps/desktop/src/preload/index.cjs b/apps/desktop/src/preload/index.cjs index 280816dd..38f12b0d 100644 --- a/apps/desktop/src/preload/index.cjs +++ b/apps/desktop/src/preload/index.cjs @@ -40,6 +40,16 @@ contextBridge.exposeInMainWorld("edgeeverDesktop", Object.freeze({ copyText: (value) => ipcRenderer.invoke("desktop:copy-text", value), copyHtml: (html, plainText) => ipcRenderer.invoke("desktop:copy-html", { html, plainText }), copyImage: (bytes) => ipcRenderer.invoke("desktop:copy-image", bytes), + readClipboard: () => { + // Check in the isolated preload world; renderer JavaScript cannot forge this + // gesture state. Clipboard reads are reserved for an explicit paste action. + if (!navigator.userActivation?.isActive) return Promise.reject(new Error("Clipboard read requires user activation")); + return ipcRenderer.invoke("desktop:read-clipboard").then((snapshot) => ({ + html: snapshot.html, + text: snapshot.text, + images: snapshot.images.map(normalizeIpcBytes), + })); + }, setSessionToken: (value) => ipcRenderer.invoke("desktop:set-session-token", value), clearSessionToken: () => ipcRenderer.invoke("desktop:clear-session-token"), testPicGoConnection: (endpoint) => ipcRenderer.invoke("desktop:picgo-test", endpoint), diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index 206d9953..cc7fd89f 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -34,6 +34,7 @@ import { LockOpen, } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { EditorTableContextMenu } from "@/components/EditorTableContextMenu"; import { ClipboardCopyNotice } from "@/components/ClipboardCopyNotice"; import { WeChatCopyProgress } from "@/components/WeChatCopyProgress"; import { MemoEditorHeaderActions } from "@/components/MemoEditorHeaderActions"; @@ -4504,9 +4505,11 @@ const RichEditorPane = ({ onAsk={() => requestSelectionAi("ask")} /> - - - + + + + + )} diff --git a/apps/web/src/components/EditorTableContextMenu.tsx b/apps/web/src/components/EditorTableContextMenu.tsx new file mode 100644 index 00000000..7ad6c9db --- /dev/null +++ b/apps/web/src/components/EditorTableContextMenu.tsx @@ -0,0 +1,220 @@ +import { Fragment, useEffect, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from "react"; +import { useEditorState, type Editor } from "@tiptap/react"; +import { useTranslation } from "react-i18next"; +import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuShortcut, ContextMenuTrigger } from "@/components/ui/context-menu"; +import { ClipboardCopyNotice } from "@/components/ClipboardCopyNotice"; +import { copyHtmlToClipboard, copyTextToClipboard } from "@/lib/clipboard"; +import { readTableClipboard } from "@/lib/editor-table-clipboard"; +import { getEditorTableActions } from "@/lib/editor-table-actions"; +import { isMouseTableContextEvent, selectTableContextTarget } from "@/lib/editor-table-context"; + +export const EditorTableContextMenu = ({ editor, readOnly, children }: { + editor: Editor | null; + readOnly: boolean; + children: ReactNode; +}) => { + const { t } = useTranslation(); + const [notice, setNotice] = useState<{ message: string; status: "copied" | "error" } | null>(null); + const [busy, setBusy] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); + const menuOpenRef = useRef(false); + const changeMenuOpen = (open: boolean) => { menuOpenRef.current = open; setMenuOpen(open); }; + const closeSession = useRef<{ closed: Promise; release: () => void } | null>(null); + const currentReadOnly = useRef(readOnly); + currentReadOnly.current = readOnly; + const pending = useRef(false); + const interactedOutside = useRef(false); + const trigger = useRef(null); + const pointerType = useRef(null); + const target = useRef(null); + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { mounted.current = false; closeSession.current?.release(); }; + }, []); + useEffect(() => { + if (!notice) return; + const timer = window.setTimeout(() => setNotice(null), 5000); + return () => window.clearTimeout(timer); + }, [notice]); + const state = useEditorState({ editor, selector: ({ editor: active }) => ({ + canCopy: Boolean(active && !active.state.selection.empty), + disabled: getEditorTableActions(active, readOnly).map((action) => action.disabled), + }) }); + const canCopy = Boolean(state?.canCopy); + const modifier = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform) ? "⌘" : "Ctrl+"; + + const openMenu = (cell: HTMLElement, point: { left: number; top: number }, keyboard = false) => { + if (readOnly || pending.current || !editor || editor.isDestroyed + || !selectTableContextTarget(editor.view, cell, keyboard ? undefined : point)) return false; + closeSession.current?.release(); + let release!: () => void; + closeSession.current = { closed: new Promise((resolve) => { release = resolve; }), release }; + interactedOutside.current = false; + target.current = editor.state; + setNotice(null); + // Keep Radix's trigger separate from the editor: its native long-press handler + // and touch-callout style must not interfere with text selection or node views. + trigger.current?.dispatchEvent(new globalThis.MouseEvent("contextmenu", { + bubbles: true, cancelable: true, button: 2, clientX: point.left, clientY: point.top, + })); + return true; + }; + + const prepare = (event: MouseEvent) => { + if (event.target === trigger.current || event.defaultPrevented + || !isMouseTableContextEvent(event.nativeEvent, pointerType.current)) return; + if (!(event.target instanceof HTMLElement) || !event.target.isContentEditable) return; + const cell = event.target instanceof Element ? event.target.closest("td, th") : null; + if (cell && openMenu(cell, { left: event.clientX, top: event.clientY })) event.preventDefault(); + }; + + const validTarget = () => { + if (!editor || editor.isDestroyed || !target.current) return false; + if (editor.state.doc === target.current.doc && editor.state.selection.eq(target.current.selection)) return true; + setNotice({ message: t("aiAssistant.sidebar.selection.stale"), status: "error" }); + return false; + }; + + const openFromKeyboard = (event: KeyboardEvent) => { + if (event.defaultPrevented || (event.key !== "ContextMenu" && !(event.key === "F10" && event.shiftKey))) return; + if (!editor || editor.isDestroyed || !editor.isEditable || readOnly || !editor.isActive("table") + || !(event.target instanceof HTMLElement) || !editor.view.dom.contains(event.target) + || !event.target.isContentEditable) return; + const { node } = editor.view.domAtPos(editor.state.selection.from); + const element = node instanceof Element ? node : node.parentElement; + const cell = element?.closest("td, th"); + if (!cell || !editor.view.dom.contains(cell)) return; + const bounds = editor.view.coordsAtPos(editor.state.selection.from); + if (openMenu(cell, { left: bounds.left, top: bounds.bottom }, true)) event.preventDefault(); + }; + + const restoreEditorFocus = (session: typeof closeSession.current) => { + if (!mounted.current || session !== closeSession.current || menuOpenRef.current || interactedOutside.current + || currentReadOnly.current || !editor || editor.isDestroyed || !editor.isEditable) return; + const active = editor.view.dom.ownerDocument.activeElement; + if (active === editor.view.dom.ownerDocument.body || (active && editor.view.dom.contains(active))) editor.view.focus(); + }; + + const copy = async () => { + if (!editor || editor.isDestroyed || editor.state.selection.empty || !validTarget()) return; + const session = closeSession.current; + const { doc, selection } = editor.state; + const unchanged = () => mounted.current && !editor.isDestroyed && session === closeSession.current + && editor.state.doc === doc && editor.state.selection.eq(selection); + try { + const { dom, text } = editor.view.serializeForClipboard(editor.state.selection.content()); + try { + // Legacy clipboard helpers change the DOM selection. Let the modal scope exit first. + const legacy = !window.edgeeverDesktop?.isAvailable && !(navigator.clipboard && "ClipboardItem" in window); + if (legacy) { await session?.closed; if (!unchanged()) return; } + await copyHtmlToClipboard(dom.innerHTML, text); + if (legacy && unchanged()) restoreEditorFocus(session); + } catch { + await session?.closed; + if (!unchanged()) return; + if (!await copyTextToClipboard(text)) throw new Error("Clipboard copy unavailable"); + if (unchanged()) restoreEditorFocus(session); + } + if (mounted.current) setNotice({ message: t("common.copied"), status: "copied" }); + } catch { + if (mounted.current) setNotice({ message: `${t("systemInfo.copyFailed")} (${modifier}C)`, status: "error" }); + } + }; + + const paste = async () => { + if (!editor || editor.isDestroyed || !editor.isEditable || readOnly || pending.current || !validTarget()) return; + const { doc, selection } = editor.state; + const session = closeSession.current; + const unchanged = () => mounted.current && !editor.isDestroyed && editor.isEditable && !currentReadOnly.current + && session === closeSession.current && editor.state.doc === doc && editor.state.selection.eq(selection); + const stale = () => { + if (mounted.current) setNotice({ message: t("aiAssistant.sidebar.selection.stale"), status: "error" }); + }; + pending.current = true; + setBusy(true); + try { + const desktop = window.edgeeverDesktop; + // Read in the user gesture. The desktop bridge returns a snapshot instead + // of dispatching a delayed native paste into whichever input has focus. + const clipboard = desktop?.isAvailable && desktop.readClipboard + ? desktop.readClipboard().then(({ html, text, images }) => ({ + html, text, images: images.map((image, index) => new File([Uint8Array.from(image)], `clipboard-${index + 1}.png`, { type: "image/png" })), + })) + : readTableClipboard(navigator.clipboard); + const [{ html, text, images }] = await Promise.all([clipboard, session?.closed]); + // A permission prompt may outlive a note switch or an edit. Never paste into a changed target. + if (!mounted.current || editor.isDestroyed) return; + if (!unchanged()) { stale(); return; } + if (html) editor.view.pasteHTML(html); + else if (text) editor.view.pasteText(text); + else if (images.length) { + const transfer = new DataTransfer(); + for (const image of images) transfer.items.add(image); + // Reuse the editor's existing attachment upload pipeline. + editor.view.dom.dispatchEvent(new ClipboardEvent("paste", { clipboardData: transfer, bubbles: true, cancelable: true })); + } + } catch { + if (mounted.current) setNotice({ message: `${t("editor.clipboardReadFailed")} (${modifier}V)`, status: "error" }); + } finally { + pending.current = false; + if (mounted.current) setBusy(false); + } + }; + + const runTableAction = async (action: ReturnType[number]) => { + if (!validTarget()) return; + const session = closeSession.current; + await session?.closed; + // Table transactions also synchronize the DOM selection. Keep those outside + // the modal focus scope, and cancel if the user changed the target meanwhile. + if (mounted.current && session === closeSession.current && !currentReadOnly.current && validTarget()) action.run({ focus: false }); + }; + + const renderedSession = closeSession.current; + return ( + +
{ pointerType.current = event.pointerType; }} + > + {children} + + +
+ { interactedOutside.current = true; }} + onCloseAutoFocus={(event) => { + event.preventDefault(); + const session = renderedSession; + // Radix removes its focus trap after this callback. TipTap's focus command is + // synchronous on Safari, so restore focus only in the following task. + window.setTimeout(() => { + restoreEditorFocus(session); + session?.release(); + }, 0); + }} + > + { void copy(); changeMenuOpen(false); }}> + {t("aiAssistant.sidebar.selection.copy")}{modifier}C + + { void paste(); changeMenuOpen(false); }}> + {t("editor.paste")}{modifier}V + + + {getEditorTableActions(editor, readOnly).map((action) => ( + + {action.destructive ? : null} + { void runTableAction(action); changeMenuOpen(false); }}> + {t(action.key)} + + + ))} + + {notice ? {notice.message} : null} +
+ ); +}; diff --git a/apps/web/src/components/EditorTableMenu.tsx b/apps/web/src/components/EditorTableMenu.tsx index 394646b0..edee62f8 100644 --- a/apps/web/src/components/EditorTableMenu.tsx +++ b/apps/web/src/components/EditorTableMenu.tsx @@ -1,3 +1,5 @@ +import { Fragment } from "react"; +import { getEditorTableActions } from "@/lib/editor-table-actions"; import { useEditorState, type Editor } from "@tiptap/react"; import { Table } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -21,12 +23,12 @@ export const EditorTableMenu = ({ editor, readOnly }: EditorTableMenuProps) => { const ready = Boolean(editor && !editor.isDestroyed); const tableActiveState = useEditorState({ editor, - selector: ({ editor: activeEditor }) => - (activeEditor?.isActive("table") ? 1 : 0) - | (activeEditor?.isActive("tableHeader") ? 2 : 0), + selector: ({ editor: activeEditor }) => ({ + inTable: activeEditor?.isActive("table") ?? false, + disabled: getEditorTableActions(activeEditor, readOnly).map((action) => action.disabled), + }), }); - const inTable = Boolean((tableActiveState ?? 0) & 1); - const inTableHeader = Boolean((tableActiveState ?? 0) & 2); + const inTable = Boolean(tableActiveState?.inTable); const disabled = readOnly || !ready; const run = (command: (activeEditor: Editor) => void) => { @@ -61,43 +63,18 @@ export const EditorTableMenu = ({ editor, readOnly }: EditorTableMenuProps) => { {t("editorToolbar.table")} - run((activeEditor) => activeEditor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run())} - > + run((activeEditor) => activeEditor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run())}> {t("editorToolbar.insertTable")} - run((activeEditor) => activeEditor.chain().focus().addRowAfter().run())}> - {t("editorToolbar.addTableRow")} - - run((activeEditor) => { - if (!activeEditor.isActive("tableHeader")) { - activeEditor.chain().focus().deleteRow().run(); - } - })} - > - {t("editorToolbar.deleteTableRow")} - - run((activeEditor) => activeEditor.chain().focus().addColumnAfter().run())}> - {t("editorToolbar.addTableColumn")} - - run((activeEditor) => activeEditor.chain().focus().deleteColumn().run())}> - {t("editorToolbar.deleteTableColumn")} - - run((activeEditor) => activeEditor.chain().focus().toggleHeaderRow().run())}> - {t("editorToolbar.toggleTableHeader")} - - - run((activeEditor) => activeEditor.chain().focus().deleteTable().run())} - > - {t("editorToolbar.deleteTable")} - + {getEditorTableActions(editor, readOnly).map((action) => ( + + {action.destructive ? : null} + action.run()}> + {t(action.key)} + + + ))} ); diff --git a/apps/web/src/lib/editor-table-actions.test.mjs b/apps/web/src/lib/editor-table-actions.test.mjs new file mode 100644 index 00000000..00a8948c --- /dev/null +++ b/apps/web/src/lib/editor-table-actions.test.mjs @@ -0,0 +1,135 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { Editor, Extension } from '@tiptap/core'; +import StarterKit from '@tiptap/starter-kit'; +import { EdgeEverTableKit as TableKit } from '../../../../packages/shared/src/markdown-table.ts'; +import { CellSelection } from '@tiptap/pm/tables'; +import { AllSelection, NodeSelection } from '@tiptap/pm/state'; +import { getEditorTableActions } from './editor-table-actions.ts'; + +const cell = (type, text) => ({ type, content: [{ type: 'paragraph', content: [{ type: 'text', text }] }] }); +const makeEditor = () => { + let focusCalls = 0; + const editor = new Editor({ + extensions: [StarterKit, TableKit, Extension.create({ + name: 'headlessFocus', + addCommands: () => ({ focus: () => () => { focusCalls++; return true; } }), + })], + content: { type: 'doc', content: [{ type: 'table', content: [ + { type: 'tableRow', content: [cell('tableHeader', 'H1'), cell('tableHeader', 'H2')] }, + { type: 'tableRow', content: [cell('tableCell', 'Alpha'), cell('tableCell', 'Bravo')] }, + { type: 'tableRow', content: [cell('tableCell', 'Charlie'), cell('tableCell', 'Delta')] }, + { type: 'tableRow', content: [cell('tableCell', 'Echo'), cell('tableCell', 'Foxtrot')] }, + ] }] }, + }); + // An unmounted TipTap editor reports isDestroyed despite its usable headless + // state. The test exercises real selections and commands without a DOM view. + Object.defineProperty(editor, 'isDestroyed', { value: false, configurable: true }); + const positions = []; + editor.state.doc.descendants((node, pos) => { + if (['cell', 'header_cell'].includes(node.type.spec.tableRole)) positions.push(pos); + }); + return { editor, positions, get focusCalls() { return focusCalls; } }; +}; +const action = (editor, key = 'editorToolbar.deleteTableRow', readOnly = false) => + getEditorTableActions(editor, readOnly).find((item) => item.key === key); +const selectCells = (editor, positions, anchor, head) => + editor.view.dispatch(editor.state.tr.setSelection(CellSelection.create(editor.state.doc, positions[anchor], positions[head]))); + +test('header protection covers cell selections in both directions', () => { + for (const [anchor, head] of [[0, 3], [3, 0]]) { + const { editor, positions } = makeEditor(); + selectCells(editor, positions, anchor, head); + const before = editor.state.doc; + assert.equal(action(editor).disabled, true); + action(editor).run(); + assert.equal(editor.state.doc, before); + editor.destroy(); + } +}); + +test('body cell selections delete all selected body rows while preserving the header', () => { + const { editor, positions } = makeEditor(); + selectCells(editor, positions, 2, 5); + assert.equal(action(editor).disabled, false); + action(editor).run(); + const table = editor.state.doc.firstChild; + assert.equal(table.childCount, 2); + assert.equal(table.firstChild.textContent, 'H1H2'); + assert.equal(table.lastChild.textContent, 'EchoFoxtrot'); + editor.destroy(); +}); + +test('a mixed header/body row is protected when its body cell is the target', () => { + const { editor } = makeEditor(); + editor.commands.setContent({ type: 'doc', content: [{ type: 'table', content: [ + { type: 'tableRow', content: [cell('tableHeader', 'Header'), cell('tableCell', 'Body')] }, + { type: 'tableRow', content: [cell('tableCell', 'Other'), cell('tableCell', 'Cell')] }, + ] }] }); + let bodyPos; + editor.state.doc.descendants((node, pos) => { if (node.type.spec.tableRole === 'cell' && bodyPos == null) bodyPos = pos; }); + editor.commands.setTextSelection(bodyPos + 2); + assert.equal(editor.isActive('tableHeader'), false); + const before = editor.state.doc; + assert.equal(action(editor).disabled, true); + action(editor).run(); + assert.equal(editor.state.doc, before); + editor.destroy(); +}); + +test('an already-created action rechecks header selection before running', () => { + const { editor, positions } = makeEditor(); + editor.commands.setTextSelection(positions[2] + 2); + const deleteRow = action(editor); + assert.equal(deleteRow.disabled, false); + selectCells(editor, positions, 0, 3); + const before = editor.state.doc; + deleteRow.run(); + assert.equal(editor.state.doc, before); + editor.destroy(); +}); + +test('read-only and dynamically noneditable editors disable mutations and refuse stale actions', () => { + const { editor, positions } = makeEditor(); + editor.commands.setTextSelection(positions[2] + 2); + const deleteTable = action(editor, 'editorToolbar.deleteTable'); + const before = editor.state.doc; + assert.equal(action(editor, 'editorToolbar.deleteTable', true).disabled, true); + action(editor, 'editorToolbar.deleteTable', true).run(); + assert.equal(editor.state.doc, before); + editor.setEditable(false); + assert.equal(getEditorTableActions(editor, false).every((item) => item.disabled), true); + deleteTable.run(); + assert.equal(editor.state.doc, before); + editor.destroy(); +}); + +test('document and table-node selections keep table commands disabled instead of resolving a nonexistent target cell', () => { + for (const createSelection of [ + (doc) => new AllSelection(doc), + (doc) => NodeSelection.create(doc, 0), + ]) { + const { editor } = makeEditor(); + editor.view.dispatch(editor.state.tr.setSelection(createSelection(editor.state.doc))); + assert.equal(editor.isActive('table'), true); + const before = editor.state.doc; + const actions = getEditorTableActions(editor, false); + assert.equal(actions.every((item) => item.disabled), true); + for (const item of actions) item.run(); + assert.equal(editor.state.doc, before); + editor.destroy(); + } +}); + +test('shared actions can omit focus while toolbar actions retain the default focus behavior', () => { + for (const focus of [true, false]) { + const fixture = makeEditor(); + const { editor, positions } = fixture; + editor.commands.setTextSelection(positions[2] + 2); + if (focus) action(editor).run(); + else action(editor).run({ focus: false }); + assert.equal(editor.state.doc.firstChild.childCount, 3); + assert.equal(fixture.focusCalls, focus ? 1 : 0); + editor.destroy(); + } +}); diff --git a/apps/web/src/lib/editor-table-actions.ts b/apps/web/src/lib/editor-table-actions.ts new file mode 100644 index 00000000..bccf7890 --- /dev/null +++ b/apps/web/src/lib/editor-table-actions.ts @@ -0,0 +1,41 @@ +import type { Editor } from "@tiptap/react"; +import { isInTable, selectedRect } from "@tiptap/pm/tables"; + +const targetsHeaderRow = (editor: Editor) => { + // CellSelection.from/to describe the head cell, so isActive("tableHeader") + // misses header rows included elsewhere in a rectangular cell selection. + const { table, top, bottom } = selectedRect(editor.state); + for (let rowIndex = top; rowIndex < bottom; rowIndex++) { + const row = table.child(rowIndex); + for (let cellIndex = 0; cellIndex < row.childCount; cellIndex++) { + if (row.child(cellIndex).type.spec.tableRole === "header_cell") return true; + } + } + return false; +}; + +/** Both menus share commands and the existing header-row protection. */ +export const getEditorTableActions = (editor: Editor | null, readOnly: boolean) => { + const inTable = Boolean(editor && !editor.isDestroyed && editor.isActive("table") && isInTable(editor.state)); + const headerRow = inTable && editor ? targetsHeaderRow(editor) : false; + const actions = [ + { key: "editorToolbar.addTableRow", command: "addRowAfter" }, + { key: "editorToolbar.deleteTableRow", command: "deleteRow", protectHeader: true }, + { key: "editorToolbar.addTableColumn", command: "addColumnAfter" }, + { key: "editorToolbar.deleteTableColumn", command: "deleteColumn" }, + { key: "editorToolbar.toggleTableHeader", command: "toggleHeaderRow" }, + { key: "editorToolbar.deleteTable", command: "deleteTable", destructive: true }, + ] as const; + return actions.map((action) => ({ + key: action.key, + destructive: "destructive" in action, + disabled: readOnly || !inTable || !editor?.isEditable || ("protectHeader" in action && headerRow), + run: ({ focus = true }: { focus?: boolean } = {}) => { + if (!editor || editor.isDestroyed || !editor.isEditable || readOnly || !editor.isActive("table") || !isInTable(editor.state)) return; + if ("protectHeader" in action && targetsHeaderRow(editor)) return; + const chain = editor.chain(); + if (focus) chain.focus(); + chain[action.command]().run(); + }, + })); +}; diff --git a/apps/web/src/lib/editor-table-clipboard.test.mjs b/apps/web/src/lib/editor-table-clipboard.test.mjs new file mode 100644 index 00000000..4c71a5dc --- /dev/null +++ b/apps/web/src/lib/editor-table-clipboard.test.mjs @@ -0,0 +1,49 @@ +import { expect, test } from 'bun:test'; +import { readTableClipboard } from './editor-table-clipboard.ts'; + +const item = (data) => ({ + types: Object.keys(data), + async getType(type) { + const content = data[type]; + if (content instanceof Error) throw content; + return new Blob([content], { type }); + }, +}); + +test('rich text wins without reading duplicate images or a failing plain-text representation', async () => { + const clipboard = { read: async () => [item({ + 'text/html': '

Alpha

', + 'text/plain': new Error('Plain text not readable'), + 'image/png': new Error('Image not readable'), + })] }; + expect(await readTableClipboard(clipboard)).toEqual({ html: '

Alpha

', text: '', images: [] }); +}); +test('a failing HTML representation falls back to available plain text', async () => { + expect(await readTableClipboard({ read: async () => [item({ 'text/html': new Error('format unavailable'), 'text/plain': '\ntext' })] })) + .toEqual({ html: '', text: '\ntext', images: [] }); +}); +test('plain text is preserved, and multiple items have a line break', async () => { + expect((await readTableClipboard({ read: async () => [item({ 'text/plain': 'A\tB' }), item({ 'text/plain': 'C\nD' })] })).text) + .toBe('A\tB\nC\nD'); +}); +test('browsers with only readText are supported without converting text to HTML', async () => { + expect(await readTableClipboard({ readText: async () => '' })) + .toEqual({ html: '', text: '', images: [] }); +}); +test('image-only contents are passed on as files with their MIME types', async () => { + const result = await readTableClipboard({ read: async () => [item({ 'image/png': 'image-bytes' })] }); + expect(result.html).toBe(''); + expect(result.text).toBe(''); + expect(result.images).toHaveLength(1); + expect(result.images[0].type).toBe('image/png'); + expect(await result.images[0].text()).toBe('image-bytes'); +}); +test('empty clipboard is a no-op', async () => { + expect(await readTableClipboard({ read: async () => [] })).toEqual({ html: '', text: '', images: [] }); +}); +test('denied and missing clipboard access fail for the caller to show its shortcut hint', async () => { + await expect(readTableClipboard(undefined)).rejects.toThrow('Clipboard read unavailable'); + await expect(readTableClipboard({ read: async () => { throw new Error('Denied'); } })).rejects.toThrow('Denied'); + await expect(readTableClipboard({ readText: async () => { throw new Error('Denied'); } })).rejects.toThrow('Denied'); + await expect(readTableClipboard({ read: async () => [item({ 'text/html': new Error('unreadable') })] })).rejects.toThrow('unreadable'); +}); diff --git a/apps/web/src/lib/editor-table-clipboard.ts b/apps/web/src/lib/editor-table-clipboard.ts new file mode 100644 index 00000000..221d643f --- /dev/null +++ b/apps/web/src/lib/editor-table-clipboard.ts @@ -0,0 +1,37 @@ +type ClipboardReader = Partial>; + +export type TableClipboardContent = { html: string; text: string; images: File[] }; + +/** Prefer rich text, then plain text, then image uploads, as with a normal paste. */ +export const readTableClipboard = async (clipboard: ClipboardReader | undefined): Promise => { + if (!clipboard?.read) { + if (!clipboard?.readText) throw new Error("Clipboard read unavailable"); + return { html: "", text: await clipboard.readText(), images: [] }; + } + const items = await clipboard.read(); + let error: unknown; + const readTextType = async (type: string) => { + const parts: string[] = []; + for (const item of items) { + if (!item.types.includes(type)) continue; + try { + parts.push(await (await item.getType(type)).text()); + } catch (cause) { + error = cause; + } + } + return parts.join(type === "text/plain" ? "\n" : ""); + }; + const html = await readTextType("text/html"); + if (html) return { html, text: "", images: [] }; + const text = await readTextType("text/plain"); + if (text) return { html: "", text, images: [] }; + const images: File[] = []; + for (const item of items) { + for (const type of item.types.filter((type) => type.startsWith("image/"))) { + images.push(new File([await item.getType(type)], `clipboard.${type.split("/")[1]}`, { type })); + } + } + if (!images.length && error) throw error; + return { html: "", text: "", images }; +}; diff --git a/apps/web/src/lib/editor-table-context.test.mjs b/apps/web/src/lib/editor-table-context.test.mjs new file mode 100644 index 00000000..d2d76d93 --- /dev/null +++ b/apps/web/src/lib/editor-table-context.test.mjs @@ -0,0 +1,109 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { Schema } from '@tiptap/pm/model'; +import { EditorState, TextSelection } from '@tiptap/pm/state'; +import { CellSelection, tableNodes } from '@tiptap/pm/tables'; +import { isMouseTableContextEvent, selectTableContextTarget } from './editor-table-context.ts'; + +const schema = new Schema({ nodes: { + doc: { content: 'block+' }, paragraph: { content: 'text*', group: 'block' }, text: {}, + ...tableNodes({ tableGroup: 'block', cellContent: 'paragraph+' }), +} }); +const paragraph = (text) => schema.nodes.paragraph.create(null, text ? schema.text(text) : null); +const cell = (text) => schema.nodes.table_cell.create(null, paragraph(text)); +const doc = schema.nodes.doc.create(null, [paragraph('outside'), schema.nodes.table.create(null, [ + schema.nodes.table_row.create(null, [cell('Alpha'), cell('Bravo')]), + schema.nodes.table_row.create(null, [cell('Charlie'), cell('Delta')]), +])]); +const positions = []; +doc.descendants((node, pos) => { if (node.type.name === 'table_cell') positions.push(pos); }); +const mockView = (selection, index = 0, hit = positions[index] + 2) => ({ + state: EditorState.create({ doc, selection }), + editable: true, + dom: { contains: () => true }, + posAtDOM: () => positions[index] + 1, + posAtCoords: () => ({ pos: hit }), + dispatch(transaction) { this.state = this.state.apply(transaction); }, +}); + +test('right-click targets clicked cell instead of old cursor', () => { + const view = mockView(TextSelection.create(doc, positions[0] + 2), 3); + assert.equal(selectTableContextTarget(view, {} , { left: 0, top: 0 }), true); + assert.equal(view.state.selection.from, positions[3] + 2); +}); +test('selection within clicked text is preserved for copy and replacement', () => { + const selection = TextSelection.create(doc, positions[0] + 2, positions[0] + 7); + const view = mockView(selection, 0, positions[0] + 4); + selectTableContextTarget(view, {}, { left: 0, top: 0 }); + assert.equal(view.state.selection, selection); +}); +test('click outside text selection resets the target', () => { + const view = mockView(TextSelection.create(doc, positions[0] + 2, positions[0] + 7), 2); + selectTableContextTarget(view, {}, { left: 0, top: 0 }); + assert.equal(view.state.selection.from, positions[2] + 2); + assert.equal(view.state.selection.empty, true); +}); +test('selected cells stay selected; an unselected cell resets selection', () => { + const selection = CellSelection.create(doc, positions[0], positions[1]); + const selected = mockView(selection, 1); + selectTableContextTarget(selected, {}, { left: 0, top: 0 }); + assert.equal(selected.state.selection, selection); + const outside = mockView(selection, 3); + selectTableContextTarget(outside, {}, { left: 0, top: 0 }); + assert.equal(outside.state.selection instanceof CellSelection, false); + assert.equal(outside.state.selection.from, positions[3] + 2); +}); +test('empty cell area and keyboard context invocation find a valid text cursor', () => { + const view = mockView(TextSelection.create(doc, 1), 1, 1); + selectTableContextTarget(view, {}, { left: 0, top: 0 }); + assert.equal(view.state.selection.from, positions[1] + 2); +}); +test('cell-end whitespace stays in the clicked cell instead of the next cell or paragraph', () => { + for (const index of [0, 3]) { + const cellEnd = positions[index] + 1 + doc.nodeAt(positions[index]).content.size; + const view = mockView(TextSelection.create(doc, 1), index, cellEnd); + assert.equal(selectTableContextTarget(view, {}, { left: 0, top: 0 }), true); + assert.equal(view.state.selection.from, cellEnd - 1); + assert.equal(view.state.selection.$from.node(-1), doc.nodeAt(positions[index])); + } +}); +test('table-like DOM inside node views must map to an actual editor table cell', () => { + const view = mockView(TextSelection.create(doc, positions[0] + 2)); + const selection = view.state.selection; + for (const contentPos of [0, 1, 2, doc.content.size + 1]) { + view.posAtDOM = () => contentPos; + assert.equal(selectTableContextTarget(view, {}, { left: 0, top: 0 }), false); + assert.equal(view.state.selection, selection); + } +}); +test('read-only and foreign cells keep the selection unchanged', () => { + const view = mockView(TextSelection.create(doc, 1)); + view.editable = false; + assert.equal(selectTableContextTarget(view, {}), false); + view.editable = true; + view.dom.contains = () => false; + assert.equal(selectTableContextTarget(view, {}), false); + assert.equal(view.state.selection.from, 1); +}); + +test('keyboard context preserves selection and cursor within the current cell', () => { + const selection = TextSelection.create(doc, positions[0] + 3, positions[0] + 6); + const view = mockView(selection); + selectTableContextTarget(view, {}); + assert.equal(view.state.selection, selection); + const cursor = mockView(TextSelection.create(doc, positions[0] + 5)); + selectTableContextTarget(cursor, {}); + assert.equal(cursor.state.selection.from, positions[0] + 5); +}); + +test('mouse and legacy mouse events are accepted, touch and pen stay native', () => { + assert.equal(isMouseTableContextEvent({ shiftKey: false, pointerType: 'mouse' }, null), true); + assert.equal(isMouseTableContextEvent({ shiftKey: false }, 'mouse'), true); + for (const pointerType of ['touch', 'pen']) { + assert.equal(isMouseTableContextEvent({ shiftKey: false, pointerType }, 'mouse'), false); + assert.equal(isMouseTableContextEvent({ shiftKey: false }, pointerType), false); + } + assert.equal(isMouseTableContextEvent({ shiftKey: true, pointerType: 'mouse' }, 'mouse'), false); + assert.equal(isMouseTableContextEvent({ shiftKey: false, sourceCapabilities: { firesTouchEvents: true } }, 'mouse'), false); + assert.equal(isMouseTableContextEvent({ shiftKey: false }, null), false); +}); diff --git a/apps/web/src/lib/editor-table-context.ts b/apps/web/src/lib/editor-table-context.ts new file mode 100644 index 00000000..f55cf876 --- /dev/null +++ b/apps/web/src/lib/editor-table-context.ts @@ -0,0 +1,39 @@ +import type { EditorView } from "@tiptap/pm/view"; +import { TextSelection } from "@tiptap/pm/state"; +import { CellSelection } from "@tiptap/pm/tables"; + +/** Right-click inside a selection preserves it; elsewhere it targets the clicked cell. */ +export const selectTableContextTarget = (view: EditorView, cell: HTMLElement, coordinates?: { left: number; top: number }) => { + if (!view.dom.contains(cell) || !view.editable) return false; + const cellContentPos = view.posAtDOM(cell, 0); + if (cellContentPos < 1 || cellContentPos > view.state.doc.content.size) return false; + const cellNode = view.state.doc.nodeAt(cellContentPos - 1); + if (!cellNode || !["cell", "header_cell"].includes(cellNode.type.spec.tableRole)) return false; + const cellEnd = cellContentPos + cellNode.content.size; + const { selection } = view.state; + const hit = coordinates ? view.posAtCoords(coordinates)?.pos : selection.from; + const pos = hit != null && hit >= cellContentPos && hit <= cellEnd + ? hit : cellContentPos; + let insideSelection = !selection.empty && pos >= selection.from && pos <= selection.to; + if (selection instanceof CellSelection) { + insideSelection = false; + selection.forEachCell((_node, cellPos) => { + if (cellPos + 1 === cellContentPos) insideSelection = true; + }); + } + if (!insideSelection) { + // Searching forward from the content-end boundary would select the next + // cell. Search back into this cell and reject unusable node-view targets. + const next = TextSelection.near(view.state.doc.resolve(pos), pos === cellEnd ? -1 : 1); + if (next.from < cellContentPos || next.to > cellEnd) return false; + view.dispatch(view.state.tr.setSelection(next)); + } + return true; +}; + +/** Legacy mouse contextmenu events may omit pointerType; remember the initiating pointer. */ +export const isMouseTableContextEvent = ( + event: { shiftKey: boolean; pointerType?: string; sourceCapabilities?: { firesTouchEvents?: boolean } | null }, + lastPointerType: string | null, +) => !event.shiftKey && !event.sourceCapabilities?.firesTouchEvents + && (event.pointerType || lastPointerType) === "mouse"; diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 63cf2c49..80e6c065 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -38,6 +38,7 @@ interface EdgeEverDesktopBridge { copyText(value: string): Promise; copyHtml(html: string, plainText: string): Promise; copyImage(bytes: Uint8Array): Promise; + readClipboard?(): Promise<{ html: string; text: string; images: Uint8Array[] }>; setSessionToken(value: string): Promise<{ stored: boolean }>; clearSessionToken(): Promise<{ stored: false }>; testPicGoConnection(endpoint: string): Promise; diff --git a/tests/browser/editor-table-context.browser.tsx b/tests/browser/editor-table-context.browser.tsx new file mode 100644 index 00000000..9afe126e --- /dev/null +++ b/tests/browser/editor-table-context.browser.tsx @@ -0,0 +1,385 @@ +// Open through Vite's /@fs//tests/browser/editor-table-context.html. +// Real React, Radix, TipTap and ProseMirror integration; no production notes or APIs. +import React from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { Editor, EditorContent } from "@tiptap/react"; +import { Node } from "@tiptap/core"; +import StarterKit from "@tiptap/starter-kit"; +import { EdgeEverTableKit as TableKit } from "../../packages/shared/src/markdown-table"; +import { CellSelection } from "@tiptap/pm/tables"; +import { I18nextProvider } from "react-i18next"; +import i18next from "i18next"; +import { enUS } from "../../packages/shared/src/i18n/en-US"; +import { EditorTableContextMenu } from "../../apps/web/src/components/EditorTableContextMenu"; +import { EditorTableMenu } from "../../apps/web/src/components/EditorTableMenu"; +import { TooltipProvider } from "../../apps/web/src/components/ui/tooltip"; +import "../../apps/web/src/styles/globals.css"; + +const i18n = i18next.createInstance(); +await i18n.init({ lng: "en", resources: { en: { translation: enUS } }, initAsync: false }); +const style = document.createElement("style"); +style.textContent = ".ProseMirror{outline:none;padding:12px}.ProseMirror table{border-collapse:collapse;width:540px}.ProseMirror td,.ProseMirror th{border:1px solid #aaa;min-width:150px;padding:12px}.selectedCell{background:#def}"; +document.head.appendChild(style); +const diagnostics = document.createElement('div'); +diagnostics.style.cssText = 'position:fixed;bottom:0;left:0;z-index:1000;background:white;color:black;font:12px monospace;padding:4px'; +document.body.appendChild(diagnostics); +let ticks = 0; +setInterval(() => { diagnostics.textContent = `tick=${++ticks} visibility=${document.visibilityState} state=${document.querySelector('[data-table-context-open]')?.getAttribute('data-table-context-open')} menu=${document.querySelector('[role="menu"]')?.getAttribute('data-state')} active=${document.activeElement?.tagName}`; }, 100); +const fixture = document.getElementById("fixture")!; +const originalClipboard = Object.getOwnPropertyDescriptor(navigator, "clipboard"); +const originalDesktop = Object.getOwnPropertyDescriptor(window, "edgeeverDesktop"); +let editor: Editor; +let root: Root | undefined; +let receivedNodeContext = 0; +let receivedPointer = 0; +let uploaded: File[] = []; +let writes: ClipboardItem[][] = []; +const TestResource = Node.create({ + name: "testResource", group: "inline", inline: true, atom: true, + parseHTML: () => [{ tag: "span[data-test-resource]" }], + renderHTML: () => ["span", { "data-test-resource": "" }], + addNodeView: () => () => { + const dom = document.createElement("span"); dom.dataset.testResource = ""; dom.textContent = "Resource"; + dom.addEventListener("pointerdown", () => receivedPointer++); + dom.addEventListener("contextmenu", (event) => { receivedNodeContext++; event.preventDefault(); }); + return { dom }; + }, +}); +const content = '

Outside table

H1H2H3

Alpha

BravoCharlie
DeltaEchoFoxtrot

Between tables

Other

After table

'; +const pause = (ms = 30) => new Promise((resolve) => setTimeout(resolve, ms)); +const assert = (value: unknown, message: string) => { if (!value) throw new Error(message); }; +const until = async (condition: () => unknown, description: string) => { + for (let i = 0; i < 100; i++) { if (condition()) return; await pause(20); } + throw new Error(`Timed out: ${description}`); +}; +const menu = () => document.querySelector('[role="menu"][data-state="open"]'); +const menuItem = (label: string) => [...(menu()?.querySelectorAll('[role="menuitem"]') ?? [])].find((item) => item.textContent?.startsWith(label)); +const cells = (table = 0) => [...editor.view.dom.querySelectorAll("table")][table].querySelectorAll("td,th"); +const cellPos = (index: number, table = 0) => editor.view.posAtDOM(cells(table)[index], 0); +const setCursor = (index: number, table = 0) => { editor.commands.setTextSelection(cellPos(index, table) + 1); editor.view.focus(); }; +const selectText = (index: number) => { + cells()[index].scrollIntoView({ block: "center" }); + const pos = cellPos(index) + 1; + editor.commands.setTextSelection({ from: pos, to: pos + cells()[index].textContent!.length }); + editor.view.focus(); +}; +const stubClipboard = (value: object) => Object.defineProperty(navigator, "clipboard", { configurable: true, value }); +const clipboardItem = (html: string, text = "") => new ClipboardItem({ ...(html ? { "text/html": new Blob([html], { type: "text/html" }) } : {}), ...(text ? { "text/plain": new Blob([text], { type: "text/plain" }) } : {}) }); +const restoreClipboard = () => { + if (originalClipboard) Object.defineProperty(navigator, "clipboard", originalClipboard); + else delete (navigator as { clipboard?: Clipboard }).clipboard; +}; +const restoreDesktop = () => { + if (originalDesktop) Object.defineProperty(window, "edgeeverDesktop", originalDesktop); + else delete window.edgeeverDesktop; +}; +const stubNativeRead = (readClipboard: () => Promise<{ html: string; text: string; images: Uint8Array[] }>) => Object.defineProperty(window, "edgeeverDesktop", { + configurable: true, value: { isAvailable: true, readClipboard }, +}); +const nativePasteEvent = (target: HTMLElement = editor.view.dom) => { + const data = new DataTransfer(); data.setData("text/plain", "Native paste"); + const event = new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: data }); + target.dispatchEvent(event); return event; +}; +const mount = async (readOnly = false) => { + root?.unmount(); + if (editor && !editor.isDestroyed) editor.destroy(); + fixture.replaceChildren(); + restoreDesktop(); + receivedNodeContext = receivedPointer = 0; uploaded = []; writes = []; + editor = new Editor({ extensions: [StarterKit, TableKit, TestResource], content, editable: !readOnly, + editorProps: { handlePaste(_view, event) { if (!event.clipboardData?.files.length) return false; uploaded = [...event.clipboardData.files]; return true; } }, + }); + stubClipboard({ read: async () => [clipboardItem('

Pasted

', 'Pasted')], write: async (items: ClipboardItem[]) => { writes.push(items); } }); + root = createRoot(fixture); + root.render( + + + + + + ); + await until(() => fixture.querySelector(".ProseMirror table"), "fixture mount"); + await pause(); +}; +const contextEvent = (element: HTMLElement, options: { pointerType?: string; legacy?: boolean; shiftKey?: boolean; edge?: boolean } = {}) => { + element.scrollIntoView({ block: "center" }); + const pointerType = options.pointerType ?? "mouse"; + element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true, pointerType })); + const rect = element.getBoundingClientRect(); + const init = { bubbles: true, cancelable: true, button: 2, shiftKey: options.shiftKey, clientX: options.edge ? innerWidth - 2 : rect.left + 18, clientY: options.edge ? innerHeight - 2 : rect.top + rect.height / 2 }; + const event = options.legacy ? new MouseEvent("contextmenu", init) : new PointerEvent("contextmenu", { ...init, pointerType }); + element.dispatchEvent(event); + return event; +}; +const open = async (index: number, table = 0, edge = false) => { + assert(contextEvent(cells(table)[index], { edge }).defaultPrevented, "custom menu must prevent native menu"); + await until(menu, "menu open"); + await pause(); +}; +const close = async () => { + (document.activeElement ?? menu())?.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true })); + await until(() => !menu(), "menu close"); + await pause(50); +}; +const choose = async (label: string) => { + const item = menuItem(label); + assert(item && item.getAttribute("aria-disabled") !== "true", `${label} must be enabled: selection ${editor.state.selection.from}..${editor.state.selection.to}; text ${JSON.stringify(window.getSelection()?.toString())}; menu ${menu()?.textContent}`); + document.getElementById("summary")!.textContent += ` | clicking ${label}`; + item!.click(); + document.getElementById("summary")!.textContent += " | waiting for close"; + await until(() => !menu(), "menu action close"); + await until(() => !document.querySelector('[role="menu"]'), "menu focus scope teardown"); + await pause(30); +}; +const check = (name: string, run: () => Promise) => ({ name, run }); +const checks = [ + check("Right click targets the clicked table and row, not the old cursor", async () => { + setCursor(0, 1); await open(4); await choose("Delete current row"); + assert(!editor.getText().includes("Alpha") && editor.getText().includes("Delta") && editor.getText().includes("Other"), "wrong row or table deleted"); + editor.commands.undo(); assert(editor.getText().includes("Alpha"), "undo did not restore row"); + }), + check("Column deletion targets clicked column and is undoable", async () => { + setCursor(3); await open(5); await choose("Delete current column"); + assert(!editor.getText().includes("Charlie") && !editor.getText().includes("Foxtrot") && editor.getText().includes("Alpha"), "wrong column deleted"); + editor.commands.undo(); assert(editor.getText().includes("Charlie"), "undo failed"); + }), + check("Add row and column commands share toolbar behavior", async () => { + setCursor(3); await open(4); await choose("Add row below"); + assert(editor.view.dom.querySelector("table")!.rows.length === 4, "row not inserted"); + editor.commands.undo(); await open(4); await choose("Add column right"); + assert(editor.view.dom.querySelector("table")!.rows[0].cells.length === 4, "column not inserted"); + }), + check("Header row deletion remains disabled", async () => { + setCursor(6); await open(0); + assert(menuItem("Delete current row")?.getAttribute("aria-disabled") === "true", "header protection lost"); await close(); + }), + check("Selections spanning header and body rows keep the header protection", async () => { + editor.view.dispatch(editor.state.tr.setSelection(CellSelection.create(editor.state.doc, cellPos(3) - 1, cellPos(4) - 1))); + await open(4); assert(menuItem("Delete current row")?.getAttribute("aria-disabled") !== "true", "body rows were disabled"); + editor.view.dispatch(editor.state.tr.setSelection(CellSelection.create(editor.state.doc, cellPos(0) - 1, cellPos(4) - 1))); + await until(() => menuItem("Delete current row")?.getAttribute("aria-disabled") === "true", "header protection after selection change"); + await close(); + }), + check("Header toggle and table deletion are reversible", async () => { + setCursor(3); await open(3); await choose("Toggle header row"); + assert(!editor.view.dom.querySelector("table th"), "header not toggled"); editor.commands.undo(); + const before = JSON.stringify(editor.getJSON()); await open(3); await choose("Delete table"); + assert(editor.view.dom.querySelectorAll("table").length === 1 && editor.getText().includes("Other"), "wrong table deleted"); + editor.commands.undo(); assert(JSON.stringify(editor.getJSON()) === before, "undo changed table contents"); + }), + check("Text selection is preserved and copied with rich and plain formats", async () => { + selectText(3); const selection = editor.state.selection; await open(3); + assert(editor.state.selection.eq(selection), "selection was collapsed"); await choose("Copy"); + await until(() => writes.length, "clipboard write"); + assert((await (await writes[0][0].getType("text/html")).text()).includes("Alpha"), "formatting lost"); + assert(await (await writes[0][0].getType("text/plain")).text() === "Alpha", "wrong copied text"); + }), + check("Copy is disabled without a selection", async () => { + setCursor(3); await open(3); assert(menuItem("Copy")?.getAttribute("aria-disabled") === "true", "copy must require selection"); await close(); + }), + check("A multi-cell selection is preserved and serialized as a table", async () => { + editor.view.dispatch(editor.state.tr.setSelection(CellSelection.create(editor.state.doc, cellPos(3) - 1, cellPos(4) - 1))); editor.view.focus(); + const before = editor.state.selection; await open(4); assert(editor.state.selection.eq(before), "cell selection lost"); await choose("Copy"); + await until(() => writes.length, "cell clipboard write"); const html = await (await writes[0][0].getType("text/html")).text(); + assert(html.includes(" { + selectText(3); await open(3); await choose("Paste"); + assert(cells()[3].innerHTML.includes("Pasted") && !cells()[3].textContent!.includes("Alpha"), "rich paste failed"); + editor.commands.undo(); assert(cells()[3].textContent === "Alpha", "paste undo failed"); + }), + check("Plain text paste treats HTML-looking text literally", async () => { + stubClipboard({ readText: async () => '' }); selectText(4); await open(4); await choose("Paste"); + assert(cells()[4].textContent === '' && !cells()[4].querySelector("script"), "text was parsed as HTML"); + }), + check("Image paste uses the existing upload handler", async () => { + stubClipboard({ read: async () => [new ClipboardItem({ 'image/png': new Blob(['fixture'], { type: 'image/png' }) })] }); + setCursor(3); await open(3); await choose("Paste"); await until(() => uploaded.length, "file upload"); assert(uploaded[0].type === 'image/png', "wrong upload type"); + }), + check("Native desktop clipboard snapshot waits for menu teardown before insertion", async () => { + let called = false; + stubClipboard({ read: async () => { throw new Error("Browser clipboard must not be used on desktop"); } }); + stubNativeRead(async () => { + called = true; return { html: '

Native paste

', text: 'Native paste', images: [] }; + }); + selectText(3); await open(3); await choose("Paste"); + await until(() => cells()[3].textContent === "Native paste", "native paste transaction"); + assert(called && cells()[3].innerHTML.includes('Native paste'), "native bridge or formatting was skipped"); editor.commands.undo(); assert(cells()[3].textContent === "Alpha", "native paste undo failed"); + }), + ...['document', 'selection', 'readOnly', 'unmount', 'userPaste'].map((change) => check(`Native clipboard snapshot arriving after ${change} changes is discarded`, async () => { + let resolve!: (data: { html: string; text: string; images: Uint8Array[] }) => void; + stubNativeRead(() => new Promise((done) => { resolve = done; })); + setCursor(3); await open(3); await choose("Paste"); + if (change === 'document') editor.commands.setContent('

Another note

'); + if (change === 'selection') setCursor(4); + if (change === 'readOnly') editor.setEditable(false); + if (change === 'unmount') { root!.unmount(); root = undefined; } + if (change === 'userPaste') nativePasteEvent(); + const before = JSON.stringify(editor.getJSON()); + resolve({ html: '', text: 'Unexpected clipboard snapshot', images: [] }); await pause(80); + assert(JSON.stringify(editor.getJSON()) === before && !editor.getText().includes('Unexpected'), "native clipboard snapshot escaped target guard"); + })), + check("Native clipboard rejection shows a shortcut hint", async () => { + stubNativeRead(async () => { throw new Error('Native read denied'); }); + setCursor(3); const before = JSON.stringify(editor.getJSON()); await open(3); await choose('Paste'); + await until(() => document.querySelector('[role="alert"]')?.textContent?.includes('V'), 'native rejection notice'); + assert(JSON.stringify(editor.getJSON()) === before, 'rejected native paste changed content'); + }), + check("All native PNG clipboard items use the existing upload pipeline", async () => { + stubNativeRead(async () => ({ html: '', text: '', images: [Uint8Array.from([137,80,78,71]), Uint8Array.from([137,80,78,71])] })); + setCursor(3); await open(3); await choose('Paste'); + await until(() => uploaded.length === 2, 'native clipboard upload'); assert(uploaded.every((file) => file.type === 'image/png'), 'native image format lost'); + }), + check("Denied clipboard read gives a visible shortcut hint and leaves document intact", async () => { + stubClipboard({ read: async () => { throw new DOMException('Denied', 'NotAllowedError'); } }); const before = JSON.stringify(editor.getJSON()); + setCursor(3); await open(3); await choose("Paste"); assert(document.querySelector('[role="alert"]')?.textContent?.includes('V'), "no paste failure hint"); + assert(JSON.stringify(editor.getJSON()) === before, "failed paste changed document"); + }), + ...['document', 'selection', 'readOnly', 'unmount'].map((change) => check(`Pending paste is cancelled after ${change} changes`, async () => { + let resolve!: (value: ClipboardItem[]) => void; stubClipboard({ read: () => new Promise((done) => { resolve = done; }) }); + setCursor(3); await open(3); await choose("Paste"); + if (change === 'document') editor.commands.setContent('

Another note

'); + if (change === 'selection') setCursor(4); + if (change === 'readOnly') editor.setEditable(false); + if (change === 'unmount') root!.unmount(); + const before = JSON.stringify(editor.getJSON()); resolve([clipboardItem('

Unexpected

', 'Unexpected')]); await pause(80); + assert(JSON.stringify(editor.getJSON()) === before, "stale paste changed target"); + if (change === 'unmount') root = undefined; + })), + check("A document change while a menu is open cancels a destructive action", async () => { + setCursor(3); await open(3); editor.commands.insertContent('Remote update'); const before = JSON.stringify(editor.getJSON()); + await choose("Delete table"); assert(JSON.stringify(editor.getJSON()) === before, "stale menu deleted table"); + }), + check("Touch and pen events, including legacy long-press contextmenu, remain native", async () => { + for (const pointerType of ['touch', 'pen']) for (const legacy of [false, true]) { + const event = contextEvent(cells()[3], { pointerType, legacy }); await pause(); + assert(!event.defaultPrevented && !menu(), `${pointerType} context was overridden`); + } + assert((getComputedStyle(editor.view.dom.parentElement!) as CSSStyleDeclaration & { webkitTouchCallout: string }).webkitTouchCallout !== 'none', "touch callout style leaked into editor"); + }), + check("Hybrid devices can use mouse context menus after a touch interaction", async () => { + contextEvent(cells()[3], { pointerType: 'touch' }); await open(4); await close(); + }), + check("Shift-right-click and right-click outside tables leave the native menu available", async () => { + assert(!contextEvent(cells()[3], { shiftKey: true }).defaultPrevented, "shift fallback blocked"); + assert(!contextEvent(editor.view.dom.querySelector('p')!).defaultPrevented, "outside context blocked"); assert(!menu(), "outside context opened menu"); + }), + check("Nested node-view context and pointer handlers receive their events", async () => { + const node = fixture.querySelector('#node-view')!; contextEvent(node, { pointerType: 'touch' }); + assert(receivedPointer === 1 && receivedNodeContext === 1 && !menu(), "nested event was swallowed"); + }), + check("A node-view context menu inside a table keeps its own target and handlers", async () => { + setCursor(3); const before = editor.state.selection; + const node = fixture.querySelector('.ProseMirror table [data-test-resource]')!; + contextEvent(node); + assert(receivedNodeContext === 1 && receivedPointer === 1 && !menu(), 'table menu swallowed node-view context'); + assert(editor.state.selection.eq(before), 'table context changed resource target'); + }), + check("Rich clipboard failure falls back to plain text", async () => { + let copied = ''; stubClipboard({ write: async () => { throw new Error('Rich clipboard denied'); }, writeText: async (value: string) => { copied = value; } }); + selectText(3); await open(3); await choose('Copy'); await until(() => copied === 'Alpha', 'plain-text copy fallback'); + }), + check("Copy failure shows an error and keeps the document intact", async () => { + const execCommand = document.execCommand; + document.execCommand = () => false; + try { + stubClipboard({ write: async () => { throw new Error('Denied'); }, writeText: async () => { throw new Error('Denied'); } }); + selectText(3); const before = JSON.stringify(editor.getJSON()); await open(3); await choose('Copy'); + await until(() => document.querySelector('[role="alert"]'), 'copy error notice'); + assert(JSON.stringify(editor.getJSON()) === before, 'failed copy changed document'); + } finally { document.execCommand = execCommand; } + }), + check("Paste passes through the editor schema without introducing script or event handlers", async () => { + stubClipboard({ read: async () => [clipboardItem('

Safe

')] }); + selectText(3); await open(3); await choose('Paste'); + assert(cells()[3].textContent === 'Safe' && !cells()[3].querySelector('script,[onclick]'), `unsafe HTML entered document: ${cells()[3].innerHTML}`); + }), + check("Keyboard menu preserves selected text after touch, and Escape restores focus", async () => { + contextEvent(cells()[3], { pointerType: 'touch' }); selectText(3); const selection = editor.state.selection; + editor.view.dom.dispatchEvent(new KeyboardEvent('keydown', { key: 'F10', shiftKey: true, bubbles: true, cancelable: true })); + await until(menu, 'keyboard menu'); assert(editor.state.selection.eq(selection) && menuItem('Copy')?.getAttribute('aria-disabled') !== 'true', 'keyboard selection lost'); + await pause(); await close(); await until(() => document.activeElement === editor.view.dom, 'focus restored after menu animation'); + }), + check("Keyboard context outside the editor cannot act on a stale table selection", async () => { + setCursor(3); + const button = fixture.querySelector('#node-view')!; + const event = new KeyboardEvent('keydown', { key: 'F10', shiftKey: true, bubbles: true, cancelable: true }); + button.dispatchEvent(event); await pause(); + assert(!event.defaultPrevented && !menu(), 'wrapper button opened the table menu'); + }), + check("Table commands and paste restore focus after the modal focus scope exits", async () => { + let trappedFocus = false; + const focus = editor.view.dom.focus.bind(editor.view.dom); + editor.view.dom.focus = (options) => { + if (document.querySelector('[role="menu"]')) { trappedFocus = true; return; } + focus(options); + }; + setCursor(3); await open(4); await choose('Add row below'); + await until(() => editor.view.hasFocus(), 'focus restored after table action'); + editor.commands.undo(); selectText(3); await open(3); await choose('Paste'); + await until(() => cells()[3].textContent === 'Pasted' && editor.view.hasFocus(), 'focus restored after paste'); + assert(!trappedFocus, 'editor was focused while the modal menu still existed'); + }), + check("Menu is kept within the viewport near its lower right edge", async () => { + setCursor(3); await open(3, 0, true); const rect = menu()!.getBoundingClientRect(); + assert(rect.left >= 0 && rect.top >= 0 && rect.right <= innerWidth + 1 && rect.bottom <= innerHeight + 1, `menu outside viewport: ${JSON.stringify(rect)}`); await close(); + }), + check("Read-only editor leaves context menu native", async () => { + await mount(true); assert(!contextEvent(cells()[3]).defaultPrevented && !menu(), 'read-only context overridden'); + }), +]; + +// AX text offsets differ between browsers for tables. These manual controls set +// an exact PM selection, while Copy/Paste still use the real browser clipboard. +const manualControls = document.createElement('div'); manualControls.hidden = true; +for (const [label, table, index] of [['Select Alpha and open menu', 0, 3], ['Select Other and open menu', 1, 0]] as const) { + const button = document.createElement('button'); button.textContent = label; + button.style.cssText = 'border:1px solid;padding:8px'; + button.addEventListener('click', () => { + const cell = cells(table)[index]; cell.scrollIntoView({ block: 'center' }); + const pos = cellPos(index, table) + 1; + editor.commands.setTextSelection({ from: pos, to: pos + cell.textContent!.length }); editor.view.focus(); + editor.view.dom.dispatchEvent(new KeyboardEvent('keydown', { key: 'F10', shiftKey: true, bubbles: true, cancelable: true })); + }); + manualControls.appendChild(button); +} +document.getElementById('summary')!.parentNode!.insertBefore(manualControls, document.getElementById('summary')); + +document.getElementById('run')!.addEventListener('click', async () => { + manualControls.hidden = true; + const button = document.getElementById('run') as HTMLButtonElement; button.disabled = true; + const results = document.getElementById('results')!; results.replaceChildren(); let passed = 0; + for (const test of checks) { + document.getElementById('summary')!.textContent = `Running ${test.name}`; + const result = document.createElement('li'); + try { await mount(); await test.run(); passed++; result.textContent = `PASS: ${test.name}`; } + catch (error) { result.textContent = `FAIL: ${test.name}: ${error instanceof Error ? error.message : String(error)}`; result.style.color = 'red'; } + results.appendChild(result); + } + root?.unmount(); root = undefined; if (editor && !editor.isDestroyed) editor.destroy(); restoreClipboard(); restoreDesktop(); + document.getElementById('summary')!.textContent = `${passed}/${checks.length} passed`; button.disabled = false; +}); + +document.getElementById('demo')!.addEventListener('click', async () => { + await mount(); restoreClipboard(); + document.getElementById('results')!.replaceChildren(); manualControls.hidden = false; + const browserClipboard = navigator.clipboard; + stubClipboard({ + read: () => browserClipboard.read(), readText: () => browserClipboard.readText(), + writeText: (text: string) => browserClipboard.writeText(text), + write: async (items: ClipboardItem[]) => { + await browserClipboard.write(items); + try { + const expected = await (await items[0].getType('text/plain')).text(); + const actual = await browserClipboard.readText(); + document.getElementById('summary')!.textContent = expected === actual + ? `Real clipboard verified: ${JSON.stringify(actual)}` + : `Clipboard mismatch: expected ${JSON.stringify(expected)}, read ${JSON.stringify(actual)}`; + } catch { + document.getElementById('summary')!.textContent = 'Clipboard write completed; read-back permission was not available.'; + } + }, + }); + document.getElementById('summary')!.textContent = 'Manual fixture ready: use real mouse and keyboard input. Clipboard access uses the browser normally.'; +}); diff --git a/tests/browser/editor-table-context.html b/tests/browser/editor-table-context.html new file mode 100644 index 00000000..9edf34ef --- /dev/null +++ b/tests/browser/editor-table-context.html @@ -0,0 +1,9 @@ + +Table context menu regression checks +

Table context menu regression checks

Local fixture only. Does not read or change notes. Clipboard access is stubbed during these checks.

Ready

    diff --git a/tests/browser/tsconfig.json b/tests/browser/tsconfig.json new file mode 100644 index 00000000..4a654e1c --- /dev/null +++ b/tests/browser/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["./*.browser.tsx", "../../apps/web/src/vite-env.d.ts"] +}