tosijs-styled-editor 0.4.4 → 0.5.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
@@ -7,6 +7,206 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-09-21
11
+
12
+ ### Added
13
+
14
+ - **Drag selection is sticky at word boundaries.** The rule is one sentence:
15
+ snapping engages only once the drag LEAVES the word it began in — which in
16
+ practice means as soon as you cross a space, since offsets bracket the space
17
+ and there is no "crossed the gap but not yet arrived" position to wait in.
18
+ Inside that word you keep character precision, so pulling `fix` out of
19
+ `prefix` still works; cross into another word and both ends snap — including the anchor,
20
+ because a selection spanning words that starts mid-word is almost never what
21
+ was meant. Coming back inside the anchor word returns to precision.
22
+ Punctuation comes along only when the pointer reaches it: the segmenter
23
+ treats `,` as its own segment, so dragging past the comma in `hello,` takes
24
+ it and stopping inside `hello` does not. Whitespace is never dragged along,
25
+ so a selection cannot end in a trailing space you did not ask for.
26
+ Sticky only within a block, and only for plain drags — a double-click drag is
27
+ already word-granular, and a cross-block selection has larger units than
28
+ words.
29
+
30
+ - **Tracked changes, and an LLM proofreading round-trip.** Insertions and
31
+ deletions are content (`<tosi-ins>` / `<tosi-del>` with author and timestamp),
32
+ not an operation log, so a tracked document still serializes and round-trips.
33
+ `reviseWith(fn, author)` sends each text node out as plain text, diffs the
34
+ response at word level, and applies the result as tracked changes;
35
+ `acceptChanges(id)` / `rejectChanges(id)` resolve them one at a time or all at
36
+ once.
37
+ The response is used as TEXT and never parsed as HTML, so a model returning
38
+ markup produces literal characters rather than elements — a stronger guarantee
39
+ than sanitizing, since there is no parse step to attack. Text already under
40
+ review is skipped, so a second pass cannot mark up the marks.
41
+ Change marks are styled in the core stylesheet on purpose: a `<tosi-del>`
42
+ without its strikethrough reads as the opposite of what the document means.
43
+ - **Live edit tracking** (`editor.trackChanges = true`). Typing lands inside a
44
+ `<tosi-ins>`, and every deletion wraps in `<tosi-del>` — caret Backspace and
45
+ Delete, selection deletes, cut, inside lists, inside table cells. Deletions
46
+ that RESTRUCTURE rather than delete text are refused instead — block merges
47
+ (a cross-paragraph selection delete, Backspace at the start of a
48
+ paragraph, Delete at the end of one, Backspace out of a list item) and table Delete Row / Delete Column. A change mark wraps
49
+ content and structure is not content, so the honest answer until structural
50
+ tracking exists is to decline rather than restructure the document with
51
+ nothing in `changes` to show for it. A refusal fires a cancelable
52
+ `structural-edit-refused` event carrying `detail.reason`, so a host can
53
+ explain the dead keystroke or override it. A custom command deletes through
54
+ `ctx.removeNode(node)` and checks `ctx.tracksChanges()` before restructuring
55
+ — see EXTENSIBILITY.md.
56
+ One delete gesture is **one** change however many nodes and blocks it spans,
57
+ matching paste. The mechanism is one predicate —
58
+ is the caret already inside an insertion that is mine, this session? — so a
59
+ continuous run of typing is one change and there is no per-operation
60
+ bookkeeping. Session is part of the test, so reopening a document and typing
61
+ at the edge of your own earlier insertion opens a new change rather than
62
+ silently merging into one bearing the older timestamp. Un-typing your own
63
+ uncommitted text really removes it; re-deleting already-deleted text is a
64
+ no-op. Cut and paste are tracked as well — a paste is ONE change rather than
65
+ one per word, since a reviewer accepts or rejects the paste, not its
66
+ individual words.
67
+ Accepting or rejecting **evaporates the mark entirely**: no wrapper, no
68
+ `data-change`, no attribution residue, and the text is re-normalized. A
69
+ document does not accumulate its own history — undo and the version store
70
+ already do that, and a document carrying every resolved edit becomes
71
+ unreadable and awkward to share.
72
+
73
+ - **Spell checking, which this editor otherwise has none of.** Browsers only
74
+ spell-check editing hosts (`textarea`, `input`, `contenteditable`), and nothing
75
+ here is one — so replacing `contentEditable` removed browser spell checking
76
+ entirely rather than leaving an unqueryable version of it. A contentEditable
77
+ editor has the opposite problem: checking it cannot query — no count, no list,
78
+ no way to block a submit on unresolved errors. This addresses both. Supply `editor.spellChecker` (a function from
79
+ words to the subset that is wrong) and the editor does tokenization
80
+ (`Intl.Segmenter`, so `don't` is one word and `l'objet` is two), marking,
81
+ and **form validity**: unresolved spelling sets `customError`, so a real form
82
+ submit is blocked rather than relying on the author to remember to check.
83
+ Resolution is `acceptWord(word, scope)` — `'document'` for a contract's
84
+ defined terms, `'dictionary'` for a firm's terms of art, exposed as
85
+ `documentWords` and `userDictionary`, with `handleWordAccepted(word, scope)`
86
+ to persist the latter. In a jargon-heavy domain the normal answer to an
87
+ unknown word is "that is a real word", not "I mistyped", so accepting has to
88
+ be as cheap as correcting. No dictionary ships — which words are real is a
89
+ localization question, and a hunspell dictionary is ~35x the size of this
90
+ editor.
91
+ Marks are view state: cleared on every check and stripped from `value`, so
92
+ they never reach the form value, an undo snapshot, or whatever the host
93
+ persists. Code, `kbd`, `samp`, `pre` and `spellcheck="false"` subtrees are
94
+ skipped.
95
+
96
+ ### Changed
97
+
98
+ - **Footnote markers are saved as `<tosi-footnote>`, not `<sup>`.** The
99
+ superscript rule lives in the editor's shadow stylesheet, so in a downstream
100
+ renderer a marker will lay out as a full-size baseline digit unless you style
101
+ it. `.footnote-ref` is retained as the class, so **one rule repairs every
102
+ document**, including ones written before this change:
103
+ `tosi-footnote, .footnote-ref { vertical-align: super; font-size: 0.75em; }`
104
+ - **`<tosi-del>` needs a strikethrough rule outside the editor too.** A tracked
105
+ document round-trips anywhere, which is the point — but an unstyled
106
+ `<tosi-del>` reads as ordinary prose, i.e. the _opposite_ of what the document
107
+ says. If you render `value` outside this component, ship
108
+ `tosi-del { text-decoration: line-through; opacity: 0.6; }` and
109
+ `tosi-ins { text-decoration: underline; }`. Beware a downstream sanitizer that
110
+ _unwraps_ unknown tags: that inverts a deletion silently. `acceptChanges()` is
111
+ how you hand a plain document to a consumer like that.
112
+ - **`ignoreWord` is gone** — it was added and deprecated within this unreleased
113
+ span, so it never shipped and protects no callers. Use
114
+ `acceptWord(word, 'document')`.
115
+ - **The build prints bundle sizes.** 0.5.0 measures 284.4 kB / **77.1 kB
116
+ gzipped** for the drop-in `dist/index.js`, and 143.8 kB / **29.2 kB gzipped**
117
+ for `dist/module.js` — three features for +3.4 kB gzipped over 0.4.5.
118
+
119
+ ### Fixed
120
+
121
+ - **`editor.value` could throw and permanently destroy every spelling mark.**
122
+ Reading `value` unwraps the marks to keep them out of the serialization, then
123
+ restores them. Restoring ran in document order, so when one mark's anchor was
124
+ the _next_ mark — routine, since any `normalize()` collapses the empty text
125
+ node between them — `insertBefore` threw partway and every remaining mark
126
+ stayed unwrapped for good. `updateUndo()` reads `value` first thing on
127
+ keypress, so one keystroke in such a document also silently lost the undo
128
+ snapshot and the form value.
129
+ - **Spell checking flagged correctly-spelled words, and the proofreader was sent
130
+ half-words.** Both walked text nodes directly, and the caret is a real element
131
+ that splits the node it sits in — so with the caret after `br` in
132
+ `the brown fox`, the checker was asked about `"br"` and reported it wrong,
133
+ blocking a form submit on a real word. Anything reading the document as
134
+ language now runs with the selection markers out of the text.
135
+ - **Under `trackChanges`, most deletions were not tracked at all.** Only
136
+ selection deletes consulted the gate; caret Backspace and Delete — the
137
+ commonest gesture in the editor — along with list and table-cell deletions and
138
+ fully-selected blocks removed content outright, with no `<tosi-del>`, no entry
139
+ in `changes`, and nothing for `rejectChanges()` to restore. Every destructive
140
+ path is now tracked.
141
+ - **A paste inside an existing insertion nested the marks**, so rejecting the
142
+ outer change silently discarded the inner one — including rejecting _another
143
+ author's_ change throwing away _your_ pasted text.
144
+ - **Change ids could collide** when a deletion and its replacement were produced
145
+ in one keystroke (typing or pasting over a selection), so accepting the
146
+ deletion also accepted the replacement.
147
+ - **Change marks arriving by paste are re-stamped.** `<tosi-ins>` is a safe
148
+ element, so pasted markup could carry any `data-author` and `data-time` it
149
+ liked and `editor.changes` reported it as fact — and a pasted `data-change`
150
+ could collide with a live one. Attribution is client-asserted document
151
+ content, not an authenticated identity; what is guaranteed is that a mark
152
+ records who put it in _this_ document.
153
+ - **A spelling error could outlive the document it described.** Undo, redo,
154
+ `value =` and form reset all wipe the marks, and none of them touched form
155
+ validity — leaving the field invalid with a message naming an absent word,
156
+ anchored to a detached node.
157
+ - **`reviseWith` no longer builds an unbounded diff table from a remote
158
+ response** (16k tokens measured at 1.7 s and +1.2 GB on the main thread), and
159
+ a proofreader that fails part-way no longer leaves the document half-revised
160
+ _outside_ the undo stack.
161
+ - **`acceptChanges('')` / `rejectChanges('')` no longer resolve every change in
162
+ the document.** An empty string arrives from a `dataset` lookup that found
163
+ nothing; `undefined` still means all.
164
+ - `src/spelling.ts` is exported from the package — `SpellChecker`,
165
+ `checkSpelling`, `wordsIn` and the rest were unnameable.
166
+
167
+ - **Footnotes maintain themselves.** `renumberFootnotes` was always correct —
168
+ it removed orphans and derived numbers from document order — but only ever ran
169
+ at insertion time, so deleting a reference left its text orphaned in the list
170
+ and the survivors mis-numbered. `<tosi-footnote>` now calls it from
171
+ connected/disconnectedCallback, so a deletion, drag, paste or undo maintains
172
+ the list with no command run. Documents saved earlier, which used a plain
173
+ `<sup class="footnote-ref">`, still renumber correctly.
174
+
175
+ ## [0.4.5] - 2026-09-17
176
+
177
+ ### Added
178
+
179
+ - **`SECURITY.md`**, with the one thing it needs to say: a sanitizer bypass
180
+ belongs to [`tosijs-kilpi`](https://github.com/tonioloewald/kilpi/issues),
181
+ because that is where the code lives. The README's security section now points
182
+ at kilpi's policy as authoritative rather than restating it — a copy of a
183
+ policy drifts from the policy, which is the same failure the extraction
184
+ removed from the code.
185
+ - **`NOTICE`**, for the three Apache-2.0 works the drop-in `dist/index.js`
186
+ bundles.
187
+
188
+ ### Changed
189
+
190
+ - **Sanitization moved to [`tosijs-kilpi`](https://github.com/tonioloewald/kilpi)**,
191
+ the same code extracted as a standalone library so it is not maintained in two
192
+ places. No API change: `sanitizeInPlace` and `isSafeNavigationUrl` are still
193
+ exported from this package, `editor.sanitize` still works the same way, and
194
+ behaviour is identical.
195
+
196
+ The reason it matters is not tidiness. When the sanitizer briefly existed
197
+ twice, a URL-normalization fix reached one copy and not the other — recorded
198
+ as M1 in the 0.4.4 review. Across two repositories that drift would not even
199
+ appear in a diff. kilpi carries DOMPurify's published 223-fixture corpus as a
200
+ hard publish gate, which this package could not run on its own.
201
+
202
+ `tosijs-kilpi` is a real dependency (this package's first — tosijs and
203
+ tosijs-ui remain peers), at `^1.0.0`. kilpi went 1.0.0 for that reason alone:
204
+ `^0.1.0` resolves to `>=0.1.0 <0.2.0`, so a 0.2.0 security fix would have
205
+ reached no installed consumer, and for a dependency that _is_ the XSS defence
206
+ a range that blocks propagation is a defect in itself. It is external in `dist/module.js`, so a consumer who
207
+ also depends on it directly gets one copy, and bundled into `dist/index.js`,
208
+ which assumes no installs.
209
+
10
210
  ## [0.4.4] - 2026-09-16
11
211
 
12
212
  First release since 0.4.3 to reach npm. 0.4.4 and 0.4.5 were versioned in the
@@ -31,8 +231,8 @@ they are collected here rather than reconstructed inaccurately.
31
231
  Ordinary formatting and unregistered custom elements are preserved.
32
232
  `editor.sanitize` is a swappable hook if you would rather supply your own
33
233
  (DOMPurify drops in; see the README).
34
- *This path was unreachable in 0.4.2–0.4.3 only because `insertionPoint()` was
35
- broken; fixing that is what made it live again.*
234
+ _This path was unreachable in 0.4.2–0.4.3 only because `insertionPoint()` was
235
+ broken; fixing that is what made it live again._
36
236
  - **Ctrl/Cmd-clicking a link checks the URL scheme** and always opens a new
37
237
  context. `javascript:` executes in the embedding page's origin and `noopener`
38
238
  does not prevent it; `_self`/`_top` are resolved before `noopener` is
@@ -77,7 +277,6 @@ they are collected here rather than reconstructed inaccurately.
77
277
  subscription.
78
278
  - The drop-in `dist/index.js` build is minified: 83.5kB → 70.8kB gzipped.
79
279
 
80
-
81
280
  ### Added
82
281
 
83
282
  - **Drag and drop editing.** Selected text is a real draggable object, offering
package/NOTICE ADDED
@@ -0,0 +1,16 @@
1
+ tosijs-styled-editor
2
+ Copyright 2026 Tonio Loewald
3
+
4
+ The drop-in build (dist/index.js) bundles the following Apache-2.0 works:
5
+
6
+ tosijs — Copyright 2026 Tonio Loewald
7
+ tosijs-ui — Copyright 2026 Tonio Loewald
8
+ tosijs-kilpi — Copyright 2026 Tonio Loewald
9
+ https://github.com/tonioloewald/kilpi
10
+
11
+ dist/module.js leaves all three external and bundles none of them.
12
+
13
+ tosijs-kilpi's verification corpus is vendored from DOMPurify
14
+ (Copyright (c) 2015 Mario Heiderich, MPL-2.0 OR Apache-2.0). That corpus is a
15
+ test fixture in kilpi's repository and is not distributed in kilpi's package or
16
+ in this one, so no DOMPurify material is redistributed here.
package/README.md CHANGED
@@ -63,7 +63,13 @@ Live site and docs: <https://editor.tosijs.net>
63
63
  npm install tosijs-styled-editor
64
64
  ```
65
65
 
66
- Peer dependencies: `tosijs`, `tosijs-ui`
66
+ **Peer dependencies** (you install these): `tosijs`, `tosijs-ui`
67
+
68
+ **Runtime dependency** (installed automatically):
69
+ [`tosijs-kilpi`](https://github.com/tonioloewald/kilpi) — the sanitizer applied
70
+ to pasted and dropped content. It has no dependencies of its own and is about
71
+ 0.8 kB gzipped. The drop-in `dist/index.js` build bundles it; the ESM build
72
+ leaves it external so you get one copy if you also depend on it directly.
67
73
 
68
74
  ## Usage
69
75
 
@@ -121,32 +127,39 @@ The caret is an `<input>` element, so mobile browsers show their keyboard automa
121
127
 
122
128
  ## Security: what is sanitized, and what is not
123
129
 
124
- Replacing `contentEditable` also means replacing the sanitization the browser
125
- was doing on your behalf. As of 0.4.4:
130
+ Replacing `contentEditable` also means replacing the sanitization the browser was
131
+ doing on your behalf.
126
132
 
127
133
  **Sanitized** — content arriving from outside the document, which is the path an
128
- attacker controls:
129
-
130
- - **paste** and **drop** (both go through one shared choke point)
131
- - inline event handlers (`onerror`, `onload`, …) are removed
132
- - `script`, `iframe`, `object`, `embed`, `link`, `meta`, `base`, `style`,
133
- `form` and the SVG animation elements are removed — in **any** namespace, so
134
- `<svg><script>` and `<svg><style>` are caught too
135
- - `href`/`src`/`xlink:href` are scheme-checked: `http(s)`, `mailto`, `tel` and
136
- relative URLs are kept, `javascript:` is dropped, and `data:` is allowed only
137
- for raster images (never for a link, never `data:image/svg+xml`)
138
- - ordinary formatting and **unregistered custom elements survive** — plugin
139
- markup is content, not a threat
134
+ attacker controls: **paste** and **drop**, both through one shared choke point.
135
+
136
+ The filtering itself is [`tosijs-kilpi`](https://github.com/tonioloewald/kilpi),
137
+ and **[its SECURITY.md is the authoritative policy](https://github.com/tonioloewald/kilpi/blob/main/SECURITY.md)** —
138
+ read it before relying on this. It is deliberately not restated here, because a
139
+ copy of a policy drifts from the policy. The one thing worth repeating, because
140
+ it is a trade rather than a detail:
141
+
142
+ > kilpi is a **denylist** for elements and attributes and an **allowlist** for URL
143
+ > schemes. That is why unknown elements survive — your plugin markup round-trips
144
+ > intact — and it is also why an element that becomes dangerous in a future
145
+ > browser, and that kilpi has never heard of, would pass through. If protection
146
+ > from the not-yet-known matters more to you than preserving unknown markup, use
147
+ > DOMPurify instead (see the hook below).
140
148
 
141
149
  **NOT sanitized** — content you supply, which is inside your own trust boundary:
142
150
 
143
151
  - `editor.value = html`
144
152
  - initial light-DOM content
145
153
 
154
+ **If you are upgrading from 0.4.3 or earlier:** documents your users created
155
+ before 0.4.4 may already contain a payload that was pasted in, and this component
156
+ cannot fix that for you — setting `value` does not filter. Sanitize your stored
157
+ corpus as part of the upgrade.
158
+
146
159
  **Using a different sanitizer.** `editor.sanitize` is the hook — it receives a
147
160
  detached element and mutates it:
148
161
 
149
- ```js
162
+ ```javascript
150
163
  editor.sanitize = (root) => {
151
164
  DOMPurify.sanitize(root, {
152
165
  IN_PLACE: true,
@@ -161,14 +174,268 @@ editor.sanitize = (root) => {
161
174
 
162
175
  It takes an element rather than an HTML string on purpose: a string signature
163
176
  would force a serialize-and-reparse round trip, and that round trip is where
164
- mutation XSS lives. Note the `CUSTOM_ELEMENT_HANDLING` block — DOMPurify
165
- unwraps unknown custom elements by default, which would discard plugin markup
166
- the built-in sanitizer preserves.
177
+ mutation XSS lives. Note the `CUSTOM_ELEMENT_HANDLING` block — DOMPurify unwraps
178
+ unknown custom elements by default, which would discard plugin markup kilpi
179
+ preserves.
180
+
181
+ **Reporting a vulnerability.** If it is in the sanitizer, file it against
182
+ [kilpi](https://github.com/tonioloewald/kilpi/issues) — that is where the code
183
+ lives. Anything else, [this repository](https://github.com/tonioloewald/tosijs-editor/issues).
184
+
185
+ ## Spell checking
186
+
187
+ This editor has **no browser spell checking at all**. Browsers only check editing
188
+ hosts — `textarea`, `input`, `contenteditable` — and nothing here is one. That is
189
+ a cost of replacing `contentEditable`, and worth knowing before you assume
190
+ squiggles will appear on their own.
191
+
192
+ In exchange you get the thing a `contentEditable` editor cannot have: an
193
+ application that can _ask_.
194
+
195
+ ```javascript
196
+ editor.spellChecker = (words) => new Set(words.filter((w) => !dictionary.has(w)))
197
+ await editor.checkSpelling()
198
+
199
+ editor.spellingErrors // [{ word, element }, …] in document order
200
+ editor.validity.customError // true — a real form submit is blocked
201
+ editor.clearSpelling() // remove every mark, change no text
202
+ ```
203
+
204
+ ### Resolution is the workflow
205
+
206
+ In a jargon-heavy domain — contracts, medicine, anything with terms of art — the
207
+ usual answer to an unknown word is _"that is a real word"_, not _"I mistyped"_.
208
+ So accepting has to be as cheap as correcting, and every flagged word has to end
209
+ up resolved one way or the other.
210
+
211
+ That is what the validity is for. **The field stays invalid until every flagged
212
+ word is corrected or accepted**, so a real form submit is blocked rather than
213
+ relying on anyone to remember to look.
214
+
215
+ Accepting has two scopes, because they have different lifetimes:
216
+
217
+ ```javascript
218
+ editor.acceptWord('indemnitor', 'document') // this document only
219
+ editor.acceptWord('lessor', 'dictionary') // everywhere, for this user
220
+ ```
221
+
222
+ - **`documentWords`** — a contract's defined terms and party names. Persist with
223
+ the document; it is part of the document's meaning, the same way a footnote is.
224
+ - **`userDictionary`** — a firm's terms of art. Persist with the user, not the
225
+ document.
226
+
227
+ Collapsing those into one list is what makes a spell checker unusable in these
228
+ domains: either every later document inherits one contract's party names, or the
229
+ user re-accepts the same terminology forever.
230
+
231
+ The editor persists neither — it does not know where either store lives. It tells
232
+ you what to write, and you decide where:
233
+
234
+ ```javascript
235
+ editor.handleWordAccepted = (word, scope) => {
236
+ if (scope === 'dictionary') saveToUserDictionary(word)
237
+ else saveWithDocument(word)
238
+ }
239
+ ```
240
+
241
+ Load them back by assigning the sets before checking.
242
+
243
+ Accepting jargon does not launder a real typo: with `indemnitor` and `lessor`
244
+ accepted, `borwn` stays flagged and the field stays invalid.
245
+
246
+ No dictionary ships with this component. Which words are real is a localization
247
+ question with a different answer per document, and a hunspell dictionary is
248
+ roughly forty times the size of the whole editor.
249
+
250
+ ### Wiring a real checker
251
+
252
+ The checker is `(distinctWords) => the subset that is wrong`, sync or async, so
253
+ any off-the-shelf engine fits behind it. With a hunspell-style library:
254
+
255
+ ```javascript
256
+ import nspell from 'nspell'
257
+
258
+ const spell = nspell(await loadAffix(), await loadDictionary())
259
+ editor.spellChecker = (words) => new Set(words.filter((w) => !spell.correct(w)))
260
+ ```
261
+
262
+ Or against a service, which is the case the batching exists for:
263
+
264
+ ```javascript
265
+ editor.spellChecker = async (words) => {
266
+ const res = await fetch('/api/spell', {
267
+ method: 'POST',
268
+ body: JSON.stringify(words),
269
+ })
270
+ return new Set(await res.json())
271
+ }
272
+ ```
273
+
274
+ The editor asks about **distinct** words, once per check — so a 10,000-word
275
+ document with a 2,000-word vocabulary is one call carrying 2,000 entries, not
276
+ 10,000 lookups and not one request per word.
277
+
278
+ ### What it does, and does not, do
279
+
280
+ Tokenization uses `Intl.Segmenter` word segmentation, so `don't` is one word and
281
+ `l'objet` is two — neither of which splitting on whitespace gives you. `code`,
282
+ `kbd`, `samp`, `pre` and any `spellcheck="false"` subtree are skipped.
283
+
284
+ Marks are **view state**: cleared on every check and stripped from `value`, so
285
+ they never reach the form value, an undo snapshot, or whatever you persist. A
286
+ document should not carry a record of which words some dictionary once disliked.
287
+
288
+ Not implemented, and worth knowing before you build UI on this:
289
+
290
+ - **no suggestions** — the checker reports _wrong_, not _did you mean_, and there
291
+ is no native right-click menu to inherit either
292
+ - **no incremental check** — `checkSpelling()` re-walks the whole document, which
293
+ is right for a button and wrong for check-as-you-type on a long document
294
+ - **persistence is yours** — the editor reports accepted words through
295
+ `handleWordAccepted` but stores nothing; reload the sets yourself
296
+
297
+ ## Tracked changes and LLM proofreading
298
+
299
+ Insertions and deletions are **content** — `<tosi-ins>` and `<tosi-del>` elements
300
+ carrying author and timestamp — not an operation log. A tracked document is still
301
+ a document: it serializes, round-trips, and can be read by something that has
302
+ never heard of this component.
303
+
304
+ ```javascript
305
+ editor.changeAuthor = { id: 'alex', name: 'Alex' }
306
+
307
+ await editor.reviseWith(async (text) => {
308
+ const res = await fetch('/api/proofread', { method: 'POST', body: text })
309
+ return (await res.json()).text
310
+ }, { id: 'gpt-x', name: 'Proofreader' })
311
+
312
+ editor.changes // [{ id, kind, author, authorName, time, text, element }]
313
+ editor.acceptChanges(id) // take this one
314
+ editor.rejectChanges(id) // keep the original
315
+ editor.acceptChanges() // all of them
316
+ ```
317
+
318
+ Both readings stay in the document until someone decides. A reviewer can put the
319
+ caret inside a proposed insertion and adjust it before accepting — the text
320
+ inside a change mark is ordinary editable content.
321
+
322
+ ### What crosses the boundary
323
+
324
+ **Out goes plain text**, one text node at a time. Formatting is deliberately not
325
+ sent: a model asked to preserve markup will sometimes not, and a reviewer should
326
+ be reviewing prose rather than diffing HTML. Marks _inside_ a block — a link, a
327
+ bold run — survive because each text node is revised in place. What the model
328
+ never sees, it cannot damage.
329
+
330
+ **Back comes text, and only text.** The response is inserted as text nodes inside
331
+ change marks and is never parsed as HTML, so a model that returns `<script>`
332
+ produces those literal characters, visible for review. That is a stronger
333
+ guarantee than sanitizing the response would be — there is no parse step to
334
+ attack — which is why this path does not go through `editor.sanitize`.
335
+
336
+ Text already under review is skipped, so a second pass cannot mark up the marks.
337
+
338
+ ### Why word-level
339
+
340
+ The diff is word-level because the unit has to be something a reviewer can
341
+ meaningfully accept or reject. A character diff turns `teh → the` into three
342
+ separate changes, and a rewritten sentence into confetti.
343
+
344
+ ### Styling is correctness here
345
+
346
+ `<tosi-ins>` and `<tosi-del>` are styled in the **core** stylesheet even though
347
+ the behaviour is a plugin. An unloaded footnote plugin is benign — you see a
348
+ stray marker. A `<tosi-del>` without its strikethrough renders deleted text as
349
+ ordinary prose, which reads as the opposite of what the document means.
350
+
351
+ ### Tracking live edits
352
+
353
+ ```javascript
354
+ editor.changeAuthor = { id: 'alex', name: 'Alex' }
355
+ editor.trackChanges = true
356
+ ```
357
+
358
+ Typing then lands inside a `<tosi-ins>`, and **every** deletion wraps in
359
+ `<tosi-del>` instead of removing — caret Backspace and Delete, a selection
360
+ delete, a cut, inside a list, inside a table cell. Nothing leaves the document
361
+ without a mark and an entry in `changes`, which is the only property that makes
362
+ `rejectChanges()` mean anything.
363
+
364
+ The whole mechanism is **one predicate**, re-evaluated only when the insertion
365
+ point might have moved: _is the caret already inside an insertion that is mine,
366
+ from this session?_ If yes, typing appends to it. If no, a new one opens. There
367
+ is no per-operation bookkeeping, because a continuous run of typing keeps the
368
+ predicate true and it stops being true exactly when it should — a click
369
+ elsewhere, an arrow key, a new line, a different author, a later session.
370
+
371
+ **Session, not just author.** Reopening a document and typing at the edge of your
372
+ own earlier tracked insertion opens a _new_ change. That edit happened at a
373
+ different time and a reviewer may want to treat it separately; without this they
374
+ would silently merge into one change bearing the older timestamp.
375
+
376
+ **Cut and paste are tracked too.** A cut wraps in `<tosi-del>` like any other
377
+ deletion. A paste is **one** change rather than one per word — you did not build
378
+ it a keystroke at a time, and a reviewer wants to accept or reject the paste, not
379
+ its individual words — so it gets its own mark even mid-typing-run. Pasted
380
+ content is still sanitized before it is marked.
381
+
382
+ Two deletions behave specially, because the pedantic version would be noise:
383
+
384
+ - text inside **your own current insertion** is really removed — you are
385
+ un-typing something you just typed, not proposing to delete your own proposal
386
+ - text already inside a `<tosi-del>` is left alone — it is deleted already
387
+
388
+ ### Not implemented
389
+
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.
405
+
406
+ A refusal fires a cancelable **`structural-edit-refused`** event carrying
407
+ `detail.reason`, so the key is not simply dead — show a note, or call
408
+ `preventDefault()` on it to allow the edit:
409
+
410
+ ```javascript
411
+ editor.addEventListener('structural-edit-refused', (e) => {
412
+ toast(`Not tracked yet: ${e.detail.reason}. Turn off tracking to do this.`)
413
+ })
414
+ ```
415
+
416
+ `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.
422
+
423
+ - **no merge story.** Changes-as-content gets attribution, review and round-trip,
424
+ but not collaborative merge. That needs an operation log, which is a larger
425
+ decision — see
426
+ [EXTENSIBILITY.md](https://github.com/tonioloewald/tosijs-editor/blob/master/EXTENSIBILITY.md)
427
+ (a repo document; it is not in the npm tarball).
428
+
429
+ ### Resolved changes leave nothing behind
430
+
431
+ Accepting or rejecting a change **evaporates the mark entirely** — no wrapper, no
432
+ `data-change`, no attribution residue, and the text is re-normalized rather than
433
+ left fragmented. A document does not accumulate its own history.
167
434
 
168
- **If you are upgrading from 0.4.3 or earlier, read this:** documents your users
169
- created before 0.4.4 may already contain a payload that was pasted in, and the
170
- component cannot fix that for you — setting `value` does not filter. Sanitize
171
- your stored corpus as part of the upgrade.
435
+ That is deliberate. Undo, and whatever version store the document lives in,
436
+ already record past states; a document that carries every resolved edit becomes
437
+ unreadable, larger than its content, and awkward to share with anyone who was not
438
+ part of the review.
172
439
 
173
440
  ## Keyboard Behavior
174
441
 
@@ -193,7 +460,18 @@ your stored corpus as part of the upgrade.
193
460
  | **Shift+Click** | Extends selection to click position |
194
461
  | **Double-click** | Selects word |
195
462
  | **Triple-click** | Selects block |
196
- | **Click-drag** | Selects character range |
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.
197
475
 
198
476
  ### Inside a table cell
199
477
 
@@ -432,10 +710,43 @@ Custom widgets you add follow the same rules and get translated too.
432
710
  | `widgets` | `'none' \| 'minimal' \| 'default'` | Attribute — built-in toolbar preset |
433
711
  | `localized` | `boolean` | Attribute — translate the built-in widgets and show a language picker |
434
712
 
435
- | Method | Description |
436
- | ---------------- | ------------------------ |
437
- | `doCommand(str)` | Execute a command string |
438
- | `focus()` | Focus the caret |
713
+ ### Change tracking
714
+
715
+ | Member | Type | Description |
716
+ | ----------------------------- | ------------------- | ------------------------------------------------------------------------- |
717
+ | `trackChanges` | `boolean` | Record live edits as tracked changes |
718
+ | `changeAuthor` | `{ id, name? }` | Who the next change is attributed to |
719
+ | `sessionId` | `string` (readonly) | Distinguishes this editing session from an earlier one by the same author |
720
+ | `changes` | `TrackedChange[]` | Every change in the document, in document order |
721
+ | `acceptChanges(id?)` | `void` | Accept one change, or all of them with no argument |
722
+ | `rejectChanges(id?)` | `void` | Reject one change, or all of them with no argument |
723
+ | `reviseWith(revise, author?)` | `Promise<number>` | Round-trip the prose through a proofreader; returns changes introduced |
724
+
725
+ ### Spell checking
726
+
727
+ | Member | Type | Description |
728
+ | ---------------------------------- | -------------------------------- | -------------------------------------------------------- |
729
+ | `spellChecker` | `(words) => Set \| Promise<Set>` | Supply a checker; without one, nothing is checked |
730
+ | `checkSpelling()` | `Promise<SpellingError[]>` | Check now and mark what comes back wrong |
731
+ | `spellingErrors` | `SpellingError[]` | The current errors — the query browsers refuse to answer |
732
+ | `acceptWord(word, scope?)` | `void` | `'document'` (default) or `'dictionary'` |
733
+ | `documentWords` / `userDictionary` | `Set<string>` | The two accepted-word scopes, for persisting |
734
+ | `handleWordAccepted` | `(word, scope) => void` | Called when a word is accepted, so the host can persist |
735
+ | `clearSpelling()` | `void` | Drop every mark without changing the text |
736
+
737
+ ### Other
738
+
739
+ | Member | Description |
740
+ | ---------------- | --------------------------------------------------------------- |
741
+ | `doCommand(str)` | Execute a command string |
742
+ | `focus()` | Focus the caret |
743
+ | `sanitize` | `(root: Element) => void` applied to pasted and dropped content |
744
+
745
+ Also exported from the package: `stickySelectionBounds`, `sanitizeInPlace` and
746
+ `isSafeNavigationUrl` (re-exported from
747
+ [tosijs-kilpi](https://www.npmjs.com/package/tosijs-kilpi)), `changeId`,
748
+ `diffWords`, `acceptChange`/`rejectChange`, `checkSpelling`, `wordsIn`,
749
+ `renumberFootnotes`, and the element classes behind the four content tags.
439
750
 
440
751
  ## License
441
752