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
H1 H2 H3 Alpha
Bravo Charlie Delta Echo Foxtrot
Between tables
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(
+
+
+
+ receivedPointer++} onContextMenu={(event) => { receivedNodeContext++; event.preventDefault(); }}>Nested node context
+
+ );
+ 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.
Run regression checks Open manual fixture 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"]
+}