Skip to content

PT-4730: Limit how much text can be copied from the editor - #23

Merged
irahopkinson merged 3 commits into
mainfrom
pt-4730-copy-limit
Sep 29, 2026
Merged

irahopkinson merged 3 commits into
mainfrom
pt-4730-copy-limit

Conversation

@irahopkinson

@irahopkinson irahopkinson commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

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, and pnpm-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:

  • Where it ends: it keeps the selection's start, and the new end never falls inside a character (grapheme cluster), a marker, a note, or an opaque construct (a table, figure or sidebar).
  • How it's measured: Standard view and the Markers view fit the selection to its USFM clipboard form. The other views write plain text of what they show.
  • What's copied or cut: exactly what's left selected. A read-only editor moves the on-screen selection to match.
  • Cuts: a limited cut removes only what it copied.

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's Intl.Segmenter splits 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:

  • One owner of the fit: CopyLimitPlugin takes the view options and owns it, in a single CRITICAL copy and cut handler that uses the view's measure. MarkerEditPlugin and MarkersViewCopyPlugin no longer fit.
  • Markers view converts the document to Standard view once per editor state, rather than once per measure.
  • The $writeCopyPayload backstop is reduced to a plain-text slice with a development warning.
  • Clipboard formats:
    • A copy that fits is a normal copy in every view. Outside Standard and Markers views, "fits" counts the selected text plus each selected note caller's preview text.
    • Over the limit, those views write plain text of what they show, leaving out collapsed footnote text.
    • Standard view writes no application/x-lexical-editor flavor while a limit is set.
  • The fit:
    • A selection starting at a note's caller isn't treated as starting inside the note.
    • Chapter lines and attribute runs are kept whole.
    • The grapheme-cluster step can't move back into a construct.
    • A host's setSelection followed by copy() is shortened rather than blocked.
  • Cuts:
    • An over-limit plain-text cut whose range holds a marker, note or construct copies without removing.
    • A node-selection cut that doesn't fit is blocked.
    • Cut is unlikely in practice, because limited texts are generally read-only resources.
  • Read-only editors move the on-screen selection to match the shortened selection.
  • The page guard and the limit:
    • normalizeCopyLimit fails closed for any non-finite value.
    • A copy whose page selection runs outside the editor is left to the page guard.
    • The page guard claims only copies that hold editor content, writes no-break spaces as spaces, and stores only the text.
  • Tests: a new test for each fix, confirmed to fail without it. The weak Select All, double-dispatch and page-guard tests are strengthened. There's also a protected-mode ordering test.
  • Docs:
    • The EditorOptions.copyLimit TSDoc, the CHANGELOG and docs/clipboard-semantics.md are rewritten.
    • The stale comments are fixed.
    • Editor.tsx says why CopyLimitPlugin must mount first.
  • Build: vite.config.mts builds a ^name(/|$) pattern for each dependency and peer dependency.
  • The doc line on cuts (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 property EditorOptions.copyLimit?: number (packages/platform/src/editor/editor.model.ts). It is reflected in packages/platform/etc/platform-editor.api.md and packages/platform/dist/index.d.ts. This is the only change to the package's exported surface.
  • @eten-tech-foundation/platform-editor package.json: added unicode-segmenter ^0.17.3 as 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. $opaqueBlockAncestor now calls it and behaves the same.
  • Internal signatures that are not exported from packages/platform/src/index.ts:
    • CopyLimitPlugin takes viewOptions as well as limit, and owns the whole fit.
    • $writeCopyPayload and $handleCopyForStandardView take the copy limit only for a plain-text backstop.
    • New helper $writeClipboardData in whitespaceDisplay.plugin.utils.ts.
  • New internal module 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

  • A required peer dependency would be a breaking change for hosts. Every host of platform-editor would have had to install unicode-segmenter, but the CHANGELOG listed it under "Added". (fixed during review: unicode-segmenter moved from peerDependencies and devDependencies to dependencies. vite.config.mts already 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, and dist/ was rebuilt, still with one external import of unicode-segmenter/grapheme.)
  • paranext-core resolves unicode-segmenter only through hoisting, because lib/platform-bible-utils depends 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 with platform-editor. This will be confirmed in core's package-lock.json when 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.)
  • Two isOtherTextField branches were untested: a focused <textarea>, and a contenteditable element outside the editor root. (fixed during review: two tests added to CopyLimitPlugin.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".)
  • limitCopyPayload always rebuilt text/html, even for a plain-text-only payload. (fixed during review: it rebuilds text/html only when the incoming payload had it. A new test fails without the guard. limitCopyPayload was later removed, when the backstop became a plain-text slice (see "Changes after review").)
  • The node-selection write in $writePlainCopy duplicated $writeCopyPayload's clipboard-write branches. (fixed during review: a shared $writeClipboardData(event, editor, data, $remove?) is now used by both.)
  • $writeCopyPayload had 8 positional parameters. (fixed during review: the last three are an options object, { copyLimit, $payloadFor, $measure }, and every call site is updated.)
  • $fitSelectionToCopyLimit was exported but only used in its own file. (fixed during review: it is now module-private.)
  • isStandardViewRef had its own useEffect in MarkerEditPlugin.tsx. (fixed during review: it is declared with the other refs and synced in the existing wiring effect.)
  • An import in MarkersViewCopyPlugin.tsx split the adaptor import group. (fixed during review: the local imports are in alphabetical path order.)
  • Two grapheme segmenters: arrow navigation uses Intl.Segmenter, and the copy limit uses unicode-segmenter. (Follow-up ticket PT-4800, linked to PT-4730 and PT-4796.)
  • A selectedText test helper was duplicated in copyLimit.utils.test.tsx. (fixed during review: one module-level selectedText(editor) replaces three copies.)
  • The MAX_FIT_ATTEMPTS doc 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

  • Silence, for P9 parity. When nothing is left to copy (a limit of 0, or a selection whose first piece doesn't fit), nothing is written and the clipboard keeps what it held. The blocked Select All does nothing. Neither shows a message. This is a deliberate exception to the "Never fail silently" guideline in applying-changes.mdx. Where the selection is shortened, the shortened selection itself is the feedback.
  • Browser-made copies. When the browser makes the copy itself (a selection that starts or ends outside the editor, or a copy after the macOS Edit-menu Select All, which the shortcut block can't reach), only the clipboard text is shortened. The on-screen selection stays full, so "what stays selected is what is copied" doesn't hold in those cases.
  • Plugin order matters. CopyLimitPlugin must mount before the plugins with CRITICAL cut guards. Editor.tsx says so, and a protected-mode test fails if the order changes.
  • Fit edge cases that copy less than they could. None breaks the documented behaviour, which allows a copy to take less than the limit:
    • With a small limit, a copy that starts on a marker or verse glyph can copy nothing, even when a selection ending just after that glyph would fit. The fit's search assumes that fitting is monotone.
    • A note or construct that fits exactly (its last text ends right at the limit, or it ends the selection) may be left out.
    • A selection with element points at both ends can be measured differently from its text.
  • $characterOffset re-implements Lexical's private offset logic, although the package already uses the exported $getCharacterOffsets.

Positive Observations

  • The public change is purely additive: one optional property, and undefined is a no-op. With no limit, CopyLimitPlugin adds no page listeners and every command handler declines, including SELECT_ALL_COMMAND.
  • Handler order is deliberate. The limit handlers register once at mount at COMMAND_PRIORITY_CRITICAL and read the limit and the view through refs, so a limit that arrives later still runs ahead of the opaque-block and structure CUT_COMMAND guards. Those guards then judge the already-shortened selection. Each site comments why re-registering would be wrong.
  • Test coverage is thorough, in three layers:
    • unit tests for $limitSelectionLength, sliceToCopyLimit and normalizeCopyLimit;
    • plugin tests covering limit 0, 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;
    • end-to-end Editor.test.tsx tests across Standard, Formatted and Markers views.
  • Reuse is good: $isOpaqueBlockNode was 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.
  • The Select All block is scoped carefully. It matches the exact platform chord, also by event.code for other keyboard layouts, and it leaves Select All working in other text fields.
  • The keyboard shortcut catalog entry lands in the companion paranext-core PR.
  • The repo conventions are followed. Every new $-prefixed export documents its calling context, there are no tracker IDs in the code, <CopyLimitPlugin /> is in alphabetical order in Editor.tsx, and comments look forward.
  • The API report, dist/index.d.ts and docs/clipboard-semantics.md are regenerated or updated to match.

Interview Notes

  • Purpose: replicate P9's copy limit on protected texts in P10, in Simple and Power mode on every operating system. This PR is the first of a set of three. It adds only the editor mechanism, and later PRs supply the limit.
  • Design, in the author's words: "The main thing was to replicate the character rule as P9 has it and then I tried to make sensible decisions about how to apply that in our editor (which is inherently different that P9). We want what is in the clipboard to make sense to users. Also for the cut we didn't want to cut parts of items that should stay together." Cut is likely never used, because limited texts will be read-only resources. The author still wanted cut to behave sensibly if a limit is ever applied to an editable text.
  • Dependency: the author meant unicode-segmenter to be external in vite.config.mts, not a peer dependency.
  • Formatted view writes plain text only: the author asked why, then confirmed the reason. Lexical's HTML and internal clipboard formats carry a whole footnote behind its caller, which would put more than the limit on the clipboard. That's why the limitCopyPayload guard 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 and EditorRef copies dispatch COPY_COMMAND / CUT_COMMAND with a null payload, and the CRITICAL-priority limit handlers see those first. The LOW-priority registerEmptyCopyGuard still answers a copy that is empty after shortening. The null-payload path is tested. The ordering against registerEmptyCopyGuard is not pinned by its own test.
  • Silent blocking and browser-made copies: the author wants both recorded in the PR description, not as an architecture decision.
  • Arrow navigation segmenter: filed as follow-up PT-4800.
  • No unresolved items, and no areas the author deferred.

In-Review Quality Check

These checks ran from packages/platform after all the in-review changes. They're judged by exit code.

  • Tests: npx vitest run passes: 110 test files, 2,072 tests passed, 3 skipped.
  • Types: tsc --noEmit passes for tsconfig.json and tsconfig.lib.json.
    • tsconfig.spec.json can't be checked on its own without a build, because it references the library project's output.
    • A run without that reference found one real error, in branch code rather than an in-review change: Editor.test.tsx read options.isReadonly in an it.each whose first row didn't have it (TS2339). Fixed: that row now has isReadonly: false, and that suite's 78 tests pass.
    • The 12 errors left are in libs/shared (untyped epitelete / proskomma-core). They come from compiling that library's source directly in this setup and aren't from any file this branch changed.
  • Lint: eslint passes on every changed source file.
  • Formatting: prettier failed on the new test in whitespaceDisplay.plugin.utils.test.tsx (a call wrapped differently from what prettier expects). Fixed with prettier --write, and all changed files now pass.
  • API report: API Extractor exits 0 with no API change. dist/index.d.ts is unchanged. After the last doc-comment edit, dist/index.js.map was rebuilt with scripts/rebuild-committed-output.mjs, because the source map embeds the source text. scripts/verify-committed-dist.mjs then exits 0.

Suggested Review Focus

  • How an over-limit copy or cut decides where to end (copyLimit.utils.ts, $limitSelectionLength and $findEnd). It keeps graphemes, markers, notes and opaque constructs whole, and a selection that starts inside a construct is handled separately.
  • Handler priorities and registration: 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.
  • The fits-the-limit measure in Formatted and Unformatted views (text plus note callers' preview text), and the over-limit plain text built from what the view shows.
  • The page-level copy listener for browser-made copies, including two editors' guards seeing one copy.
  • The silent-by-decision exceptions listed above, and whether that silence is acceptable for users.
  • The unicode-segmenter dependency: external, not bundled. Confirm it arrives in paranext-core through the editor's own dependency list.

🤖 Generated with Claude Code


This change is Reviewable

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>
katherinejensen00

This comment was marked as resolved.

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 irahopkinson left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: CopyLimitPlugin now 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 $writeCopyPayload backstop 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-editor flavor 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. normalizeCopyLimit now 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.md are 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.mts now 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, addRootElementEvents has case 'dragstart': return isEditable && dispatchCommand(...). A read-only editor never dispatches DRAGSTART_COMMAND, and the browser's native drag of selected non-editable text goes ahead.
  • In an editable editor, rich-text's DRAGSTART_COMMAND handler returns true without calling preventDefault. StructureKeyboardPlugin's drag guard acts only on an unsafe selection in protected mode.
  • Probe: full <Editor> with copyLimit: 0 and the whole document selected. A dispatched dragstart came back with defaultPrevented === false, so text/plain and text/html carry 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 dragstart listener next to the page copy listener. Where the DOM selection intersects the root, either cancel the drag, or rewrite dataTransfer to the sliced text/plain and 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 katherinejensen00 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:lgtm: 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:

  1. 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: :shipit: 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 irahopkinson left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

@irahopkinson
irahopkinson merged commit 8b0b0bb into main Sep 29, 2026
5 of 6 checks passed
@irahopkinson
irahopkinson deleted the pt-4730-copy-limit branch September 29, 2026 22:38
tjcouch-sil added a commit that referenced this pull request Oct 13, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants