tosijs-styled-editor 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,7 +5,181 @@ All notable changes to this project are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [0.6.0] - 2026-10-07
9
+
10
+ ### Added
11
+
12
+ - **Structural change tracking, BEHIND AN OFF-BY-DEFAULT FLAG**
13
+ (`editor.trackStructuralEdits`). Not reachable in 0.6.0, and not a supported
14
+ configuration for a document you care about — see the first note under
15
+ **Changed** for why. The rest of this entry describes what it does when
16
+ enabled, which is what 0.7.0 will ship.
17
+ Merging paragraphs is recorded the brute-force way — _these blocks out, these
18
+ blocks in_. A change mark wraps
19
+ content and a paragraph break is not content, so merging two paragraphs
20
+ strikes both originals (`data-block-delete`) and proposes a third
21
+ (`data-block-insert`); all three share one `data-change`, because one
22
+ keystroke is one change — the same rule paste already follows. Covers
23
+ Backspace at the start of a block, Delete at the end of one, and a selection
24
+ spanning blocks, where the proposed block reads as the deletion _would_ read
25
+ once accepted.
26
+ The cost is honest duplication: the text is present twice until someone
27
+ resolves it — and across a chain of merges, once per step, since each
28
+ superseded proposal is struck rather than removed. `value` carries all of it
29
+ until the chain is resolved.
30
+ **A merge is an ordinary edit.** It gets no veto and no special group: a
31
+ pending change in either paragraph, yours or anyone else's, neither blocks it
32
+ nor is absorbed into it. Every mark keeps its own id and stays independently
33
+ resolvable — privileging a structural edit over a textual one is not what
34
+ change tracking is for. The corollary is that contradictory choices are
35
+ possible and the editor honours them: reject an insertion, then accept a merge
36
+ whose proposal was taken from that paragraph, and those words are in the
37
+ result. Both are on screen when you choose; a review UI that wants to prevent
38
+ it should resolve a merge together with the edits inside it.
39
+ A chain of merges **strikes** each superseded proposal like any other block,
40
+ so an intermediate proposal stays in the document as
41
+ `<tosi-del data-change=… data-block-delete>` wrapping the earlier
42
+ `<tosi-ins data-block-insert>` — a shape a review UI has to render, and one
43
+ that stays resurrectable by rejecting the later merge. Dropping it instead
44
+ was tried and reversed: it was only safe while an earlier design absorbed the
45
+ earlier gesture into the later one, and without that it left a change id with
46
+ a delete half and no insert half, a state no gesture produces, from which
47
+ resolving per id lost text outright.
48
+ Lists and grid tables are the one refusal (`merge-blocks-not-mergeable`), and
49
+ it is about valid DOM rather than review policy: merging them produces loose
50
+ text as a direct child of a `<ul>`, or a mark that becomes a grid item and
51
+ shifts every `cellIndex`. That guard applies with tracking **off** as well.
52
+ `merge-blocks-backward`, `merge-blocks-forward` and `merge-blocks-selection`
53
+ no longer fire — those edits are recorded now.
54
+
55
+ ### Changed
56
+
57
+ - **A cross-block merge is still refused while `trackChanges` is on, and the
58
+ refusal is no longer overridable.** The structural representation above is
59
+ complete and tested, but RESOLUTION has a confirmed defect: accepting a merge
60
+ whose outgoing block has nothing to strike — empty, holding only an empty
61
+ inline wrapper, or already fully struck — leaves that block standing and
62
+ anchors the replacement after it:
63
+
64
+ ```
65
+ <p>First</p><p></p><p>Third</p> + Delete in the empty block
66
+ tracking off → <p>First</p><p>Third</p>
67
+ tracking on, accepted → <p></p><p>First</p><p>Third</p>
68
+ ```
69
+
70
+ A flag about review deciding document structure is the one class this release
71
+ spent five correctness rounds eliminating, so the feature is gated off rather
72
+ than shipped reachable. `rejectChanges()` was correct throughout; accept was
73
+ the broken half. 0.7.0 finishes it.
74
+
75
+ 0.5.x allowed `preventDefault()` on these refusals to perform the edit
76
+ untracked. That is gone, for the same reason it is gone for
77
+ `merge-blocks-not-mergeable`: an override that half-applies a gesture is the
78
+ shape three earlier remediation rounds kept producing.
79
+
80
+ - **A mouse drag no longer snaps to word boundaries; a touch drag does.**
81
+ `stickySelection` is the knob — `'touch'` (the default), `'always'`, or
82
+ `'never'` — on both the component and `Selectable`, live and settable at any
83
+ time. **0.5.x snapped for every pointer**, so if you relied on that, set
84
+ `editor.stickySelection = 'always'`.
85
+ The reasoning is per-pointer: rounding a drag a mouse user aimed out to the
86
+ nearest words overrides a precise gesture, and double-click already means
87
+ "select this word". A fingertip has no precision to override. A hybrid device
88
+ (a Surface Pro) answers differently for its two pointers on the same document,
89
+ because the decision is per gesture. A stylus counts as a mouse: pen input
90
+ arrives as pointer plus compatibility mouse events, not touch events.
91
+ - **Deleting across blocks now keeps the block at the START of the selection**,
92
+ with its type and its attributes. A selection delete used to keep the LAST
93
+ block while every other block merge kept the first, so dragging from a heading
94
+ into a paragraph and deleting left a `<p>` — and once tracked merges stopped
95
+ being refused, `trackChanges` silently decided which element type and which
96
+ `id` survived: the same gesture gave `<p class="a">Headgraph</p>` untracked and
97
+ `<h1 id="t">Headgraph</h1>` tracked. Same text, different wrapper, chosen by a
98
+ flag about review. One rule now, on every path.
99
+ - **Word stickiness now applies to touch at all, including the affordance
100
+ handles.** It had been wired to the mouse only, which is backwards — and on a
101
+ phone the handles are how a selection is adjusted, so a touch user could not
102
+ reach it even after the two `Selectable` paths were fixed.
103
+
104
+ ### Removed
105
+
106
+ - **Nothing was removed from the `structural-edit-refused` vocabulary.**
107
+ `merge-blocks-backward`, `merge-blocks-forward` and `merge-blocks-selection`
108
+ still fire with the default flags, so a 0.5.x listener keyed on them keeps
109
+ working. They stop firing only when `trackStructuralEdits` is enabled, which
110
+ 0.6.0 does not do.
111
+ - **`merge-blocks-not-mergeable` is no longer overridable.** 0.5.x documented
112
+ `preventDefault()` on `structural-edit-refused` as "performs the edit
113
+ untracked"; that stands for `remove-list-item`, `merge-list-items`,
114
+ `delete-table-row` and `delete-table-col`, and **not** for
115
+ `merge-blocks-not-mergeable`, which is about valid DOM rather than review
116
+ policy and fires with `trackChanges` off as well, where "untracked" means
117
+ nothing.
118
+ Before → after for a host doing
119
+ `if (e.detail.reason.startsWith('merge-blocks')) e.preventDefault()`: an
120
+ ordinary paragraph merge used to fire `merge-blocks-backward` and be performed
121
+ untracked; it now fires **no event** and is recorded as a tracked structural
122
+ change. Only a list or grid-table merge still refuses, and `preventDefault()`
123
+ on it has no effect — the document is left alone and the event is there to tell
124
+ the user why.
125
+
126
+ ### Fixed
127
+
128
+ - **A partially struck block was skipped, and its text then appeared twice.**
129
+ The "already entirely deleted" guard tested element children, so
130
+ `<p>keep <tosi-del>cut</tosi-del></p>` looked fully deleted — a text node is
131
+ not an element child. Accepting a merge then left the surviving text in both
132
+ the original and the replacement.
133
+ - **Rejecting a block-scoped insertion left an empty paragraph** where the
134
+ proposal had been, and took the caret with it — the mirror of a fix already
135
+ made on the accept side. The caret is now rescued out before the block goes.
136
+ - **With `trackChanges: false` — the default — Backspace at the start of a block
137
+ and Delete at the end of one ATE A CHARACTER of the neighbouring block.**
138
+ `<p>one</p><p>two</p>` + Backspace at the start of `two` produced `"ontwo"`;
139
+ Delete at the end of `one` produced `"onewo"`. The character deletion ran and
140
+ then the blocks merged, where a gesture crossing a block boundary should delete
141
+ the paragraph break and nothing else. Present since at least 0.4.4 and
142
+ unaffected by tracking being off, so **every 0.5.x build loses a character on
143
+ every cross-paragraph Backspace.**
144
+ - **A footnote reference duplicated in the document minted a second list item**
145
+ sharing one `data-footnote` and one `id`, carrying placeholder text, which
146
+ survived accept, reject and an explicit renumber and reached `value`. Reachable
147
+ in 0.5.x by pasting a reference. Footnote numbering is now per distinct note
148
+ rather than per reference, so repeated references to one note share its number
149
+ and its single list entry.
150
+ - **Rejecting a chain of merges left one stray empty paragraph per intermediate
151
+ step**, in the document and in `value`.
152
+ - **Delete in an empty block kept the block and pulled the NEXT block's content
153
+ into it**, leaving the caret at the start of what it had absorbed. An empty
154
+ block is residue — most often what a block-series delete just left behind — so
155
+ it now merges into the PREVIOUS block and the caret lands at its end, which is
156
+ what Backspace already did. The gesture's whole effect is that the block the
157
+ caret was in stops existing, so moving the caret forward made no sense. The
158
+ one exception is having no previous block, where the ordinary forward
159
+ behaviour stands.
160
+
161
+ ## [0.5.2] - 2026-09-26
162
+
163
+ ### Security
164
+
165
+ - **0.5.1's fix was incomplete: `reviseWith()` could still emit live HTML.**
166
+ The guard that strips `<` and `>` from `changeAuthor` was applied at the four
167
+ write sites in the component and missed the fifth, `mark()` in
168
+ `src/changes.ts` — which is exactly what `reviseWith()` and the exported
169
+ `applyRevision()` go through. So the LLM-proofreading path, documented public
170
+ API, still broke a display name out of a raw-text element and put an
171
+ `<img onerror>` into `editor.value`. Reproduced end to end through the public
172
+ `value` setter and `reviseWith()`, with no host cooperation beyond wiring
173
+ `changeAuthor.name` to a profile name.
174
+
175
+ The guard now lives in `src/changes.ts` beside the only code that writes those
176
+ attributes, and every writer shares it. **A guard belongs at the layer every
177
+ writer shares, not at the addresses where the bug was first noticed** — which
178
+ is precisely the mistake 0.5.1 made.
179
+
180
+ **Upgrade from 0.5.1 as well as from 0.5.0** if you use `trackChanges` or
181
+ `reviseWith`. `SECURITY.md`'s statement in 0.5.1 was true of typing and
182
+ deletion and false of revision.
9
183
 
