Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
0bfe2c9
PT-4201: Standard-view copy/paste USFM fidelity (squashed for rebase)
tjcouch-sil Aug 26, 2026
2c6c006
fix: a copy with nothing selected must leave the clipboard untouched
tjcouch-sil Aug 27, 2026
b9703ac
refactor: close the rebase-review findings on the paste payload and m…
tjcouch-sil Aug 27, 2026
0fc05e4
docs: reconcile the clipboard semantics with the per-flavor NBSP inve…
tjcouch-sil Aug 27, 2026
7a60841
fix: guard the empty copy on the command, not in front of the dispatch
tjcouch-sil Aug 27, 2026
432de8c
test(platform): complete the table-optbreak context literal for the w…
tjcouch-sil Sep 10, 2026
a601533
fix: keep a pasted figure's caption inside the figure
tjcouch-sil Sep 10, 2026
690e554
fix: keep every child-bearing opaque construct in the lexical-flavor …
tjcouch-sil Sep 10, 2026
d591a6e
fix: copy an opaque construct on one line so a paste can reassemble it
tjcouch-sil Sep 10, 2026
6c2fa60
fix: carry a paste's provenance across a deferred Tier-2 settle
tjcouch-sil Sep 10, 2026
f6e7c57
fix: make the copy carriers agree at a construct's own start boundary
tjcouch-sil Sep 10, 2026
ef949ca
fix: keep a touched construct whole in the copy instead of hoisting i…
tjcouch-sil Sep 10, 2026
87b26d6
fix: reassemble a copied \periph division on paste
tjcouch-sil Sep 10, 2026
72a3925
fix: give a mixed attribute-context paste body content's paste rules
tjcouch-sil Sep 10, 2026
35fd56a
fix: sweep the real-world 2SA fixture, closing two copy-side attribut…
tjcouch-sil Sep 10, 2026
8c4f46e
fix: correct the attribute-value carve-out's rationale and close the …
tjcouch-sil Sep 10, 2026
4fe842d
style: keep the clipboard comments forward-facing
tjcouch-sil Sep 10, 2026
ae47dd2
fix(clipboard): close two review gaps in the copy/paste path
tjcouch-sil Sep 15, 2026
2d379e4
fix(clipboard): close two data-fidelity defects and the review's conv…
tjcouch-sil Sep 15, 2026
f4aac52
chore: rebuild the committed platform dist and note the copy/cut change
tjcouch-sil Sep 15, 2026
ddea154
fix(selection): snap a selection landing inside a decorator to its bo…
tjcouch-sil Sep 17, 2026
a1dede3
fix(clipboard): carry USFM in text/html, decode Paratext 9's usfm com…
tjcouch-sil Sep 17, 2026
44d69db
chore: rebuild the committed platform dist
tjcouch-sil Sep 17, 2026
40d0648
fix(selection): leave the browser's drag base alone while snapping in…
tjcouch-sil Sep 17, 2026
e91c930
chore: rebuild the committed platform dist
tjcouch-sil Sep 17, 2026
edddbec
fix(clipboard): omit the internal flavor for a selection that cuts th…
tjcouch-sil Sep 17, 2026
aec3889
chore: rebuild the committed platform dist
tjcouch-sil Sep 17, 2026
24b030d
fix(clipboard): close five paste/copy gaps found in review
tjcouch-sil Sep 17, 2026
a0efe7d
fix(usfm): keep the peripheral division when its attribute list is re…
tjcouch-sil Sep 17, 2026
1b2163a
fix(paste): paste the pasted bytes — no paragraph dedup, no invented …
tjcouch-sil Sep 18, 2026
543e9c1
chore(marker-edit): drop leftovers of the removed paste dedup
tjcouch-sil Sep 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
264 changes: 264 additions & 0 deletions docs/clipboard-semantics.md

Large diffs are not rendered by default.

188 changes: 188 additions & 0 deletions libs/shared-react/src/plugins/usj/ClipboardPlugin.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { ClipboardPlugin } from "./ClipboardPlugin";
import { copySelection } from "./clipboard.utils";
import { baseTestEnvironment } from "./react-test.utils";
import { act } from "@testing-library/react";
import {
$createNodeSelection,
$createTextNode,
$getRoot,
$setSelection,
LexicalEditor,
TextNode,
} from "lexical";
import { $createImmutableTypedTextNode, $createParaNode } from "shared";

