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
This commit is contained in:
Thorsten Ruf authored and GitHub committed 2026-10-16 11:24:46 +08:00
1 parent 1d6cc281e2
commit 367cb8368d
17 files changed
+1310 -45

No files matched your search

+62
View File
@@ -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;
});
};
@@ -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": "<p><strong>Alpha</strong></p>", "text/plain": new Error("Unreadable"), "image/png": new Error("Unreadable") }))
.toEqual({ html: "<p><strong>Alpha</strong></p>", text: "", images: [] });
});
test("falls back from unreadable HTML to literal plain text", async () => {
expect(await snapshot({ "text/html": new Error("Unreadable"), "text/plain": "<literal>\ntext", "image/png": new Error("Unreadable") }))
.toEqual({ html: "", text: "<literal>\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: "<p>attacker</p>" }, 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");
});
});
+11 -3
View File
@@ -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);
+10
View File
@@ -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),
+6 -3
View File
@@ -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")}
/>
</BubbleMenu>
<AttachmentTranscriptContext.Provider value={attachmentTranscriptContext}>
<EditorContent editor={editor} />
</AttachmentTranscriptContext.Provider>
<EditorTableContextMenu editor={editor} readOnly={effectiveReadOnly}>
<AttachmentTranscriptContext.Provider value={attachmentTranscriptContext}>
<EditorContent editor={editor} />
</AttachmentTranscriptContext.Provider>
</EditorTableContextMenu>
</div>
)}
</div>
@@ -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<void>; release: () => void } | null>(null);
const currentReadOnly = useRef(readOnly);
currentReadOnly.current = readOnly;
const pending = useRef(false);
const interactedOutside = useRef(false);
const trigger = useRef<HTMLSpanElement>(null);
const pointerType = useRef<string | null>(null);
const target = useRef<Editor["state"] | null>(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<void>((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<HTMLDivElement>) => {
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<HTMLElement>("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<HTMLDivElement>) => {
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<HTMLElement>("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<typeof getEditorTableActions>[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 (
<ContextMenu open={menuOpen} onOpenChange={changeMenuOpen}>
<div
data-table-context-open={menuOpen}
onContextMenu={prepare}
onKeyDown={openFromKeyboard}
onPointerDownCapture={(event) => { pointerType.current = event.pointerType; }}
>
{children}
<ContextMenuTrigger asChild disabled={readOnly || !editor || editor.isDestroyed}>
<span ref={trigger} aria-hidden="true" />
</ContextMenuTrigger>
</div>
<ContextMenuContent
onInteractOutside={() => { 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);
}}
>
<ContextMenuItem disabled={!canCopy || busy} onSelect={() => { void copy(); changeMenuOpen(false); }}>
{t("aiAssistant.sidebar.selection.copy")}<ContextMenuShortcut>{modifier}C</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem disabled={readOnly || !editor?.isEditable || busy} onSelect={() => { void paste(); changeMenuOpen(false); }}>
{t("editor.paste")}<ContextMenuShortcut>{modifier}V</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuSeparator />
{getEditorTableActions(editor, readOnly).map((action) => (
<Fragment key={action.key}>
{action.destructive ? <ContextMenuSeparator /> : null}
<ContextMenuItem disabled={action.disabled || busy} className={action.destructive ? "text-rose-700 focus:text-rose-800" : undefined} onSelect={() => { void runTableAction(action); changeMenuOpen(false); }}>
{t(action.key)}
</ContextMenuItem>
</Fragment>
))}
</ContextMenuContent>
{notice ? <ClipboardCopyNotice status={notice.status}>{notice.message}</ClipboardCopyNotice> : null}
</ContextMenu>
);
};
+16 -39
View File
@@ -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) => {
<TooltipContent side="bottom">{t("editorToolbar.table")}</TooltipContent>
</Tooltip>
<DropdownMenuContent align="start">
<DropdownMenuItem
disabled={inTable}
onSelect={() => run((activeEditor) => activeEditor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run())}
>
<DropdownMenuItem disabled={inTable} onSelect={() => run((activeEditor) => activeEditor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run())}>
{t("editorToolbar.insertTable")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={!inTable} onSelect={() => run((activeEditor) => activeEditor.chain().focus().addRowAfter().run())}>
{t("editorToolbar.addTableRow")}
</DropdownMenuItem>
<DropdownMenuItem
disabled={!inTable || inTableHeader}
onSelect={() => run((activeEditor) => {
if (!activeEditor.isActive("tableHeader")) {
activeEditor.chain().focus().deleteRow().run();
}
})}
>
{t("editorToolbar.deleteTableRow")}
</DropdownMenuItem>
<DropdownMenuItem disabled={!inTable} onSelect={() => run((activeEditor) => activeEditor.chain().focus().addColumnAfter().run())}>
{t("editorToolbar.addTableColumn")}
</DropdownMenuItem>
<DropdownMenuItem disabled={!inTable} onSelect={() => run((activeEditor) => activeEditor.chain().focus().deleteColumn().run())}>
{t("editorToolbar.deleteTableColumn")}
</DropdownMenuItem>
<DropdownMenuItem disabled={!inTable} onSelect={() => run((activeEditor) => activeEditor.chain().focus().toggleHeaderRow().run())}>
{t("editorToolbar.toggleTableHeader")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-rose-700 focus:text-rose-800"
disabled={!inTable}
onSelect={() => run((activeEditor) => activeEditor.chain().focus().deleteTable().run())}
>
{t("editorToolbar.deleteTable")}
</DropdownMenuItem>
{getEditorTableActions(editor, readOnly).map((action) => (
<Fragment key={action.key}>
{action.destructive ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem disabled={action.disabled} className={action.destructive ? "text-rose-700 focus:text-rose-800" : undefined} onSelect={() => action.run()}>
{t(action.key)}
</DropdownMenuItem>
</Fragment>
))}
</DropdownMenuContent>
</DropdownMenu>
);
@@ -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();
}
});
+41
View File
@@ -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();
},
}));
};
@@ -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': '<p><strong>Alpha</strong></p>',
'text/plain': new Error('Plain text not readable'),
'image/png': new Error('Image not readable'),
})] };
expect(await readTableClipboard(clipboard)).toEqual({ html: '<p><strong>Alpha</strong></p>', 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': '<literal>\ntext' })] }))
.toEqual({ html: '', text: '<literal>\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 () => '<script>literal</script>' }))
.toEqual({ html: '', text: '<script>literal</script>', 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');
});
@@ -0,0 +1,37 @@
type ClipboardReader = Partial<Pick<Clipboard, "read" | "readText">>;
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<TableClipboardContent> => {
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 };
};
@@ -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);
});
+39
View File
@@ -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";
+1
View File
@@ -38,6 +38,7 @@ interface EdgeEverDesktopBridge {
copyText(value: string): Promise<boolean>;
copyHtml(html: string, plainText: string): Promise<boolean>;
copyImage(bytes: Uint8Array): Promise<boolean>;
readClipboard?(): Promise<{ html: string; text: string; images: Uint8Array[] }>;
setSessionToken(value: string): Promise<{ stored: boolean }>;
clearSessionToken(): Promise<{ stored: false }>;
testPicGoConnection(endpoint: string): Promise<boolean>;
@@ -0,0 +1,385 @@
// Open through Vite's /@fs/<checkout>/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 = '<p>Outside table</p><table><tr><th>H1</th><th>H2</th><th>H3</th></tr><tr><td><p><strong>Alpha</strong></p></td><td>Bravo</td><td>Charlie</td></tr><tr><td>Delta</td><td>Echo</td><td>Foxtrot<span data-test-resource></span></td></tr></table><p>Between tables</p><table><tr><td>Other</td></tr></table><p>After table</p>';
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<HTMLElement>('[role="menu"][data-state="open"]');
const menuItem = (label: string) => [...(menu()?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? [])].find((item) => item.textContent?.startsWith(label));
const cells = (table = 0) => [...editor.view.dom.querySelectorAll("table")][table].querySelectorAll<HTMLElement>("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('<p><em>Pasted</em></p>', 'Pasted')], write: async (items: ClipboardItem[]) => { writes.push(items); } });
root = createRoot(fixture);
root.render(<I18nextProvider i18n={i18n}><TooltipProvider>
<EditorTableMenu editor={editor} readOnly={readOnly} />
<EditorTableContextMenu editor={editor} readOnly={readOnly}>
<EditorContent editor={editor} />
<button id="node-view" onPointerDown={() => receivedPointer++} onContextMenu={(event) => { receivedNodeContext++; event.preventDefault(); }}>Nested node context</button>
</EditorTableContextMenu>
</TooltipProvider></I18nextProvider>);
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<void>) => ({ 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("<strong>Alpha</strong>"), "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("<table") && html.includes("Alpha") && html.includes("Bravo") && !html.includes("Delta"), "wrong copied cells");
}),
check("Rich HTML paste replaces selected text, keeps formatting and supports undo", async () => {
selectText(3); await open(3); await choose("Paste");
assert(cells()[3].innerHTML.includes("<em>Pasted</em>") && !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 () => '<script>literal</script>' }); selectText(4); await open(4); await choose("Paste");
assert(cells()[4].textContent === '<script>literal</script>' && !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: '<p><em>Native paste</em></p>', 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('<em>Native paste</em>'), "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('<p>Another note</p>');
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('<p>Another note</p>');
if (change === 'selection') setCursor(4);
if (change === 'readOnly') editor.setEditable(false);
if (change === 'unmount') root!.unmount();
const before = JSON.stringify(editor.getJSON()); resolve([clipboardItem('<p>Unexpected</p>', '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<HTMLElement>('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<HTMLElement>('#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<HTMLElement>('.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('<p onclick="throw 1">Safe<script>throw 2</script></p>')] });
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<HTMLElement>('#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.';
});
+9
View File
@@ -0,0 +1,9 @@
<!doctype html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Table context menu regression checks</title></head>
<body><main style="padding:24px"><h1>Table context menu regression checks</h1><p>Local fixture only. Does not read or change notes. Clipboard access is stubbed during these checks.</p><button id="run" style="border:1px solid;padding:8px">Run regression checks</button><button id="demo" style="border:1px solid;padding:8px">Open manual fixture</button><p id="summary" role="status">Ready</p><ol id="results"></ol><div id="fixture"></div></main><script type="module">
import RefreshRuntime from "/@react-refresh";
RefreshRuntime.injectIntoGlobalHook(window);
window.$RefreshReg$ = () => {};
window.$RefreshSig$ = () => (type) => type;
window.__vite_plugin_react_preamble_installed__ = true;
</script><script type="module" src="./editor-table-context.browser.tsx"></script></body></html>
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["./*.browser.tsx", "../../apps/web/src/vite-env.d.ts"]
}