10
184
  ## [0.5.1] - 2026-09-26
11
185
 
@@ -48,7 +222,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
48
222
  ### Added
49
223
 
50
224
  - `.github/workflows/publish.yml` — OIDC trusted publishing with npm staged
51
- publishing. CI can only *stage*; the maintainer's 2FA approval on npmjs.com is
225
+ publishing. CI can only _stage_; the maintainer's 2FA approval on npmjs.com is
52
226
  what publishes, and it works from a phone.
53
227
 
54
228
  ## [0.5.0] - 2026-09-21
package/README.md CHANGED
@@ -355,6 +355,58 @@ editor.changeAuthor = { id: 'alex', name: 'Alex' }
355
355
  editor.trackChanges = true
356
356
  ```
357
357
 
358
+ ### Structural edits
359
+
360
+ **Merging paragraphs is tracked too**, the brute-force way: _these blocks out,
361
+ these blocks in_. A change mark wraps content and a paragraph break is not
362
+ content, so merging two paragraphs strikes both originals and proposes a third:
363
+
364
+ ```xml
365
+ <p><tosi-del data-change="c1" data-block-delete>Alpha.</tosi-del></p>
366
+ <p><tosi-del data-change="c1" data-block-delete>Beta.</tosi-del></p>
367
+ <p><tosi-ins data-change="c1" data-block-insert>Alpha. Beta.</tosi-ins></p>
368
+ ```
369
+
370
+ All three share one `data-change`, because one keystroke is one change — the
371
+ same rule paste already follows. Accepting removes the struck blocks and keeps
372
+ the replacement; rejecting restores the originals and drops the proposal.
373
+ Covers Backspace at the start of a block, Delete at the end of one, and a
374
+ selection spanning blocks (where the proposal reads as the deletion _would_
375
+ read once accepted).
376
+
377
+ **The text is present twice until someone resolves it.** That is the cost of the
378
+ brute-force representation, and it is worth knowing before you persist `value`
379
+ on every keystroke: a document mid-merge serializes both the originals and the
380
+ proposal.
381
+
382
+ **`data-block-delete` / `data-block-insert` say a mark's scope is a whole
383
+ block** rather than a run of text, and they are what tell `acceptChanges` to
384
+ remove the struck block instead of leaving it empty. A downstream sanitizer that
385
+ strips unknown `data-*` attributes turns a proposed merge back into three
386
+ ordinary blocks.
387
+
388
+ **A merge is an ordinary edit.** It has no veto and no special group: a pending
389
+ change in either paragraph — yours or anyone's — does not block it, and it does
390
+ not absorb that change or resolve it for you. Every mark keeps its own id and
391
+ stays independently resolvable, because the point of tracking changes is that
392
+ you see the old text and the new text and _you_ decide what stays.
393
+
394
+ The corollary is that you can make contradictory choices, and the editor will do
395
+ what you said. Reject an insertion inside a paragraph and then accept a merge
396
+ whose proposed text was taken from that paragraph, and the inserted words are in
397
+ the result — you rejected them in the old text and accepted a new paragraph that
398
+ visibly contains them. Both are on screen when you choose. A review UI that
399
+ wants to prevent this should resolve a merge and the edits inside it together;
400
+ the editor does not decide that for you.
401
+
402
+ Lists and grid tables are the one refusal (`merge-blocks-not-mergeable`), and it
403
+ is about valid DOM rather than about review policy: merging them produces loose
404
+ text as a direct child of a `<ul>`, or a mark that becomes a grid item and
405
+ shifts every `cellIndex`. It is therefore **not overridable** — see
406
+ `structural-edit-refused` below.
407
+
408
+ ### Everything else
409
+
358
410
  Typing then lands inside a `<tosi-ins>`, and **every** deletion wraps in
359
411
  `<tosi-del>` instead of removing — caret Backspace and Delete, a selection
360
412
  delete, a cut, inside a list, inside a table cell. Nothing leaves the document
@@ -387,21 +439,13 @@ Two deletions behave specially, because the pedantic version would be noise:
387
439
 
388
440
  ### Not implemented
389
441
 
390
- - **no structural tracking.** A change mark wraps _content_, and structure is
391
- not content. So while `trackChanges` is on, edits that restructure rather
392
- than delete text are **refused** rather than performed:
393
-
394
- - Backspace at the start of a paragraph, Delete at the end of one, and
395
- Backspace out of a list item (each deletes a paragraph break)
396
- - **Delete Row** and **Delete Column** on a table — a grid table has no row
397
- elements, so row and column are derived from `cellIndex`, and a
398
- `<tosi-del>` around a cell would itself become a grid item and shift every
399
- later cell
400
-
401
- Refusing is deliberate: the alternative is silently restructuring the
402
- document with nothing in `changes` to show for it, which is the failure this
403
- feature exists to prevent. Text deletion inside a block or a cell is
404
- unaffected and fully tracked.
442
+ - **lists and tables are still refused.** Backspace out of a list item, and
443
+ **Delete Row** / **Delete Column** on a table — a grid table has no row
444
+ elements, so row and column are derived from `cellIndex`, and a `<tosi-del>`
445
+ around a cell would itself become a grid item and shift every later cell.
446
+ Refusing beats silently restructuring with nothing in `changes` to show for
447
+ it. Text deletion inside a list item or a cell is unaffected and fully
448
+ tracked. (Paragraph merges ARE tracked — see Structural edits above.)
405
449
 
406
450
  A refusal fires a cancelable **`structural-edit-refused`** event carrying
407
451
  `detail.reason`, so the key is not simply dead — show a note, or call
@@ -414,11 +458,31 @@ Two deletions behave specially, because the pedantic version would be noise:
414
458
  ```
