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 +176 -2
- package/README.md +123 -32
- package/SECURITY.md +2 -1
- package/dist/changes.d.ts +68 -1
- package/dist/commands.d.ts +0 -7
- package/dist/index.js +5 -5
- package/dist/module.js +396 -169
- package/dist/selection.d.ts +104 -1
- package/dist/tosijs-styled-editor.d.ts +287 -11
- package/dist/version.d.ts +1 -1
- package/package.json +14 -4
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
|
-
## [
|
|
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
|
|
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
|
-
- **
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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`, `
|
|
418
|
-
`delete-table-row`, `delete-table-col`.
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
`
|
|
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** |
|
|
464
|
-
|
|
465
|
-
**
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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.
|
|
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;
|
package/dist/commands.d.ts
CHANGED
|
@@ -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>;
|