Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 7 additions & 1 deletion docs/clipboard-semantics.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ Already ~true because markers are real text nodes; this plan closes the exceptio

- **An opaque construct copies out on ONE line:** the walk's ordinary rule puts a `\n` at every non-inline element boundary it crosses, which would spread a single construct over several lines — a sidebar's nested `\p`, and every row and cell of a table, are real block-level nodes. Pasting that text replays each `\n` as a paragraph split, and Tier 2 re-tokenizes strictly one paragraph at a time, so the `\esb`/`\esbe` and `\tr`/`\th`/`\tc` assembly the fragment tokenizer already implements never saw the whole construct in one pass. `$startsBlockLine` (`whitespaceDisplay.plugin.utils.ts`) therefore suppresses the line break for a block boundary with an opaque ancestor ABOVE it — the construct's own outermost node still starts its own line, so a table stays separated from the prose around it. USFM markers self-delimit, so the one-line form is valid USFM of the same document; it is simply not the line-per-marker layout a USFM writer emits, which is the deliberate trade for a paste that survives. Pinned by the corpus sweep's now-unskipped `"sidebar (esb)"` and `"table with header and cells"` fixtures and by `unknownClipboardFidelity.test.tsx`. A `\periph` peripheral division needs the rule just as much, for the same reason and with nothing periph-specific about it: the division marker carries no closing bytes at all, so what ends its attribute run and what opens its content are the SAME thing — the next marker — and both spellings tokenize identically (pinned in `usfmFragmentToUsj.test.ts`). It is the paste's line splitting, not the byte layout, that decides whether one re-tokenization pass ever sees the division together with the blocks inside it; measured both ways and pinned in `unknownClipboardFidelity.test.tsx`. Tables needed one more thing on the paste side: `usfm.sty` classifies `\th1`/`\tc1`/… as CHARACTER styles, so with a real project stylesheet a cell marker reached the fragment tokenizer as a char token and its table-cell assembly — which only ran for paragraph tokens — never saw it, turning every cell into a loose `char` span inside an empty row. ParatextData derives a cell from the marker NAME, so the assembly now recognizes a cell marker on either token kind, guarded on an open `\tr` row. Pinned sheet-backed (`createMarkerLookup(defaultStyleInfo)`, where the cell markers really are Character) in `usfmFragmentToUsj.test.ts`'s `"cell markers under a real stylesheet"` describe, including the align-infix names (`\thc3` → center, `\thr5` → end) and both guards — a cell-named span with no open row stays an ordinary char span, and a nested `\+tc1` is never a cell.

- **A copy limit is fitted to what the copy writes:** when the host sets `EditorOptions.copyLimit` (its TSDoc holds the contract), one CRITICAL handler in `CopyLimitPlugin` measures each copy or cut the way the view writes it: Standard-view USFM (`$selectionToUsfmText`), Markers-view USFM (`$selectionToUsfmViaStandardView`, which rebuilds the document as Standard view once per editor state), or elsewhere the selected text plus every note caller's preview text. A copy or cut that fits is a normal one, except that Standard view writes no `application/x-lexical-editor` flavor while a limit is set: a caller's `previewText` carries its whole note. One over the limit has its selection shortened (`$limitSelectionLength`) until the measure fits. The start is kept; the end never splits a grapheme cluster, even across text nodes; and atomic text (token-mode, glyph and chapter-line text), a note or an opaque construct (a table, a figure, a sidebar) that the selection runs into is kept whole or left out, unless the selection starts strictly inside it. What stays selected is what is copied, and a read-only editor moves the page selection to match. When nothing fits, the copy or cut is blocked, writing and removing nothing, as at a limit of 0; a node selection that does not fit is blocked the same way. `$writeCopyPayload` keeps only a backstop: a payload that still overshoots is written as the start of its plain text, with a development warning, and removes nothing. Pinned in `Editor.test.tsx` (`options.copyLimit`), `CopyLimitPlugin.test.tsx` and `copyLimit.utils.test.tsx`.

### S2. Paste (Standard view, internal) — same-namespace `application/x-lexical-editor` payload reconstructs the exact node tree (existing)