415
459
 
416
460
  `detail.reason` is one of `merge-blocks-backward`, `merge-blocks-forward`,
417
- `merge-blocks-selection`, `remove-list-item`, `merge-list-items`,
418
- `delete-table-row`, `delete-table-col`. **Calling `preventDefault()` performs the edit
419
- untracked** — if tracking could have represented it, there would have been
420
- nothing to refuse. A custom command refuses the same way, through
421
- `ctx.refuseStructural(reason)`; see EXTENSIBILITY.md.
461
+ `merge-blocks-selection`, `merge-blocks-not-mergeable`, `remove-list-item`,
462
+ `merge-list-items`, `delete-table-row`, `delete-table-col`.
463
+
464
+ The first three are a cross-block merge while `trackChanges` is on. They stop
465
+ firing if you enable `trackStructuralEdits`, which records those edits instead
466
+ — see the Component API table; it is off in 0.6.0 and not a supported
467
+ configuration yet.
468
+
469
+ **Calling `preventDefault()` performs the edit untracked** — if tracking could
470
+ have represented it, there would have been nothing to refuse — **except for
471
+ the four `merge-blocks-*` reasons, which are not overridable.** 0.5.x allowed
472
+ it; an override that half-applies a gesture is the shape several remediation
473
+ rounds kept producing, so the document is now left alone and the event is
474
+ there to tell the user why. That one is about
475
+ valid DOM rather than review policy, and it fires with `trackChanges` **off**
476
+ as well, where "perform it untracked" means nothing: merging a list or a grid
477
+ table produces loose text as a direct child of a `<ul>`, or a mark that
478
+ becomes a grid item and shifts every `cellIndex`. The refusal event still
479
+ fires, so you can tell the user why nothing happened; the document is left
480
+ alone. A cross-block selection delete that hits it still deletes the selected
481
+ TEXT, tracked, and simply does not merge the remnants — the words are
482
+ representable, the paragraph break is not.
483
+
484
+ A custom command refuses the same way, through `ctx.refuseStructural(reason)`,
485
+ and its result IS honoured; see EXTENSIBILITY.md.
422
486
 
423
487
  - **no merge story.** Changes-as-content gets attribution, review and round-trip,
424
488
  but not collaborative merge. That needs an operation log, which is a larger
@@ -460,18 +524,35 @@ part of the review.
460
524
  | **Shift+Click** | Extends selection to click position |
461
525
  | **Double-click** | Selects word |
462
526
  | **Triple-click** | Selects block |
463
- | **Click-drag** | Selects words, sticky at boundaries |
464
-
465
- **Click-drag is sticky at word boundaries.** Snapping engages only once the drag
466
- leaves the word it began in — in practice, as soon as you cross a space. Inside
467
- that first word you keep character precision, so pulling `fix` out of `prefix`
468
- still works; cross into another word and both ends snap, including the anchor,
469
- because a selection that spans words but starts mid-word is almost never what was
470
- meant. Punctuation comes along only when the pointer reaches it, and a selection
471
- never ends in a trailing space you did not ask for. Sticky within a block only —
472
- a double-click drag is already word-granular, and a cross-block selection has
473
- larger units than words. `stickySelectionBounds(text, anchor, head)` is exported
474
- if you want the rule without the editor.
527
+ | **Click-drag** | Character precision; a FINGER snaps to words |
528
+
529
+ **Word stickiness is per pointer, and since 0.6.0 a mouse drag does NOT snap.**
530
+ A mouse user gets the platform's behaviour — character precision, and
531
+ double-click when they want a word — because rounding a drag they aimed out to
532
+ the nearest word boundaries overrides a precise gesture. A fingertip has no such
533
+ precision to override, so a touch drag snaps, which is the only way a drag across
534
+ a word ends up selecting that word.
535
+
536
+ `editor.stickySelection` chooses: `'touch'` (the default), `'always'` — which
537
+ restores the pre-0.6.0 behaviour for the mouse as well — or `'never'`. It is a
538
+ live property, settable at any time, and it takes effect on the next drag.
539
+
540
+ **When snapping does apply**, it engages only once the drag leaves the word it
541
+ began in — in practice, as soon as you cross a space. Inside that first word you
542
+ keep character precision, so pulling `fix` out of `prefix` still works; cross
543
+ into another word and both ends snap, including the anchor, because a selection
544
+ that spans words but starts mid-word is almost never what was meant. Punctuation
545
+ comes along only when the pointer reaches it, and a selection never ends in a
546
+ trailing space you did not ask for. Sticky within a block only — a double-click
547
+ drag is already word-granular, and a cross-block selection has larger units than
548
+ words.
549
+
550
+ Whether the pointer has left the anchor word is asked **visually**, per line box,
551
+ not by comparing logical offsets: inside a right-to-left run on a left-to-right
552
+ line, x decreases as the logical index rises, so the two directions oppose and an
553
+ offset comparison answers about the wrong one. `stickySelectionBounds(text,
554
+ anchor, head, leftTheAnchorWord?)` is exported if you want the rule without the
555
+ editor.
475
556
 