/**
* `document.execCommand("copy")` is how a clipboard write reaches the browser when there is no real
* clipboard event to fill in: `@lexical/clipboard` points the DOM selection at a hidden placeholder
* element it appends to the editor and runs it to provoke one. jsdom implements no `execCommand` at
* all, so these tests install a spy in its place — **called means a write reached the browser, not
* called means the clipboard is untouched**. That is the observable throughout; the placeholder's
* own content belongs to `@lexical/clipboard` and is never asserted on here.
*/
let execCommand: ReturnType<typeof vi.fn>;

beforeEach(() => {
execCommand = vi.fn(() => true);
Object.defineProperty(document, "execCommand", {
configurable: true,
writable: true,
value: execCommand,
});
// `pasteSelection`/`pasteSelectionAsPlainText` read the async clipboard API, which jsdom also
// does not implement. A read that never settles is enough for these tests: they assert only that
// the paste keys still reach it, not what a paste does with what it finds.
vi.stubGlobal("navigator", {
...navigator,
clipboard: { read: vi.fn(() => new Promise(() => undefined)) },
});
});

afterEach(async () => {
// `@lexical/clipboard` keeps a MODULE-level timer handle while it waits for the clipboard event
// its `execCommand` call should provoke, and refuses to start another copy until that handle
// clears. A test that reaches the real copy path therefore silences the `execCommand` assertion
// in the NEXT test unless the window is drained here — which would make a failure show up only in
// whichever test happens to run first. The window is `EVENT_LATENCY`, 50ms.
await new Promise((resolve) => setTimeout(resolve, 60));
Reflect.deleteProperty(document, "execCommand");
vi.unstubAllGlobals();
});

/** An editor holding one paragraph of text, with the clipboard key handling under test mounted. */
async function clipboardEnvironment(): Promise<{ editor: LexicalEditor; text: TextNode }> {
let text: TextNode | undefined;
const { editor } = await baseTestEnvironment(
() => {
text = $createTextNode("In the beginning");
$getRoot().append($createParaNode("p").append(text));
},
<ClipboardPlugin />,
);
if (!text) throw new Error("expected the initial text node to exist");
return { editor, text };
}

/** Presses a clipboard shortcut on the editor's root element, where the plugin listens. */
async function pressShortcut(
editor: LexicalEditor,
key: string,
shiftKey = false,
): Promise<KeyboardEvent> {
const rootElement = editor.getRootElement();
if (!rootElement) throw new Error("editor has no root element to press a key on");
const event = new KeyboardEvent("keydown", {
key,
ctrlKey: true,
shiftKey,
bubbles: true,
cancelable: true,
});
await act(async () => {
rootElement.dispatchEvent(event);
});
return event;
}

describe("ClipboardPlugin — copy/cut with nothing selected", () => {
it("leaves the clipboard untouched on Ctrl+C at a collapsed caret", async () => {
const { editor, text } = await clipboardEnvironment();
await act(async () => editor.update(() => text.select(3, 3)));

await pressShortcut(editor, "c");

expect(execCommand).not.toHaveBeenCalled();
});

it("leaves the clipboard untouched on Ctrl+X at a collapsed caret, and removes nothing", async () => {
const { editor, text } = await clipboardEnvironment();
await act(async () => editor.update(() => text.select(3, 3)));

await pressShortcut(editor, "x");

expect(execCommand).not.toHaveBeenCalled();
editor.getEditorState().read(() => expect(text.getTextContent()).toBe("In the beginning"));
});
});

describe("ClipboardPlugin — copy/cut with a selection", () => {
it("copies on Ctrl+C", async () => {
const { editor, text } = await clipboardEnvironment();
await act(async () => editor.update(() => text.select(0, text.getTextContentSize())));

await pressShortcut(editor, "c");

expect(execCommand).toHaveBeenCalledWith("copy");
});

it("cuts on Ctrl+X", async () => {
const { editor, text } = await clipboardEnvironment();
await act(async () => editor.update(() => text.select(0, text.getTextContentSize())));

await pressShortcut(editor, "x");

expect(execCommand).toHaveBeenCalledWith("copy");
});

it("copies a node selection — the guard is about having nothing to copy, not about ranges", async () => {
// A node selection covers real content and is not collapsed, so it copies like any other. The
// guard tests "is there anything here", NOT "is this a range": narrowing it to range selections
// would silently swallow this copy.
const { editor } = await clipboardEnvironment();
await act(async () =>
editor.update(() => {
const decorator = $createImmutableTypedTextNode("marker", "\\p");
$getRoot().getFirstChild()?.insertBefore?.($createParaNode("p").append(decorator));
const nodeSelection = $createNodeSelection();
nodeSelection.add(decorator.getKey());
$setSelection(nodeSelection);
}),
);

await pressShortcut(editor, "c");

expect(execCommand).toHaveBeenCalledWith("copy");
});
});

