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:
1 parent
1d6cc281e2
commit
367cb8368d
17 files changed
+1310
-45
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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";
|
||||
Vendored
+1
@@ -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.';
|
||||
});
|
||||
@@ -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>
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["./*.browser.tsx", "../../apps/web/src/vite-env.d.ts"]
|
||||
}
|
||||
Reference in new issue
Block a user