476
557
  ### Inside a table cell
477
558
 
@@ -596,6 +677,14 @@ inserting one in the middle renumbers the rest and reorders the list to match.
596
677
  Deleting a marker drops its entry on the next renumber, and deleting the last
597
678
  one removes the list. The stable identity is `data-footnote`, not the number.
598
679
 
680
+ **Numbering is per NOTE, not per reference** (since 0.6.0): two references
681
+ sharing a `data-footnote` are two pointers at one note, so they show the same
682
+ number and share its single list entry. That matters because a reference can
683
+ legitimately exist twice — copy one, or make a tracked edit that proposes a
684
+ merged paragraph, and the same marker is in the document twice until the change
685
+ is resolved. Numbering per reference minted a second list entry for the same
686
+ note, sharing one `id`, carrying placeholder text.
687
+
599
688
  Inside the editor a link is text you are editing, so clicking it places the
600
689
  caret rather than navigating. Ctrl/Cmd-click follows it.
601
690
 
@@ -709,6 +798,8 @@ Custom widgets you add follow the same rules and get translated too.
709
798
  | `commands` | `object` | Command registry (extend to add custom commands) |
710
799
  | `widgets` | `'none' \| 'minimal' \| 'default'` | Attribute — built-in toolbar preset |
711
800
  | `localized` | `boolean` | Attribute — translate the built-in widgets and show a language picker |