describe("ClipboardPlugin — the guard reads the live selection, not the committed one", () => {
// Lexical commits on a microtask, so the last COMMITTED selection lags a selection made earlier
// in the same synchronous tick. A guard that read the committed state would see "nothing
// selected" here and silently copy nothing — and this is the ordinary shape of the public
// `EditorRef.copy()` path: select something programmatically, then copy it.
it("copies a selection made earlier in the same synchronous tick", async () => {
const { editor, text } = await clipboardEnvironment();

await act(async () => {
editor.update(() => text.select(0, text.getTextContentSize()));
copySelection(editor);
});

expect(execCommand).toHaveBeenCalledWith("copy");
});

it("copies a selection made inside the same editor.update() as the copy call", async () => {
const { editor, text } = await clipboardEnvironment();

await act(async () =>
editor.update(() => {
text.select(0, text.getTextContentSize());
copySelection(editor);
}),
);

expect(execCommand).toHaveBeenCalledWith("copy");
});
});

describe("ClipboardPlugin — paste keys are unaffected", () => {
it("claims Ctrl+V and Ctrl+Shift+V regardless of the selection", async () => {
const { editor, text } = await clipboardEnvironment();
await act(async () => editor.update(() => text.select(3, 3)));

// A collapsed caret is exactly where a paste belongs, so the empty-selection rule copy and cut
// now follow must not reach these.
expect((await pressShortcut(editor, "v")).defaultPrevented).toBe(true);
expect((await pressShortcut(editor, "v", true)).defaultPrevented).toBe(true);
});
});
38 changes: 24 additions & 14 deletions libs/shared-react/src/plugins/usj/ClipboardPlugin.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { pasteSelection, pasteSelectionAsPlainText } from "./clipboard.utils";
import {
copySelection,
cutSelection,
pasteSelection,
pasteSelectionAsPlainText,
registerEmptyCopyGuard,
} from "./clipboard.utils";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { IS_APPLE } from "@lexical/utils";
import { COPY_COMMAND, CUT_COMMAND } from "lexical";
import { IS_APPLE, mergeRegister } from "@lexical/utils";
import { useEffect } from "react";

export function ClipboardPlugin(): null {
Expand All @@ -14,26 +19,31 @@ export function ClipboardPlugin(): null {

if (!shiftKey && key.toLowerCase() === "c") {
event.preventDefault();
editor.dispatchCommand(COPY_COMMAND, null);
copySelection(editor);
} else if (!shiftKey && key.toLowerCase() === "x") {
event.preventDefault();
editor.dispatchCommand(CUT_COMMAND, null);
cutSelection(editor);
} else if (key.toLowerCase() === "v") {
event.preventDefault();
if (shiftKey) pasteSelectionAsPlainText(editor);
else pasteSelection(editor);
}
};

return editor.registerRootListener(
(rootElement: HTMLElement | null, prevRootElement: HTMLElement | null) => {
if (prevRootElement !== null) {
prevRootElement.removeEventListener("keydown", onKeyDown);
}
if (rootElement !== null) {
rootElement.addEventListener("keydown", onKeyDown);
}
},
return mergeRegister(
// Every copy/cut this plugin's shortcuts synthesize — and every one the context menu or an
// editor ref synthesizes against the same editor — passes through this guard.
registerEmptyCopyGuard(editor),
editor.registerRootListener(
(rootElement: HTMLElement | null, prevRootElement: HTMLElement | null) => {
if (prevRootElement !== null) {
prevRootElement.removeEventListener("keydown", onKeyDown);
}
if (rootElement !== null) {
rootElement.addEventListener("keydown", onKeyDown);
}
},
),
);
}, [editor]);

Expand Down
132 changes: 132 additions & 0 deletions libs/shared-react/src/plugins/usj/ContextMenuPlugin.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { ClipboardPlugin } from "./ClipboardPlugin";
import { ContextMenuPlugin } from "./ContextMenuPlugin";
import { baseTestEnvironment } from "./react-test.utils";
import { act } from "@testing-library/react";
import { $createTextNode, $getRoot, LexicalEditor, TextNode } from "lexical";
import { $createParaNode } from "shared";

/**
* The context menu's Cut/Copy dispatch the same commands the keyboard shortcuts do, so they are
* covered by the same empty-copy guard (`registerEmptyCopyGuard`). This pins that the leg really
* does go through it, rather than dispatching around it — both in the shape the shipped editors
* mount (alongside `ClipboardPlugin`, which registers the guard too) and with `ContextMenuPlugin`
* mounted alone, which a host consuming the plugin on its own is free to do.
*
* Note where `onSelect` runs: inside `editor.update()` (see the plugin's Enter handler). That is
* why the guard has to live on the COMMAND and read the live selection — a check in front of the
* dispatch reading the last committed state would be both stale and, via `editor.read()`, unsafe
* to call from there.
*
* `document.execCommand("copy")` is the observable, as everywhere else in this suite: called means
* a clipboard write reached the browser, not called means the clipboard is untouched.
*/
let execCommand: ReturnType<typeof vi.fn>;

beforeEach(() => {
execCommand = vi.fn(() => true);
Object.defineProperty(document, "execCommand", {
configurable: true,
writable: true,
value: execCommand,
});
});

afterEach(async () => {
// Drain `@lexical/clipboard`'s module-level `EVENT_LATENCY` (50ms) handle, which otherwise makes
// a test that reached the real copy path silence the next test's assertion.
await new Promise((resolve) => setTimeout(resolve, 60));
Reflect.deleteProperty(document, "execCommand");
});

async function contextMenuEnvironment(
withClipboardPlugin = true,
): Promise<{ editor: LexicalEditor; text: TextNode }> {
let text: TextNode | undefined;
const { editor } = await baseTestEnvironment(
() => {
text = $createTextNode("In the beginning");
$getRoot().append($createParaNode("p").append(text));
},
<>
{withClipboardPlugin && <ClipboardPlugin />}
<ContextMenuPlugin />
</>,
);
if (!text) throw new Error("expected the initial text node to exist");
return { editor, text };
}

/**
* Opens the context menu over the editor's content and activates the option at `index` the way a
* keyboard user would (the plugin's own arrow/Enter handling), rather than by reaching for the
* rendered menu's markup. Built-in order: Cut, Copy, Paste, Paste as Plain Text.
*/
async function chooseContextMenuOption(editor: LexicalEditor, index: number): Promise<void> {
const rootElement = editor.getRootElement();
const target = rootElement?.firstElementChild;
if (!target) throw new Error("expected the editor to have rendered content to right-click");
await act(async () => {
target.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true }));
});
for (let step = 0; step <= index; step++) {
await act(async () => {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
});
}
await act(async () => {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
});
}

const COPY_OPTION = 1;
const CUT_OPTION = 0;

describe("ContextMenuPlugin — Cut/Copy go through the empty-copy guard", () => {
it("Copy with a collapsed caret leaves the clipboard untouched", async () => {
const { editor, text } = await contextMenuEnvironment();
await act(async () => editor.update(() => text.select(3, 3)));

await chooseContextMenuOption(editor, COPY_OPTION);

expect(execCommand).not.toHaveBeenCalled();
});

it("Cut with a collapsed caret leaves the clipboard untouched and removes nothing", async () => {
const { editor, text } = await contextMenuEnvironment();
await act(async () => editor.update(() => text.select(3, 3)));

await chooseContextMenuOption(editor, CUT_OPTION);

expect(execCommand).not.toHaveBeenCalled();
editor.getEditorState().read(() => expect(text.getTextContent()).toBe("In the beginning"));
});

it("Copy with a selection copies — the menu leg is really wired to the command", async () => {
const { editor, text } = await contextMenuEnvironment();
await act(async () => editor.update(() => text.select(0, text.getTextContentSize())));

await chooseContextMenuOption(editor, COPY_OPTION);

expect(execCommand).toHaveBeenCalledWith("copy");
});
});

describe("ContextMenuPlugin mounted without ClipboardPlugin", () => {
it("Copy with a collapsed caret still leaves the clipboard untouched — the plugin carries its own guard", async () => {
const { editor, text } = await contextMenuEnvironment(false);
await act(async () => editor.update(() => text.select(3, 3)));

await chooseContextMenuOption(editor, COPY_OPTION);

expect(execCommand).not.toHaveBeenCalled();
});

it("Copy with a selection still copies — the standalone guard does not over-claim", async () => {
const { editor, text } = await contextMenuEnvironment(false);
await act(async () => editor.update(() => text.select(0, text.getTextContentSize())));

await chooseContextMenuOption(editor, COPY_OPTION);

expect(execCommand).toHaveBeenCalledWith("copy");
});
});
Loading