Repository navigation
PT-4730: Limit how much text can be copied from the editor - #23
Conversation
When `copyLimit` is set, `CopyLimitPlugin` limits copying and cutting out of the editor: - A copy or cut over the limit shortens the selection (its start is kept, its end dropped) and copies or cuts exactly what remains, silently. The end never splits a character cluster (including across text nodes), a marker, a token node, a note, or a table, figure or sidebar. In Standard and Markers view the selection keeps shortening until its USFM clipboard form fits, so what is copied or removed is exactly what stays selected. A copy may take less than the limit, possibly nothing. - A limit of 0 copies and cuts nothing and leaves the clipboard unchanged. - A page-wide copy guard shortens browser copies that include editor text but that the editor didn't handle; when several editors guard one copy, the smallest limit wins. - The Select All shortcut is blocked while a limit is set, except in another text field. The macOS Edit menu's Select All is native and not blocked; a copy after it is still shortened. - Under a limit, copies from Formatted view (and Markers-view selections that can't be mapped to USFM) go to the clipboard as plain text. - Undefined leaves copy, cut and Select All unaffected. Character boundaries come from `unicode-segmenter` (`^0.17.3`), a new dependency that is imported, not bundled. Rebuilds the committed platform dist and API report, and adds docs/clipboard-semantics.md. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
5a2c5e4 to
7bf0f1b
Compare
One owner of the fit - CopyLimitPlugin takes the view options and owns the whole fit: one CRITICAL copy and cut handler measures the selection the way the view copies it (Standard-view USFM, Markers-view USFM, or text elsewhere) and shortens it once, ahead of the opaque-block and structure cut guards. MarkerEditPlugin and MarkersViewCopyPlugin no longer fit, so MarkersViewCopyPlugin is mounted only in the Markers view again. - The Markers view rebuilds the document as Standard view once per editor state and maps only the USJ range for each measure, so a shortened copy converts the document once. - $writeCopyPayload keeps only a backstop: a payload over the limit is written as the start of its plain text, with a development warning, and removes nothing. The options bag, the payload and measure callbacks and limitCopyPayload are gone, with the tests that only covered them. - Editor.tsx says why CopyLimitPlugin must mount before the plugins with CRITICAL cut handlers, and the fit notes that it must leave the second pass of a copy with no clipboard event to @lexical/clipboard. Clipboard formats - A copy or cut that fits the limit is a normal copy or cut in every view. Outside Standard and Markers views, fitting counts the selected text plus every note caller's preview text, which the HTML and Lexical flavors carry. - Over the limit, those views are fitted to and write the plain text the view shows: a collapsed note's text is left out and does not count. - Standard view writes no application/x-lexical-editor flavor while a limit is set. The fit - A construct is exempt from being kept whole only when the selection starts strictly inside it, so a selection starting at a note's caller no longer copies or cuts part of the note. - Atomic text is token text, glyph text ($isGlyphTextNode) and the chapter line's text, so a fit never ends inside a chapter line or a character attribute. - The grapheme-cluster step falls back to before a construct rather than moving back into it. - The selection's cached nodes are cleared after each attempt, so a host setSelection followed by copy() is shortened rather than blocked. Cuts - A limited cut removes only what it copied: an over-limit plain-text cut whose range holds a verse or chapter marker, a note or another construct copies without removing, and a node-selection cut that does not fit is blocked. Cuts are still never refused. Read-only editors - After a fit, a read-only editor moves the page selection to match the shortened selection. Page guard and limit - normalizeCopyLimit turns any value that is not a finite number into 0; undefined still means no limit. - A browser copy whose page selection starts or ends outside the editor is declined by the fit and the clipboard writers, so the page guard writes it. - The page guard claims a copy only when its selection holds some of the editor's content, writes no-break spaces as spaces, and stores only the text it left. Tests - The Select All, double-dispatch and page-guard tests now fail when the code they cover is removed, and new tests cover a protected whole-paragraph cut, the read-only page selection, a caller start, a chapter line, an attribute run and a host setSelection before copy. Docs and build - The EditorOptions.copyLimit TSDoc, the CHANGELOG entry and docs/clipboard-semantics.md describe the behaviour as it now is. Comments are trimmed and point to EditorOptions.copyLimit. - vite.config.mts externalizes each dependency and peer dependency with any subpath imported from it, instead of listing a subpath by hand. - Rebuilds the committed platform dist. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
irahopkinson
left a comment
There was a problem hiding this comment.
Thanks, Katherine, this was a really useful review. Everything is in a new commit, ce7bb988, on top of the original. Item by item:
- 1 and 3: out of scope by the Product Owner's decision. See my reply on item 1.
- 2: after a fit, a read-only editor now moves the on-screen selection to match what was copied. Tested on
document.getSelection(). - 4, 5, 13 and 15: fixed. A start construct is exempt from keep-whole only when the selection starts strictly inside it. Atomic text is now
isToken() || $isGlyphTextNode(node)plus the chapter line's text. Cached nodes are cleared after each attempt. The grapheme-cluster step falls back to before a construct rather than moving into it. Each has a test. - 6: a copy that fits is now a normal copy in every view. In Formatted and Unformatted views, "fits" counts the selected text plus each selected note caller's preview text, since the HTML and Lexical formats carry it. Over the limit, those views write plain text of what the view shows: collapsed footnote text is left out and isn't counted.
- 7: a limited cut now removes only what it copied. An over-limit plain-text cut whose range holds a verse or chapter marker, a note or another construct copies without removing. A node-selection cut that doesn't fit is blocked. Cuts that fit are normal cuts. Note that cut is unlikely in practice, because limited texts are generally read-only resources, so cut isn't the focus of this PR.
- 8 and 18:
CopyLimitPluginnow owns the whole fit, in one CRITICAL handler that uses the view's measure. The Markers view converts the document to Standard view once per copy rather than once per measure. The$writeCopyPayloadbackstop is now a plain-text slice with a dev warning, and the options bag and callbacks are gone. - 9: Standard view writes no
application/x-lexical-editorflavor while a limit is set. - 10: there's a comment at
<CopyLimitPlugin />on why it must mount first, a note on the re-dispatch next to the fit, and a protected-mode test that fails if the plugin order changes. - 11, 12, 14 and 17: fixed, each with a test that fails without the fix.
normalizeCopyLimitnow fails closed. A copy whose page selection runs outside the editor is left to the page guard. The weak tests are strengthened. The page guard claims a copy only when it holds editor content, writes no-break spaces as spaces, and stores only the text. - 19 and 20: the public TSDoc, the CHANGELOG and
clipboard-semantics.mdare rewritten to be short and accurate, and the stale comments are fixed. Anything about what the limit doesn't cover is going in our private repo rather than the public docs. - 25:
vite.config.mtsnow builds a^name(/|$)pattern for each dependency and peer dependency, so a subpath import can't be bundled by accident. - 26: I'll correct the description.
- 16 and 21–24: left as they are for now. They fail closed or are nits.
@irahopkinson+AI made 2 comments.
Reviewable status: 4 of 21 files reviewed, 1 unresolved discussion (waiting on katherinejensen00).
packages/platform/src/editor/copyLimit/CopyLimitPlugin.tsx line 120 at r1 (raw file):
Previously, katherinejensen00 wrote…
1. Dragging a selection out of the editor ignores the limit
High · attach at
packages/platform/src/editor/copyLimit/CopyLimitPlugin.tsx:120· Confirmed (Lexical source and probe)A user can select a whole chapter of a limited resource and drag it into Word or another P10 panel, and all of it arrives. The limit only covers copy, cut and Select All.
- In Lexical 0.43,
addRootElementEventshascase 'dragstart': return isEditable && dispatchCommand(...). A read-only editor never dispatchesDRAGSTART_COMMAND, and the browser's native drag of selected non-editable text goes ahead.- In an editable editor, rich-text's
DRAGSTART_COMMANDhandler returnstruewithout callingpreventDefault.StructureKeyboardPlugin's drag guard acts only on an unsafe selection in protected mode.- Probe: full
<Editor>withcopyLimit: 0and the whole document selected. A dispatcheddragstartcame back withdefaultPrevented === false, sotext/plainandtext/htmlcarry the full selection.- Only the Select All shortcut is blocked, so Ctrl+Shift+End or a mouse drag still selects everything.
- This is not in the PR's known limitations.
Suggested fix: while a limit is set, add a
dragstartlistener next to the pagecopylistener. Where the DOM selection intersects the root, either cancel the drag, or rewritedataTransferto the slicedtext/plainand drop the other flavors. If P9 doesn't limit drag and you'd rather match it, list drag under "Known limitations" so the product owner makes that call explicitly.
Thanks, Katherine. Drag and drop is out of scope for this PR by the Product Owner's decision. It isn't limited in P9 either, and he's asked us to keep it in mind for later. The same goes for item 3.
We're recording the paths this PR doesn't cover in our private repo rather than in the public docs, so I'd rather not add them to the known limitations here. Could we take this one offline if you'd like to discuss it further?
katherinejensen00
left a comment
There was a problem hiding this comment.
Thanks for making those changes, Ira! There is one non blocking item to consider and I will go ahead and approve the PR so you can merge when ready:
- Low–Medium: a limited Standard-view cut ending inside a \nd span also removes the \nd* closer, which it never copied. For example, at limit 12 the clipboard gets \p aa \nd LO and the document is left as \p RDLORD bb. This is the round-1 point in items 5 and 19: the doc wording changed, the behaviour didn't. The quick fix is to reword that doc line.
@katherinejensen00 reviewed 17 files and all commit messages, made 1 comment, and resolved 1 discussion.
Reviewable status:complete! all files reviewed, all discussions resolved (waiting on irahopkinson).
…its closer 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. States this in the EditorOptions.copyLimit TSDoc and the CHANGELOG. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
irahopkinson
left a comment
There was a problem hiding this comment.
Thanks, Katherine! I've reworded the doc line in 702f7693. The EditorOptions.copyLimit TSDoc and the CHANGELOG now say that 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. I've left the behaviour as it is, since it's how Standard-view cuts work generally, and cut is rarely used on limited texts.
@irahopkinson+AI made 1 comment.
Reviewable status: 18 of 21 files reviewed, all discussions resolved (waiting on katherinejensen00).
Brings in #23 (copy limit), #14 (context menu, \id-line editing and the book settle scope), #24 (empty-verse caret) and #15 (note index, key and highlight, unfocused applyUpdate, and the note-editing fixes). Conflicts, resolved to keep both sides' intent: - Settle engine (tier2Rebuild, virtualSettle): this branch's caret and annotation carry ($captureCaret/$restoreCaret through pairRuns, mark carry) replaces main's byte anchor with preserved-key counting, which covers the same typed-undeclared-marker case through the literal alignment. Main's book scope ($rebuildBook, $settledBookLine), note close at a typed closer, undeclared-marker-in-note rule and region caret restore are kept and ported onto this branch's capture, char opener renames, carried runs and tokenizeFragment (tokenizedBookLine now takes the fragment, so placeholders keep their kinds). One fragment join: $buildParaScopeFragment uses main's $appendParaFragments. $charNeedsSentinelAs asks main's edited-glyph question of a span as renamed. - applyUpdate: main's applyOps (focus rule, extra tag, report from onUpdate so a call from an update listener works) with this branch's applying-update signal and settled-USJ report. The signal is now set inside the update and cleared in onUpdate, so a nested call is applied and announced once with its ops; the "inside an update" warning and its test expectation go, since the call now behaves. - Note shell guard: this branch's rule that a caret never rests outside a protected note (front and past-closer carets go into the content, ranges are confined, select-all handled in its own update, moves announced with APP_PLACED_CARET_COMMAND) on main's command handling (deletes refused or stopped at the shell and closer edges, edits confine the range first). The past-closer caret lands at main's content-end text point. - Caller: this branch's caller transform, regex and caret handling are kept; main's caller-slot predicate replaces the duplicate scan, and $createWholeNote builds the slot for an empty caller (unmergeable). The editor->USJ adaptor keeps the identity-based caller slot and adds main's Standard-view space collapse after the caller. - Separator prefix: both helpers kept ($charSeparatorPrefixLength is mark-transparent); both use main's $ownsOpenerSeparator. - Literal-only blocks: the shared $isInLiteralOnlyBlock drops the book, which main gave a settle scope. - Tags: this branch's "tag only a commit that moved a node" and APP_PLACED_CARET_COMMAND are kept over main's release-after-commit in the caret repairs and scripture-reference placement. - Committed dist: this branch's; rebuilt separately. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us
Code Review Summary
Branch: pt-4730-copy-limit
Base: origin/main
Date: 2026-09-28, updated 2026-09-29 after review
Review model: Claude Opus 5.5
Files changed: 21 (3 generated:
packages/platform/dist/index.js, its.map, andpnpm-lock.yaml)Overview
PT-4730 protects the rights of text owners by limiting how much text can be copied out of the editor at once. This PR is the first of a set of three. It adds only the mechanism: a new optional
EditorOptions.copyLimit, counted in UTF-16 code units. The second PR, in paranext-core, works out the limit for each chapter and passes it to the editor. The third, outside this repo, decides which texts are limited. With no limit set, the editor behaves exactly as before.A copy or cut that fits the limit is a normal copy or cut. When one goes over the limit, the editor silently shortens the selection first:
The Select All shortcut is disabled while a limit is set, except in other text fields. A copy the browser makes itself, such as one whose selection runs outside the editor, has its clipboard text shortened too. Character boundaries come from
unicode-segmenter, because Electron'sIntl.Segmentersplits some Khmer and Myanmar conjuncts.Changes after review
Katherine's review is answered in the Reviewable thread, and the changes are in
ce7bb988, on top of the original commit:CopyLimitPlugintakes the view options and owns it, in a single CRITICAL copy and cut handler that uses the view's measure.MarkerEditPluginandMarkersViewCopyPluginno longer fit.$writeCopyPayloadbackstop is reduced to a plain-text slice with a development warning.application/x-lexical-editorflavor while a limit is set.setSelectionfollowed bycopy()is shortened rather than blocked.normalizeCopyLimitfails closed for any non-finite value.EditorOptions.copyLimitTSDoc, the CHANGELOG anddocs/clipboard-semantics.mdare rewritten.Editor.tsxsays whyCopyLimitPluginmust mount first.vite.config.mtsbuilds a^name(/|$)pattern for each dependency and peer dependency.702f7693, after the approval): 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.API Changes
@eten-tech-foundation/platform-editor: added the optional propertyEditorOptions.copyLimit?: number(packages/platform/src/editor/editor.model.ts). It is reflected inpackages/platform/etc/platform-editor.api.mdandpackages/platform/dist/index.d.ts. This is the only change to the package's exported surface.@eten-tech-foundation/platform-editorpackage.json: addedunicode-segmenter^0.17.3as a regular dependency. It is external, so it is imported rather than bundled. (The analysis saw it as a required peer dependency. It was changed to a regular dependency during review.)shared-react(a private workspace package, not published): added the export$isOpaqueBlockNode.$opaqueBlockAncestornow calls it and behaves the same.packages/platform/src/index.ts:CopyLimitPlugintakesviewOptionsas well aslimit, and owns the whole fit.$writeCopyPayloadand$handleCopyForStandardViewtake the copy limit only for a plain-text backstop.$writeClipboardDatainwhitespaceDisplay.plugin.utils.ts.packages/platform/src/editor/copyLimit/(CopyLimitPlugin,copyLimit.utils). None of it is exported from the package.Findings
Critical — Must address before merge
None.
Important — Should address before merge
platform-editorwould have had to installunicode-segmenter, but the CHANGELOG listed it under "Added". (fixed during review:unicode-segmentermoved frompeerDependenciesanddevDependenciestodependencies.vite.config.mtsalready keeps dependencies out of the bundle, and the/^unicode-segmenter\//pattern stays for the subpath import. The vite comment and the CHANGELOG line were reworded, anddist/was rebuilt, still with one external import ofunicode-segmenter/grapheme.)unicode-segmenteronly through hoisting, becauselib/platform-bible-utilsdepends on it and none of the editor's consumers declare it. (fixed during review by the same change: as a regular dependency it now arrives withplatform-editor. This will be confirmed in core'spackage-lock.jsonwhen it is refreshed after the editor release.)Author response: the author intended an external dependency, not a peer. The same change covers both findings.
Minor — Consider
Silent blocking goes against the UX guideline "Never fail silently".(A deliberate Product Owner decision for P9 parity: a copy with nothing left to write, and the blocked Select All, give no feedback. See "Known limitations and deliberate exceptions" below.)Browser-made copies are shortened invisibly: only the clipboard text is cut down, and the on-screen selection stays full.(An accepted fallback. See "Known limitations and deliberate exceptions" below.)isOtherTextFieldbranches were untested: a focused<textarea>, and a contenteditable element outside the editor root. (fixed during review: two tests added toCopyLimitPlugin.test.tsx. The contenteditable test focuses the editor's own root as a control, so it tells the editor's contenteditable apart from another one. Both tests fail when their branch is removed.)Where the CHANGELOG places the dependency note.(Resolved by the dependency change: it is no longer a breaking change, so it stays under "Added".)limitCopyPayloadalways rebuilttext/html, even for a plain-text-only payload. (fixed during review: it rebuildstext/htmlonly when the incoming payload had it. A new test fails without the guard.limitCopyPayloadwas later removed, when the backstop became a plain-text slice (see "Changes after review").)$writePlainCopyduplicated$writeCopyPayload's clipboard-write branches. (fixed during review: a shared$writeClipboardData(event, editor, data, $remove?)is now used by both.)$writeCopyPayloadhad 8 positional parameters. (fixed during review: the last three are an options object,{ copyLimit, $payloadFor, $measure }, and every call site is updated.)$fitSelectionToCopyLimitwas exported but only used in its own file. (fixed during review: it is now module-private.)isStandardViewRefhad its ownuseEffectinMarkerEditPlugin.tsx. (fixed during review: it is declared with the other refs and synced in the existing wiring effect.)MarkersViewCopyPlugin.tsxsplit the adaptor import group. (fixed during review: the local imports are in alphabetical path order.)Two grapheme segmenters: arrow navigation uses(Follow-up ticket PT-4800, linked to PT-4730 and PT-4796.)Intl.Segmenter, and the copy limit usesunicode-segmenter.selectedTexttest helper was duplicated incopyLimit.utils.test.tsx. (fixed during review: one module-levelselectedText(editor)replaces three copies.)MAX_FIT_ATTEMPTSdoc didn't say what happens above 65,536 characters. (fixed during review: the doc now says that past that, the search stops early with a shorter selection that still fits.)Author response: the author asked for fixes to 9 of the 13 minor findings. #1 and #2 go in this description, #4 was resolved by the dependency change, and #11 became PT-4800.
Template Propagation
Shared Regions Modified
None.
Extension Config Changes
None. This repo has no extension template and no
extensions/directory.Known limitations and deliberate exceptions
applying-changes.mdx. Where the selection is shortened, the shortened selection itself is the feedback.CopyLimitPluginmust mount before the plugins with CRITICAL cut guards.Editor.tsxsays so, and a protected-mode test fails if the order changes.$characterOffsetre-implements Lexical's private offset logic, although the package already uses the exported$getCharacterOffsets.Positive Observations
undefinedis a no-op. With no limit,CopyLimitPluginadds no page listeners and every command handler declines, includingSELECT_ALL_COMMAND.COMMAND_PRIORITY_CRITICALand read the limit and the view through refs, so a limit that arrives later still runs ahead of the opaque-block and structureCUT_COMMANDguards. Those guards then judge the already-shortened selection. Each site comments why re-registering would be wrong.$limitSelectionLength,sliceToCopyLimitandnormalizeCopyLimit;undefined, NaN and fractional limits, backward selections, surrogate pairs, combining marks, Khmer conjuncts under a segmenter that splits them, token nodes, notes and opaque constructs, node selections, read-only cut, browser-made copies, two editors seeing one copy, a limit set after mount, and listener cleanup;Editor.test.tsxtests across Standard, Formatted and Markers views.$isOpaqueBlockNodewas pulled out of$opaqueBlockAncestor, so the copy-limit walk uses the exact same opaque-construct test. The existing clipboard writers are reused rather than new ones added.event.codefor other keyboard layouts, and it leaves Select All working in other text fields.$-prefixed export documents its calling context, there are no tracker IDs in the code,<CopyLimitPlugin />is in alphabetical order inEditor.tsx, and comments look forward.dist/index.d.tsanddocs/clipboard-semantics.mdare regenerated or updated to match.Interview Notes
unicode-segmenterto be external invite.config.mts, not a peer dependency.limitCopyPayloadguard was added. After Katherine's review, a copy that fits the limit, counting note callers' preview text, is a normal copy again, and only over-limit copies are plain text.ClipboardPlugin/clipboard.utils: the author asked whether these had been taken into account. The branch doesn't change them. Their shortcut, context-menu andEditorRefcopies dispatchCOPY_COMMAND/CUT_COMMANDwith a null payload, and the CRITICAL-priority limit handlers see those first. The LOW-priorityregisterEmptyCopyGuardstill answers a copy that is empty after shortening. The null-payload path is tested. The ordering againstregisterEmptyCopyGuardis not pinned by its own test.In-Review Quality Check
These checks ran from
packages/platformafter all the in-review changes. They're judged by exit code.npx vitest runpasses: 110 test files, 2,072 tests passed, 3 skipped.tsc --noEmitpasses fortsconfig.jsonandtsconfig.lib.json.tsconfig.spec.jsoncan't be checked on its own without a build, because it references the library project's output.Editor.test.tsxreadoptions.isReadonlyin anit.eachwhose first row didn't have it (TS2339). Fixed: that row now hasisReadonly: false, and that suite's 78 tests pass.libs/shared(untypedepitelete/proskomma-core). They come from compiling that library's source directly in this setup and aren't from any file this branch changed.whitespaceDisplay.plugin.utils.test.tsx(a call wrapped differently from what prettier expects). Fixed withprettier --write, and all changed files now pass.dist/index.d.tsis unchanged. After the last doc-comment edit,dist/index.js.mapwas rebuilt withscripts/rebuild-committed-output.mjs, because the source map embeds the source text.scripts/verify-committed-dist.mjsthen exits 0.Suggested Review Focus
copyLimit.utils.ts,$limitSelectionLengthand$findEnd). It keeps graphemes, markers, notes and opaque constructs whole, and a selection that starts inside a construct is handled separately.CopyLimitPlugin's single CRITICAL fit handler (with its choice of the view's measure, and the Markers view's once-per-state conversion), the plain-text writer at NORMAL, and the empty-copy guard at LOW.copylistener for browser-made copies, including two editors' guards seeing one copy.unicode-segmenterdependency: external, not bundled. Confirm it arrives in paranext-core through the editor's own dependency list.🤖 Generated with Claude Code
This change is