Skip to content

PT-4911: Hidden-marker views copy prose and filter \ and // on paste - #41

Draft
tjcouch-sil wants to merge 29 commits into
mainfrom
pt-4911-hidden-view-clipboard
Draft

tjcouch-sil wants to merge 29 commits into
mainfrom
pt-4911-hidden-view-clipboard

Conversation

@tjcouch-sil

@tjcouch-sil tjcouch-sil commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

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

  • Copy no longer writes gutter paragraph markers (\q1 ) or hidden figure/unknown-construct text. A collapsed footnote's text stays in the plain-text copy, by design (catalog CB-6).
  • Paste and drop are never refused any more. Every \, 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.
  • Typing a single / works; \, and a / beside a /, are blocked. A / beside a / is judged across a comment or annotation mark's edge too.
  • An optional line break counts as the // 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 /.
  • A macOS transpose (Ctrl+T) never joins two / into //. Chromium raises it as a beforeinput insertTranspose, which Lexical applies past the filters, so /x/ could become //x. UnstorableTextPlugin refuses 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 reports source: "typing". Modelled in jsdom only; unverified on a real Mac.
  • Deleting never joins two / into //. Deleting b from a/b/c saved a//c, which the next load reads as an optional line break (the view then showed ac). A delete whose result would put a / against a / it was not already touching is refused, and reported once through onUnstorableTextRemoved with the new source: "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.
  • The host is told through a new EditorOptions.onUnstorableTextRemoved, called once per paste, drop or blocked keystroke that removed something. Hosts show the notice (paranext-core: companion PR below).
  • Locked structure: a plain-text paste of several lines now joins them with one space instead of starting a new paragraph.
  • Marker glyphs in pasted or dropped html (a drag across a paragraph break, an older copy) are dropped in these views, as the locked path already did, so a marker name never arrives as text.

UnstorableTextPlugin is 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 behind ParaMarkerSelectionPlugin's after a switch, and deleting a selected paragraph marker could join a/ 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 for main's changes: HiddenViewCopyPlugin now writes a stored no-break space as ~ in its plain text unless allowInvisibleCharacters is on, as main's InvisibleCharactersPlugin does for these views (this plugin claims the copy ahead of it); and since main's StructureKeyboardPlugin inserts a protected paste through SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, UnstorableTextPlugin filters 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 replaces CommandMenuPlugin (the Markers view keeps that). Word and line deletes are measured by $probeDeleteExtent (hiddenContentDelete.utils.ts), shared with HiddenContentDeleteGuardPlugin. StructureKeyboardPlugin takes $filterPastedNodes for its locked html path (hidden views only). HiddenContentDeleteGuardPlugin asks before a paste that will insert something.
  • shared-react now depends on @lexical/clipboard (same version as packages/platform).
  • Clipboard corpus (clipboardViewsCorpus.test.tsx): paragraph-structure rows now run off, guarded and protected; 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.md S5 rewritten; CHANGELOG; committed dist and etc/platform-editor.api.md rebuilt (public API: onUnstorableTextRemoved and three types; UnstorableTextRemoved.removed is a readonly array, and the handler is called synchronously from the editor command or update that made the edit).

Decisions for the reviewer

  • Locked structure + a stray marker: under 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.
  • Plain-text-only paste of a copied stray marker (\*) gives * with the notice, the same rule as \v 9 x → v 9 x.
  • IME-composed \ or // is not filtered (documented in the plugin's TSDoc).
  • Known, tracked in PT-4921: the html route still loses attributes and note details; a caption-less figure is left out of the editor's own clipboard format, so cut → paste back of a range holding one loses it (pinned under the PT-4921 rule).
  • A refused delete during a drag-move leaves the text in both places: the drop is its own input event and still goes in, so the move becomes a copy, with the "not deleted" notice. Nothing is lost.
  • A cut that first asks about hidden content has already copied: HiddenContentDeleteGuardPlugin copies 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).
  • Pre-existing, not changed here: under protected, a glyph-only html paste over a selection removes the selection (StructureKeyboardPlugin inserts 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); with SE_TEST_LIBRARY_VIEWS=1 1125 / 91.
  • After the code review: shared-react 2392 passed, 1 skipped; touched packages/platform suites 917 passed, 73 expected-fail.
  • Deletion guard: unstorableText.utils.test.ts ($deletionJoinsSlashes, 24) and unstorableTextDeletion.test.tsx (99); shared-react 2411 passed, 1 skipped; platform src/editor 4879 passed, 4 expected-fail; corpus 826 / 73.
  • After the /review-paratext round (optional-line-break rule, always-mounted plugin, shared delete-extent probe, readonly removed, new tests): nx run-many -t typecheck lint clean; shared-react 2417 passed, 1 skipped; packages/platform 5741 passed, 77 expected-fail, 127 skipped.
  • Against main (run on the merged tree before the rebase; the rebase changed no PT-4911 source): nx run-many -t typecheck lint clean; 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); with SE_TEST_LIBRARY_VIEWS=1 1132 / 84.
  • Transpose refusal: $transposeJoinsSlashes utils rows and unstorableTextTyping.test.tsx transpose rows, each guard mutation-checked. shared-react 2483 passed, 1 skipped; platform unstorable, clipboard and paste suites 1324 passed, 66 expected-fail.
  • Rebased onto main after the Scribe removal (scripture-editors#2) and PT-4982 (#2973); no Scribe-specific content left. nx clean; unstorable and clipboard suites 1386 + 84 expected-fail.
  • node scripts/verify-committed-dist.mjs: current at e7a9373c.
  • Hand QA in the app: not done.

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 Reviewable

Base automatically changed from pt-4197-data-fidelity to main November 3, 2026 20:40
@tjcouch-sil
tjcouch-sil force-pushed the pt-4911-hidden-view-clipboard branch from e87f905 to 36bf341 Compare November 4, 2026 19:04
tjcouch-sil and others added 26 commits November 5, 2026 15:41
…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>
tjcouch-sil and others added 3 commits November 5, 2026 15:41
…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
tjcouch-sil force-pushed the pt-4911-hidden-view-clipboard branch from 0a8aff7 to e7a9373 Compare November 5, 2026 23:34
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.

1 participant