801
+ | `stickySelection` | `'touch' \| 'always' \| 'never'` | When a drag snaps to word boundaries. Default `'touch'`: a finger snaps, a mouse keeps character precision. `'always'` restores pre-0.6.0 mouse behaviour |
802
+ | `trackStructuralEdits` | `boolean` | Record cross-block merges as blocks-out/blocks-in instead of refusing them. **Off in 0.6.0 and not supported** — accepting such a merge resolves wrongly when an outgoing block has nothing to strike. 0.7.0 finishes it |
712
803
 
713
804
  ### Change tracking
714
805
 
package/SECURITY.md CHANGED
@@ -38,7 +38,8 @@ and we will find a private channel.
38
38
  `editor.value`, the form value and every undo snapshot. It is host-supplied and
39
39
  inside your trust boundary — but "the host wired it to a profile name" is the
40
40
  ordinary case, so the editor does not trust it: `<` and `>` are stripped at the
41
- seam (fixed in 0.5.1; 0.5.0 is affected).
41
+ seam (fully fixed in 0.5.2 — 0.5.0 is affected, and **0.5.1 is still affected
42
+ through `reviseWith()`**, whose write site the first fix missed).
42
43
 
43
44
  They have to be stripped rather than escaped. HTML attribute serialization
44
45
  escapes `&` and `"` and **never `<`**, and `style`, `xmp`, `title`, `textarea`,
package/dist/changes.d.ts CHANGED
@@ -36,6 +36,25 @@ export interface TrackedChange {
36
36
  }
37
37
  export declare const INS_TAG = "tosi-ins";
38
38
  export declare const DEL_TAG = "tosi-del";