**Fixed gap (2026-08-13, extended to every kind 2026-09-10), lexical-JSON flavor only:** the "exact node tree" promise did not hold for a selection containing an `UnknownNode` (figure, sidebar, periph, ref, optbreak). `@lexical/clipboard`'s JSON generator (`$appendNodesToJSON`, the function behind the `application/x-lexical-editor` flavor) computes node exclusion from `currentNode.excludeFromCopy('html')` — the literal string `'html'`, hardcoded for EVERY copy-out destination `$appendNodesToJSON` itself handles, not only actual HTML generation (`'clone'` is never passed by any Lexical-shipped code path in the installed version). This change touches ONLY that flavor, and doubly so: Standard view's `text/html` is not a DOM export at all (S1 — it renders the copy walker's own USFM), and even where Lexical's exporter does run, `$appendNodesToHTML` (`@lexical/html`) computes the same `excludeFromCopy('html')` value but returns early on `UnknownNode.exportDOM()`'s unconditional `{element: null}` BEFORE ever consulting it.
Expand Down Expand Up @@ -105,13 +107,17 @@ Under S3 this is semantically identical to a normal external paste in Standard v

- **Copy-out is prose** (no marker text) — existing, gets a gate test protecting Standard-view handlers from leaking there. Their `text/html` is Lexical's own `$generateHtmlFromNodes` export, unchanged by this work, so it drops every `UnknownNode` and `ImmutableNoteCallerNode` subtree — the one-set-of-bytes guarantee (S1) is Standard view's alone.

- **With a copy limit, an over-limit copy from these views is plain text of what they show:** when the host sets `EditorOptions.copyLimit`, a copy or cut here that fits, counting every note caller's preview text (which Lexical's `text/html` and `application/x-lexical-editor` flavors carry), is Lexical's normal copy. One over the limit, like a Markers-view range that cannot be mapped, is fitted to and written as the plain text the view shows (`$selectionShownText`): a collapsed note's text is left out and does not count. A limited cut removes the range only when that text carries all of it, and never when the range holds a verse or chapter marker, a note or another construct. Pinned in `CopyLimitPlugin.test.tsx` and `Editor.test.tsx`.

Unformatted view is not a hidden-marker view, but it has no view-specific clipboard claim either, so the same rules apply to its copies and cuts.

