Repository navigation
PT-4911: Hidden-marker views copy prose and filter \ and // on paste - #41
Draft
tjcouch-sil wants to merge 29 commits into
Draft
tjcouch-sil wants to merge 29 commits into
tjcouch-sil wants to merge 29 commits into
Conversation
This was referenced Oct 29, 2026
tjcouch-sil
force-pushed
the
pt-4911-hidden-view-clipboard
branch
from
November 4, 2026 19:04
e87f905 to
36bf341
Compare
…constructs A copy or cut in a hidden-marker view (formatted, paragraph-structure, block-verse, Preview) now writes the text the view shows: - HiddenViewCopyPlugin (COPY/CUT at NORMAL, mounted right after CopyLimitPlugin for markerMode "hidden") writes text/plain without a paragraph's gutter marker glyph or the text of an UnknownNode construct (a figure's caption), text/html from Lexical's export, and the internal lexical flavor. Collapsed note text stays in the plain text, which a plain-text paste needs. - ImmutableTypedTextNode exports no element for a gutter glyph, so the HTML flavor leaves it out. Lexical consults excludeFromCopy only on element nodes, so a decorator has to leave itself out through exportDOM. - CopyLimitPlugin's over-limit plain writer and its fit leave the same nodes out in hidden views. - The clipboard corpus's gutter-glyph known-failure rule is removed: its 43 rows now pass (the 44th still fails, under the html-flavor rule). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add UnstorableTextPlugin, which removes every `\` and every `//` (pairing slashes from the left, and counting a pasted `/` that touches a document `/`) from each paste and drop route: node copies and html through SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, plain text through PASTE_COMMAND and insertFromDrop. It reports each removal once through onRemoved and logs it at info. Under protected structure, a plain-text paste or drop joins its lines with a space instead of splitting the paragraph. StructureKeyboardPlugin takes a $filterPastedNodes prop for the html it inserts itself under protected structure. HiddenContentDeleteGuardPlugin treats a plain paste as inserting nothing only when the filter leaves it empty, and asks about html and node copies. EditorOptions gains onUnstorableTextRemoved (wired into the Editor separately). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The protected-structure sanitizer lists a paragraph's children without detaching them, so a text node the unstorable-text filter emptied was detached but stayed listed and was inserted with its original text. The filter now drops an emptied node from the list whenever it is listed, and both the plugin and the sanitizer use one `$insertsNothing` predicate, so a paste that leaves only an emptied character span no longer deletes the selection it was pasted over. Plain-text insertion goes through `$insertDataTransferForRichText` again (shared-react now depends on @lexical/clipboard, already in the lockfile). Under protected structure, each run of line breaks becomes one space and a trailing line break is dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Hidden-marker views mount UnstorableTextPlugin in place of CommandMenuPlugin, so a paste or drop holding `\` or `//` goes in without them instead of being refused, and EditorOptions.onUnstorableTextRemoved is told once per paste or drop. Under protected structure the html sanitizer filters the same way through StructureKeyboardPlugin's $filterPastedNodes, given only in hidden-marker views so Standard view's paste keeps its markers. The Markers view keeps CommandMenuPlugin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Hidden-marker views now take a paste holding `\` or `//` without those, so the corpus oracle filters the pasted text (filterFragment: strings only, never a stray-marker node) instead of expecting the paste refused, and checks that the host is told once whenever the filter removed something. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t stray \ and // The paste filter strips `\` and `//` from a hidden-marker view's pasted text, so a copy that leaked a gutter glyph or marker into text/plain would pass the paste checks with a matching notice. Each hidden-view copy row now checks that the text/plain holds exactly as many `\` and `/` as the copied content does, a stray marker counted as its own text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Hidden-marker views refuse a typed `\`, and a typed `/` that would touch a `/` beside it (judged outside any selection the keystroke replaces); a lone `/` goes in. Each refused keystroke is reported once through `onUnstorableTextRemoved` with source "typing". The refusal happens at KEY_DOWN (shortcut chords such as Ctrl+/ pass, AltGr does not), with backstops for typed text that arrives without a keydown: a CRITICAL CONTROLLED_TEXT_INSERTION_COMMAND handler (string payloads and insertReplacementText events) and a capture-phase root beforeinput listener for native insertText. Multi-character typed text (autocorrect, replacements) loses only `\` and `//` and goes in, with one notice. IME composition is not filtered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…guarded Simple mode's paragraph-structure view now runs every clipboard corpus row under each structure protection (off, guarded, protected — Simple mode's default); the other views run off. - Oracle: protected html pastes (all flavors included, since the sanitizer takes text/html first) unwrap paragraphs joined by one space and drop verse/chapter markers (protectedFragment); protected plain multi-line pastes join lines; a protected cut across a paragraph or over a verse is refused (document unchanged, nothing on the clipboard). The locked html route carries a stray marker as text, so its `\` is filtered with one notice (TJ ruling: accepted behavior). - Cut-paste-back expectations are built from the filtered fragment, so the document and the notice count agree; the filter models the characters beside the insertion point. - New rows: a range holding 2SA-1's figures, "cut, paste html and plain", and plain pastes of and/or, 1/2 and https://example.org. Every hidden-view copy checks text/plain for no gutter glyph, no figure caption, and the collapsed notes' text. - PT-4921 rule covers the html route under protection and a caption-less figure left out of the editor's own clipboard format (TJ ruling); PT-4388 rule narrowed to the rows that fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ally The plain-text paste rows now state what a hidden-marker view inserts and what its notice names as removed (https://example.org -> https:example.org with one "//" notice; and/or and 1/2 unchanged, no notice; the USFM-looking texts lose their "\"), instead of deriving both from the filter under test. A precondition asserts no "/" sits beside the paste point, which the literals assume. Also widens the PT-4921 reason to name an optbreak alongside the caption-less figure, and fixes two comment nits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rewrite the clipboard semantics doc's S5 for the hidden-marker views: the copy leaves out gutter markers and hidden constructs but keeps a collapsed note's text; paste, drop and typing remove `\` and `//` (with adjacency to a document `/`) and tell the host once through EditorOptions.onUnstorableTextRemoved; locked plain multi-line paste joins lines; the Markers view still refuses `\` and `/`. Close the CommandMenuPlugin paste-gate deferral, restate the collapsed-note copy as intended, point the remaining html-route and caption-less-figure gaps at PT-4921, and map the new tests. Record the user-visible changes and the new public API in the platform package's changelog. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dden-marker views Outside structure protection, html pasted or dropped into a hidden-marker view brought a marker glyph span back as text (ImmutableTypedTextNode's html import never claims one), and the unstorable-text filter then left it as text the user never wrote (`\q1 ` -> `q1 `) with a notice. A drag-move across a paragraph break in the paragraph-structure view hits this, since Lexical's DRAGSTART writes no data and the browser serializes the next paragraph's gutter glyph; so do html pastes of a Markers-view copy or an older copy. UnstorableTextPlugin now removes every `[data-text-type="marker"]` element from the html route of a paste or drop before Lexical parses it, through removeMarkerGlyphElements, which StructureKeyboardPlugin's protected sanitizer now shares. The node-copy and plain-text routes are untouched; html left with nothing to insert changes nothing. Standard view's handling of the same html is pinned unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- CopyLimitPlugin: the over-limit writer runs ahead of the hidden-marker views' HiddenViewCopyPlugin (NORMAL, registered after it), not only of HIGH handlers. - MarkerEditPlugin: the CUT arm's list of removers names HiddenViewCopyPlugin. - clipboard-semantics S5 and CHANGELOG: under protection a tab becomes a space only in a multi-line plain-text paste. - UnstorableTextPlugin joinLines: a trailing line break is dropped because under protection it would only split the paragraph. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e rule HiddenContentDeleteGuardPlugin skips asking about a paste that will insert nothing. It judged that by UnstorableTextPlugin's rule (only `\` and `//` are removed), but the guard also runs in the Markers view, where CommandMenuPlugin still refuses any paste whose plain text holds a `\` or `/`. In a Markers view made editable, a paste of "and/or" over hidden content would be asked about, and a "delete" answer would remove the hidden content while nothing was pasted. (The guard does nothing in a read-only editor, so the shipped read-only Markers view is not affected.) A new refusesCommandCharacters prop (set by Editor for markerMode "visible") restores the command-character answer there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…or precedence Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A comment or annotation mark saves as nothing, so text either side of its edge ends up adjacent in the file. The filter read only direct siblings, so a / typed or pasted right beside a / across a mark's edge saved as //. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nput alone insertFromYank (macOS Ctrl+Y) reaches CONTROLLED_TEXT_INSERTION_COMMAND as a replacement does, but the filter only read the replacement's event, so a yanked \ or // went in unfiltered. The beforeinput check called editor.read on every typed character, which commits pending updates; it now tests for a \ or / first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
$deletionJoinsSlashes reads what the file stores either side of what a delete removes (a range, one character from a caret, or a paragraph join) and says whether a / would end up against a / it was not already touching. It reads through annotation marks, gutter glyphs and cursor placeholders, through a character span the delete empties, and through two pieces of one span a join makes one again; a span edge that stays, a verse, a note or a hidden construct keeps the two apart, and a // already in the text is not counted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018WCZ5J1wq8kko2sACPaBFZ
…idden-marker views Deleting b from a/b/c saved a//c, which USFM reads as an optional line break, so the next load showed ac. UnstorableTextPlugin now refuses any delete whose result would put a / against a / it was not already touching, and tells the host once through onUnstorableTextRemoved with the new source "deletion". It judges every delete route: Backspace and Delete with their word and line chords at the keystroke (CRITICAL, ahead of guarded structure's arming press, the hidden-content question and the paragraph-marker merge), the same deletes without a keystroke (DELETE_*_COMMAND), a selected paragraph marker, a cut and a drag-move's removal of its source (HIGH, after the guards that narrow them). Under protected structure a join is left to that structure's own refusal. Word and line extents are measured on a copy of the selection, as HiddenContentDeleteGuardPlugin measures them; its deleteChordOf is exported for the keystroke mapping. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018WCZ5J1wq8kko2sACPaBFZ
An optional line break is a childless unknown node in the views that hide markers, so the filter and the delete check read it as a wall. The file stores it as a bare `//`, though: deleting the space from `a/ `, break, `/b` saved `a////b`, which reads back as two breaks, and the view lost both `/`. Typing or pasting a `/` against a break had the same effect when a `/` stood on its other side. $characterBeside now reads a break beside a point as `/`, and $readPieces gives a break's atom the text `//`, so $joinsSlashes sees a `/` brought against it. A delete still takes the break whole. Also in unstorableText.utils.ts: say where $insertsNothing and $textAroundSelection may be called, return undefined rather than null from $neighbourThroughMarks, build the grapheme segmenter once, and name the deletion's start, end and caret a Boundary rather than a Marker, which in this file otherwise means a USFM marker. New $deletionJoinsSlashes rows: a verse or a note between two `/` (staying and going), a range starting or ending inside a note, and a delete inside an expanded note's text. Docs: the CHANGELOG's protected-paste entry loses a stray line break, and Deferred item 9 states its open half instead of narrating it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018WCZ5J1wq8kko2sACPaBFZ
…s filtered Pins two rules UnstorableTextPlugin documents but no test held: a keydown an input method is composing is neither refused nor reported, and a drop carrying only `text/uri-list` (as Lexical reads it when there is no `text/plain`) loses its `//` with one notice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018WCZ5J1wq8kko2sACPaBFZ
…where markers hide It was mounted only when markerMode was "hidden", so an editor that switched into a hidden-marker view registered its CRITICAL KEY_DOWN and delete handlers behind ParaMarkerSelectionPlugin's: deleting a selected paragraph marker joined `a/` and `/c` into `a//c`. The plugin now takes isEnabled, held in a ref and asked by every handler and its beforeinput listener, and Editor mounts it always with isEnabled set for hidden markers, so its handlers keep their place across a view switch. mountInView gains rerenderWithView for the test. (Carried down from the PT-4921 branch, where it was 717333f, so that this branch holds it on its own.) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018WCZ5J1wq8kko2sACPaBFZ
…both delete judges HiddenContentDeleteGuardPlugin and UnstorableTextPlugin each extended a dirty clone of the selection with Selection.modify, then re-dirtied the live selection, so the delete's own modify starts from the caret again. That sequence is subtle and must not drift between the two, so it is now $probeDeleteExtent in hiddenContentDelete.utils.ts, which both call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018WCZ5J1wq8kko2sACPaBFZ
Hosts get `removed` to read, so it is `readonly UnstorableCharacter[]` before the type ships, rather than a breaking change later. The handler's TSDoc now says it is called synchronously from inside the editor command or update that made the edit. The CHANGELOG no longer says a refused cut copies nothing in every case: one that first asks about hidden content in the selection has already copied, as docs/clipboard-semantics.md's known gaps say. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018WCZ5J1wq8kko2sACPaBFZ
…pace as `~` `HiddenViewCopyPlugin` claims every range copy in the views that hide markers, ahead of `InvisibleCharactersPlugin`'s respelling copy, so after merging main a stored no-break space was copied as U+00A0 again. It now writes each stored one as `~` in its plain text when `allowInvisibleCharacters` is off, leaving the html and the editor's own flavor as U+00A0, as `InvisibleCharactersPlugin` does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he clipboard-nodes command Since main's `StructureKeyboardPlugin` sends a protected html paste through `SELECTION_INSERT_CLIPBOARD_NODES_COMMAND`, `UnstorableTextPlugin` filters it even when the sanitizer has no filter of its own. The test that pinned the old pass-through now pins that, and the TSDoc says the sanitizer's filter is still what reports a drop as a drop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
macOS Ctrl+T (a `beforeinput` `insertTranspose`) swaps the characters
either side of the caret, or at the end of a line the two before it, and
Lexical leaves it to the browser or inserts the swapped text itself without
a command this plugin sees. In a hidden-marker view `/x/` with the caret
before the second `/` became `//x`, which reloads as an optional line break.
Platform.Bible's main editor takes Ctrl+T for inserting a footnote, but the
footnote popover and other hosts do not.
`UnstorableTextPlugin`'s capture-phase `beforeinput` listener now refuses
such a transpose, puts the caret back (the browser selects the two
characters before it asks), and reports `source: "typing"`, `["//"]`: the
notice for a refused keystroke fits a transpose, the refused-delete one
("Not deleted") does not, and the public source union stays as it is.
`$transposeJoinsSlashes` judges the swap as the Backspace and Delete at that
caret, within the paragraph, so an optional line break counts as its `//`
and a character span's marker keeps two `/` apart. Modelled in jsdom only;
unverified on a real Mac.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…to the editor With isEnabled false, as a view that shows markers mounts it, each handler the plugin registers is driven once: a plain-text and an html paste, a plain and an html drop, the keydown of `\` and of a `/` beside a `/`, typed, replacement and yank text, a beforeinput insertText and insertTranspose, a Backspace keystroke, a delete without one, a cut and a drag-move's removal. Each leaves the text to the editor and reports nothing. A switch from enabled to disabled and back acts only while enabled and registers no command again. Mutation-checked: removing the isEnabled gate from any one handler fails at least one of these, except DELETE_WORD and DELETE_LINE, which need the browser's Selection.modify and are pinned in the platform editor suites instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…` handling UnstorableTextPlugin is mounted in every view and acts only where markers are hidden. Pin, in Standard view and the Markers view (the library-only `visible` registry view, run under SE_TEST_LIBRARY_VIEWS), that none of its routes changes what those views do or reports a removal: - deletes that join two `/`: Backspace, Delete, a delete without a keydown, word and line deletes, a cut, a drag-move's removal; - typing `\` and a `/` beside a `/` (the Markers view's CommandMenuPlugin refuses both silently), beforeinput text without a keydown, and typed, replacement and yank text holding `//`; - a plain-text paste of `//`, an html paste and a plain-text drop of `\`, and a protected paste's lines, with structure off and protected; - after a switch from paragraph-structure into Standard view: a delete, a typed `\` and a pasted `//`. The corpus's Standard plain-text rows now also assert no notice, and the transpose block runs its Markers row through enabledViews like the rest. Mutation-checked per handler: removing the isEnabled gate from any one handler, or the hidden-only gate on the sanitizer filter, fails at least one Standard or Markers row. In Standard view the paste and clipboard-nodes handlers are unreachable (Standard's own paste handling claims every paste at HIGH), so their gates are pinned by the Markers rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tjcouch-sil
force-pushed
the
pt-4911-hidden-view-clipboard
branch
from
November 5, 2026 23:34
0a8aff7 to
e7a9373
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Scripture-editors half of PT-4911. In the views that hide USFM markers (Simple mode's paragraph-structure view, the formatted view, block-verse):
\q1) or hidden figure/unknown-construct text. A collapsed footnote's text stays in the plain-text copy, by design (catalog CB-6).\, and every//scanning left to right (///leaves/), is removed from the inserted text, on every insertion route (Lexical's node route, its plain-text route, drops, and the locked-structure html path). A/next to an existing/counts as forming//. A single/("and/or", 1/2) is kept. Stray-marker (unmatched) nodes are left alone./works;\, and a/beside a/, are blocked. A/beside a/is judged across a comment or annotation mark's edge too.//the file stores for it. The view shows nothing there, but a/against one saves as///, and with a/on its other side as////, two breaks, which hides both/after the next load. So a/typed or pasted against a break is refused or removed, and a delete that would bring a/against one is refused, as for any other/./into//. Chromium raises it as abeforeinputinsertTranspose, which Lexical applies past the filters, so/x/could become//x.UnstorableTextPluginrefuses a transpose that would put a/against a/(an optional line break counts as//), judged as the Backspace and Delete at that caret ($transposeJoinsSlashes). It keeps the caret in place and reportssource: "typing". Modelled in jsdom only; unverified on a real Mac./into//. Deletingbfroma/b/csaveda//c, which the next load reads as an optional line break (the view then showedac). A delete whose result would put a/against a/it was not already touching is refused, and reported once throughonUnstorableTextRemovedwith the newsource: "deletion". It covers Backspace and Delete with their word and line chords, keydown-less deletes, selection deletes, a selected paragraph marker, a paragraph join (guarded structure's first press), cut and a drag-move's removal. Under protected structure a join stays protection's own refusal. Known gaps (S5): IME composition, and Android Chrome's browser-applied Backspace.EditorOptions.onUnstorableTextRemoved, called once per paste, drop or blocked keystroke that removed something. Hosts show the notice (paranext-core: companion PR below).UnstorableTextPluginis mounted in every view and acts only where markers are hidden (isEnabled), so an editor that switches into a hidden-marker view keeps its delete handlers ahead of the other plugins' at the same priority. Mounted only in hidden views, it registered behindParaMarkerSelectionPlugin's after a switch, and deleting a selected paragraph marker could joina/and/c. The same fix is on the PT-4921 branch (#43).Standard and Markers views are unchanged.
Companion: paranext/paranext-core#2969, which wires the notice and updates the data-fidelity catalog. Merge this first, then move
platform-yalc.Rebased onto
main, which includes #29's PT-4197 work; this branch carries only PT-4911's commits. Two follow-ups formain's changes:HiddenViewCopyPluginnow writes a stored no-break space as~in its plain text unlessallowInvisibleCharactersis on, as main'sInvisibleCharactersPlugindoes for these views (this plugin claims the copy ahead of it); and since main'sStructureKeyboardPlugininserts a protected paste throughSELECTION_INSERT_CLIPBOARD_NODES_COMMAND,UnstorableTextPluginfilters that paste even without the sanitizer's own filter, which the editor still passes so a drop is reported as a drop. PT-4921 (lossless Ctrl+V, #43) is stacked on this.Why review this
Part of the Standard-view epic's data-fidelity work: PT-4911 fixes catalog rows CB-1 and CB-5 found by the PT-4197 audit. Copying across a paragraph break in Simple view couldn't be pasted, a cut text was lost until Undo, and any paste with
\or/(a URL, "and/or") was silently refused.Changes
HiddenViewCopyPlugin+hiddenViewCopy.utils.ts: one copy/cut writer for hidden-marker views (plain text without gutter glyphs or unknown-construct text; html without glyph spans; the editor's own flavor unchanged, behind$hiddenViewLexicalFlavor, which PT-4921 changes).CopyLimitPlugin's over-limit copy uses the same predicate plus collapsed notes.UnstorableTextPlugin+unstorableText.utils.ts+unstorable-text.model.ts(shared-react): the filter, its paste/drop/typing routes, the delete check and the notice. Mounted in every view, acting only in hidden-marker ones, where it replacesCommandMenuPlugin(the Markers view keeps that). Word and line deletes are measured by$probeDeleteExtent(hiddenContentDelete.utils.ts), shared withHiddenContentDeleteGuardPlugin.StructureKeyboardPlugintakes$filterPastedNodesfor its locked html path (hidden views only).HiddenContentDeleteGuardPluginasks before a paste that will insert something.shared-reactnow depends on@lexical/clipboard(same version aspackages/platform).clipboardViewsCorpus.test.tsx): paragraph-structure rows now runoff,guardedandprotected; rows for "and/or", 1/2, a URL, ranges holding a figure; hidden-view copies checked for no gutter text, no caption, and the collapsed note's text. The 44 PT-4911 pins are gone.docs/clipboard-semantics.mdS5 rewritten; CHANGELOG; committeddistandetc/platform-editor.api.mdrebuilt (public API:onUnstorableTextRemovedand three types;UnstorableTextRemoved.removedis areadonlyarray, and the handler is called synchronously from the editor command or update that made the edit).Decisions for the reviewer
protected(Simple's default), the locked html path's DOMPurify drops the<unmatched>tag, so a copied stray\*pastes as*(with the notice). Accepted by TJ for now. A two-line DOMPurify allowlist would keep it; unlocked and Ctrl+V-through-the-editor-format routes already keep it.\*) gives*with the notice, the same rule as\v 9 x→v 9 x.\or//is not filtered (documented in the plugin's TSDoc).HiddenContentDeleteGuardPlugincopies before it asks, so if the removal is then refused for joining two/, the copy stays on the clipboard and nothing is removed (documented in S5's known gaps).protected, a glyph-only html paste over a selection removes the selection (StructureKeyboardPlugininserts an empty list).Testing
nx run-many -t typecheck lint: 0 errors. Vitest (--pool=forks --maxWorkers=4): shared 922 passed; shared-react 2387 passed, 1 skipped; packages/platform 5614 passed, 77 expected-fail, 126 skipped (run before the final review's fix commits; the touched suites were re-run after them and pass). Clipboard corpus: 826 passed, 73 expected-fail (PT-4921 60, PT-4388 13); withSE_TEST_LIBRARY_VIEWS=11125 / 91.unstorableText.utils.test.ts($deletionJoinsSlashes, 24) andunstorableTextDeletion.test.tsx(99); shared-react 2411 passed, 1 skipped; platformsrc/editor4879 passed, 4 expected-fail; corpus 826 / 73./review-paratextround (optional-line-break rule, always-mounted plugin, shared delete-extent probe, readonlyremoved, new tests):nx run-many -t typecheck lintclean; shared-react 2417 passed, 1 skipped; packages/platform 5741 passed, 77 expected-fail, 127 skipped.main(run on the merged tree before the rebase; the rebase changed no PT-4911 source):nx run-many -t typecheck lintclean; shared 948 passed; shared-react 2467 passed, 1 skipped; packages/platform 6043 passed, 69 expected-fail, 143 skipped. Clipboard corpus: 833 passed, 66 expected-fail (PT-4921 60, PT-4388 6: main's work lets the 2SA-5 plain-text pastes through); withSE_TEST_LIBRARY_VIEWS=11132 / 84.$transposeJoinsSlashesutils rows andunstorableTextTyping.test.tsxtranspose rows, each guard mutation-checked. shared-react 2483 passed, 1 skipped; platform unstorable, clipboard and paste suites 1324 passed, 66 expected-fail.mainafter the Scribe removal (scripture-editors#2) and PT-4982 (#2973); no Scribe-specific content left.nxclean; unstorable and clipboard suites 1386 + 84 expected-fail.node scripts/verify-committed-dist.mjs: current ate7a9373c.AI Involvement
AI-assisted (Claude Code): implemented task by task by AI subagents from a written plan, each task spec- and quality-reviewed by a separate AI reviewer, then a whole-branch AI review and fix wave. TJ ruled on the open product questions.
Risk Level
Medium — changes every clipboard route in Simple mode's editable view; covered by the corpus suite in all three structure-protection modes.
🤖 Generated with Claude Code
This change is