39
+ /**
40
+ * Marks whose scope is a whole BLOCK rather than a run of text.
41
+ *
42
+ * A structural edit is recorded the brute-force way — *these blocks out, these
43
+ * blocks in* — because a change mark wraps content and a paragraph break is not
44
+ * content. Merging two paragraphs therefore strikes both originals and proposes
45
+ * a third; splitting one strikes it and proposes two. Accepting removes the
46
+ * struck blocks entirely rather than leaving them empty, which is what these
47
+ * attributes tell `acceptChanges` to do, and the gesture shares one
48
+ * `data-change` because one keystroke is one change — the rule paste already
49
+ * follows, not a privilege granted to structural edits.
50
+ *
51
+ * The cost is honest duplication: the text appears twice until someone resolves
52
+ * it. The alternative — a sentinel marking the break itself — avoids that but
53
+ * means the pre-accept DOM does not represent the proposed document, so
54
+ * anything without the styling reads it wrongly.
55
+ */
56
+ export declare const BLOCK_DELETE_ATTR = "data-block-delete";
57
+ export declare const BLOCK_INSERT_ATTR = "data-block-insert";
39
58
  /**
40
59
  * A new change id.
41
60
  *
@@ -63,8 +82,56 @@ export declare class TosiIns extends HTMLElement {
63
82
  export declare class TosiDel extends HTMLElement {
64
83
  }
65
84
  export declare function defineChanges(): void;
85
+ /**
86
+ * An attribute value that cannot break out of the element it is written into.
87
+ *
88
+ * HTML attribute serialization escapes `&` and `"` and **never `<`**. Inside a
89
+ * raw-text or RCDATA element — `style`, `xmp`, `title`, `textarea`, `noembed`,
90
+ * `noframes`, `plaintext` — the contents re-parse as text, so a `</style>` in
91
+ * an attribute value terminates the element and everything after it parses as
92
+ * markup, and `editor.value` then carries live HTML.
93
+ *
94
+ * This lives HERE, next to the only code that writes these attributes, because
95
+ * the first version of the fix lived in the component and covered four of the
96
+ * five write sites. The fifth was `mark()` below — on the public `reviseWith()`
97
+ * path — so 0.5.1 shipped as the fix for this and was still exploitable
98
+ * through it. A guard belongs at the layer every writer shares, not at the
99
+ * addresses where the bug was first noticed.
100
+ *
101
+ * Stripping, not escaping: no escape survives attribute serialization into a
102
+ * raw-text element.
103
+ */
104
+ export declare function safeAttributeValue(value: string): string;
105
+ /**
106
+ * Stamp a change mark with its attribution. **The only place that writes these
107
+ * attributes.**
108
+ *
109
+ * It exists because they were written at five addresses — four in the component
110
+ * (`openInsertion`, `trackDeletion`, `trackStructuralEdit`, `restampPastedChanges`)
111
+ * and `mark()` here — and the five drifted. `mark()` omitted `data-session`, so
112
+ * a `reviseWith()` mark attributed to the editor's own author carried no
113
+ * session, the "is this insertion MINE, from THIS session?" predicate could
114
+ * never answer true for it, and typing over your own revision nested a second
115
+ * change inside it instead of un-typing it.
116
+ *
117
+ * That is the shape this file already documents one screen above: the 0.5.1
118
+ * attribute-injection fix covered four of five write sites and shipped still
119
+ * exploitable through the fifth, and the conclusion recorded there was that a
120
+ * guard belongs at the layer every writer shares. The guard was moved and the
121
+ * five writers were not. (`reviews/0.6.0-dx-review.md`, M-3.)
122
+ *
123
+ * `id` is generated when not given, so a caller that needs one gesture to be
124
+ * one change passes the id it already minted.
125
+ */
126
+ export declare function stampMark(el: Element, attribution: {
127
+ id?: string;
128
+ author: ChangeAuthor;
129
+ session: string;
130
+ }): string;
66
131
  /** Every tracked change in a root, in document order. */
67
132
  export declare function changesIn(root: Element): TrackedChange[];
133
+ /** Unwrap an element, leaving its children where it was. */
134
+ export declare function unwrap(el: Element): void;
68
135
  /**
69
136
  * Accept a change: the insertion becomes ordinary text, the deletion goes.
70
137
  *
@@ -123,4 +190,4 @@ export declare function diffWords(before: string, after: string): DiffOp[];
123
190
  * proofreading pass that changes nothing should not dirty the document or
124
191
  * produce an undo step.
125
192
  */
126
- export declare function applyRevision(node: Text, revised: string, author: ChangeAuthor): number;
193
+ export declare function applyRevision(node: Text, revised: string, author: ChangeAuthor, session: string): number;
@@ -74,13 +74,6 @@ export interface EditableContext {
74
74
  }
75
75
  /** Parse a "key value key value" argument list into a CSS object */
76
76
  export declare function makeCSS(args: string[]): Record<string, string> | null;
77
- /**
78
- * Renumber footnotes from DOCUMENT ORDER and reorder the list to match.
79
- *
80
- * Numbers are never stored — they are derived here — so inserting a footnote
81
- * in the middle renumbers everything after it, and deleting a marker drops its
82
- * entry. The stable identity is `data-footnote`, not the number.
83
- */
84
77
  export declare function renumberFootnotes(root: HTMLElement): void;
85
78
  /** Command definitions — extensible by adding new methods */
86
79
  export declare const commands: Record<string, Command>;