- **The paste gate** (swallow anything containing `\` **or `/`**) is over-broad — it eats URLs, dates, "and/or" (live-confirmed 2026-08-07). **Pre-existing upstream behavior** (`CommandMenuPlugin` on `origin/main`, predates the standard-view branches) — per TJ, NOT fixed here; recorded in the semantics doc's deferred list. Structural apply-markers-on-paste in formatted views likewise out of scope.

### S6. Cut = copy + `removeText()` (existing)

Including the empty case: a cut with nothing selected writes nothing and removes nothing, by the same rule and the same two enforcement layers as copy (see S1's "Copying nothing leaves the clipboard untouched").

A structure-protected document's cut of a selection `StructureKeyboardPlugin` refuses to replace (a range spanning a paragraph boundary, or containing a verse marker) is declined to that plugin, which refuses it whole — nothing is copied and nothing removed — the same one-owner rule the paste claim follows (S3). Pinned in `whitespaceDisplay.plugin.utils.test.tsx` (`structure protection`).
A structure-protected document's cut of a selection `StructureKeyboardPlugin` refuses to replace (a range spanning a paragraph boundary, or containing a verse marker) is declined to that plugin, which refuses it whole — nothing is copied and nothing removed — the same one-owner rule the paste claim follows (S3). Pinned in `whitespaceDisplay.plugin.utils.test.tsx` (`structure protection`). With a copy limit, the selection is fitted before that plugin judges it, so a cut it would refuse whole can go ahead once shortened to within one paragraph (pinned in `Editor.test.tsx`).

The WI-2 filed quirk — a selection-delete ending exactly at a just-settled char-span boundary absorbing one adjacent character — gets a targeted regression pin. **Outcome (2026-08-07):** does NOT reproduce at the Lexical selection level. Pinned as regression armor for both `CUT_COMMAND` and a plain `removeText()`, each given a selection whose focus is an element point mirroring DOM `range.setEndAfter(spanElement)` — the exact boundary shape the live repro's programmatic DOM selection used. Both variants produced byte-exact, correctly-scoped results, narrowing the live repro's root cause to the DOM Range → Lexical selection-resolution layer (`applyDOMRange`/`$internalResolveSelectionPoints`), which is not exercised by a Lexical-level `RangeSelection` and so remains covered only by the E2E selection steps.

Expand Down
18 changes: 12 additions & 6 deletions libs/shared-react/src/plugins/usj/OpaqueBlockGuardPlugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,12 +177,18 @@ export function isEditingKey(event: KeyboardEvent): boolean {
* lands, this predicate is where it comes back out.
*/
export function $opaqueBlockAncestor(node: LexicalNode): LexicalNode | undefined {
return (
$findMatchingParent(
node,
(current) => $isUnknownNode(current) || $isImmutableTableNode(current),
) ?? undefined
);
return $findMatchingParent(node, $isOpaqueBlockNode) ?? undefined;
}

/**
* Read-only: safe inside `editor.getEditorState().read()`, an `editor.update()`, or a command
* handler.
*
* Whether `node` itself is an opaque construct — the test {@link $opaqueBlockAncestor} applies at
* each ancestor, for a caller that already walks the ancestors itself.
*/
export function $isOpaqueBlockNode(node: LexicalNode | null | undefined): boolean {
return $isUnknownNode(node) || $isImmutableTableNode(node);
}

/**
Expand Down
10 changes: 10 additions & 0 deletions packages/platform/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ refused. The public surface grew substantially; nothing was removed.
- `defaultStyleInfo` and the `StyleInfo` / `MarkerStyleInfo` / `StyleType` types.
- `EditorOptions.styleInfo`, `EditorOptions.markerSettleDelayMs`,
`ViewOptions.showParaMarkerPrefixes`, and the caller/separator fields on `UsjNodeOptions`.
- `EditorOptions.copyLimit`: the most text one copy or cut may take, in UTF-16 code units
(fractions round down, negative or `NaN` is `0`, `undefined` is no limit). A copy that fits is a
normal copy. One over the limit is shortened silently, keeping its start and never splitting a
character, marker, note or construct, so it may take less than the limit or nothing; Standard
and Markers views fit the USFM they copy, and other views write plain text of what they show. A
limited cut removes only what it copied, except that in Standard view a cut ending inside a
character style also removes the style's closing marker, as any Standard-view cut does. The
Select All shortcut is blocked, except in other text fields, and `0` blocks copy and cut.
- Dependency on `unicode-segmenter` (`^0.17.3`), which keeps a shortened copy on grapheme cluster
boundaries whatever the runtime's own segmenter does. It is imported, not bundled.
- **Ctrl+Space removes character formatting from the selection.** On macOS this is ⌃Space rather than
⌘Space, which is Spotlight. It can collide with the macOS input-source switcher and with some IME
on/off toggles; the handler declines while a composition is active.
Expand Down
17 changes: 17 additions & 0 deletions packages/platform/dist/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,23 @@ export declare interface EditorOptions {
* Falls back to the bundled default stylesheet data when absent.
*/
styleInfo?: StyleInfo;
/**
* Most text one copy or cut may take, in UTF-16 code units (`String.length`). Fractions round
* down; negative or `NaN` means `0`; `undefined` means no limit.
*
* While set:
* - A copy or cut that fits the limit is a normal copy or cut, except that Standard view leaves
* out the editor's own `application/x-lexical-editor` clipboard format.
* - One over the limit is shortened silently. It keeps the selection's start and never splits a
* character, marker, note or construct (a table, figure or sidebar), so it may take less than
* the limit, or nothing. Standard view and the Markers view fit the USFM they copy; other views
* write plain text of what they show.
* - A limited cut removes only what it copied, except that in Standard view a cut ending inside a
* character style also removes the style's closing marker, as any Standard-view cut does.
* - The Select All shortcut is blocked, except in other text fields.
* - `0` blocks copy and cut.
*/
copyLimit?: number;
/**
* EXPERIMENTAL: Delay in milliseconds before pending marker edits settle in place while the
* user is idle (Paratext 9's debounced-reformat cadence), in editable marker modes. Defaults
Expand Down
Loading
Loading