tosijs-styled-editor 0.4.5 → 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 +168 -5
- package/README.md +304 -5
- package/dist/changes.d.ts +126 -0
- package/dist/commands.d.ts +45 -0
- package/dist/dom-utils.d.ts +10 -0
- package/dist/footnote.d.ts +36 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -7
- package/dist/module.js +937 -77
- package/dist/selection.d.ts +94 -0
- package/dist/spelling.d.ts +102 -0
- package/dist/toolbar.d.ts +13 -1
- package/dist/tosijs-styled-editor.d.ts +285 -1
- package/dist/version.d.ts +1 -1
- package/package.json +6 -4
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,171 @@ 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
|
+
|
|
10
175
|
## [0.4.5] - 2026-09-17
|
|
11
176
|
|
|
12
177
|
### Added
|
|
@@ -37,12 +202,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
37
202
|
`tosijs-kilpi` is a real dependency (this package's first — tosijs and
|
|
38
203
|
tosijs-ui remain peers), at `^1.0.0`. kilpi went 1.0.0 for that reason alone:
|
|
39
204
|
`^0.1.0` resolves to `>=0.1.0 <0.2.0`, so a 0.2.0 security fix would have
|
|
40
|
-
reached no installed consumer, and for a dependency that
|
|
205
|
+
reached no installed consumer, and for a dependency that _is_ the XSS defence
|
|
41
206
|
a range that blocks propagation is a defect in itself. It is external in `dist/module.js`, so a consumer who
|
|
42
207
|
also depends on it directly gets one copy, and bundled into `dist/index.js`,
|
|
43
208
|
which assumes no installs.
|
|
44
209
|
|
|
45
|
-
|
|
46
210
|
## [0.4.4] - 2026-09-16
|
|
47
211
|
|
|
48
212
|
First release since 0.4.3 to reach npm. 0.4.4 and 0.4.5 were versioned in the
|
|
@@ -67,8 +231,8 @@ they are collected here rather than reconstructed inaccurately.
|
|
|
67
231
|
Ordinary formatting and unregistered custom elements are preserved.
|
|
68
232
|
`editor.sanitize` is a swappable hook if you would rather supply your own
|
|
69
233
|
(DOMPurify drops in; see the README).
|
|
70
|
-
|
|
71
|
-
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._
|
|
72
236
|
- **Ctrl/Cmd-clicking a link checks the URL scheme** and always opens a new
|
|
73
237
|
context. `javascript:` executes in the embedding page's origin and `noopener`
|
|
74
238
|
does not prevent it; `_self`/`_top` are resolved before `noopener` is
|
|
@@ -113,7 +277,6 @@ they are collected here rather than reconstructed inaccurately.
|
|
|
113
277
|
subscription.
|
|
114
278
|
- The drop-in `dist/index.js` build is minified: 83.5kB → 70.8kB gzipped.
|
|
115
279
|
|
|
116
|
-
|
|
117
280
|
### Added
|
|
118
281
|
|
|
119
282
|
- **Drag and drop editing.** Selected text is a real draggable object, offering
|
package/README.md
CHANGED
|
@@ -182,6 +182,261 @@ preserves.
|
|
|
182
182
|
[kilpi](https://github.com/tonioloewald/kilpi/issues) — that is where the code
|
|
183
183
|
lives. Anything else, [this repository](https://github.com/tonioloewald/tosijs-editor/issues).
|
|
184
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.
|
|
434
|
+
|
|
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.
|
|
439
|
+
|
|
185
440
|
## Keyboard Behavior
|
|
186
441
|
|
|
187
442
|
### General editing
|
|
@@ -205,7 +460,18 @@ lives. Anything else, [this repository](https://github.com/tonioloewald/tosijs-e
|
|
|
205
460
|
| **Shift+Click** | Extends selection to click position |
|
|
206
461
|
| **Double-click** | Selects word |
|
|
207
462
|
| **Triple-click** | Selects block |
|
|
208
|
-
| **Click-drag** | Selects
|
|
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.
|
|
209
475
|
|
|
210
476
|
### Inside a table cell
|
|
211
477
|
|
|
@@ -444,10 +710,43 @@ Custom widgets you add follow the same rules and get translated too.
|
|
|
444
710
|
| `widgets` | `'none' \| 'minimal' \| 'default'` | Attribute — built-in toolbar preset |
|
|
445
711
|
| `localized` | `boolean` | Attribute — translate the built-in widgets and show a language picker |
|
|
446
712
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
|
450
|
-
|
|
|
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.
|
|
451
750
|
|
|
452
751
|
## License
|
|
453
752
|
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tracked changes — insertions and deletions as CONTENT, with attribution.
|
|
3
|
+
*
|
|
4
|
+
* The alternative was an operation log alongside the undo snapshots.
|
|
5
|
+
* EXTENSIBILITY.md weighs both: an operation log is the only thing that gets you
|
|
6
|
+
* a merge story, and it is the most expensive decision in this codebase.
|
|
7
|
+
* Changes-as-content gets everything except merge, for a fraction of the work,
|
|
8
|
+
* and it fits the web-component substrate that footnotes and spelling already
|
|
9
|
+
* proved.
|
|
10
|
+
*
|
|
11
|
+
* WHAT THIS BUYS THAT AN OPERATION LOG DOES NOT: a tracked document is still a
|
|
12
|
+
* document. It serializes, round-trips, pastes into another editor, and can be
|
|
13
|
+
* read by something that has never heard of this component.
|
|
14
|
+
*
|
|
15
|
+
* ONE ASYMMETRY TO KNOW ABOUT. A footnote plugin that fails to load is benign —
|
|
16
|
+
* you see a stray marker. A `<tosi-del>` that fails to load renders deleted text
|
|
17
|
+
* as ordinary prose, which reads as the opposite of what the document means. So
|
|
18
|
+
* the strikethrough is load-bearing for CORRECTNESS, not decoration, and those
|
|
19
|
+
* styles belong in the core stylesheet even though the behaviour is a plugin.
|
|
20
|
+
*/
|
|
21
|
+
/** Who made a change, and when. */
|
|
22
|
+
export interface ChangeAuthor {
|
|
23
|
+
/** Stable id — a user id, or a model name for an LLM pass. */
|
|
24
|
+
id: string;
|
|
25
|
+
/** What to show a reviewer. */
|
|
26
|
+
name?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface TrackedChange {
|
|
29
|
+
id: string;
|
|
30
|
+
kind: 'insert' | 'delete';
|
|
31
|
+
author: string;
|
|
32
|
+
authorName: string;
|
|
33
|
+
time: string;
|
|
34
|
+
text: string;
|
|
35
|
+
element: HTMLElement;
|
|
36
|
+
}
|
|
37
|
+
export declare const INS_TAG = "tosi-ins";
|
|
38
|
+
export declare const DEL_TAG = "tosi-del";
|
|
39
|
+
/**
|
|
40
|
+
* A new change id.
|
|
41
|
+
*
|
|
42
|
+
* The sequence counter is not decoration. `Date.now()` alone collides whenever
|
|
43
|
+
* two marks are produced in one synchronous handler, which is the NORMAL case:
|
|
44
|
+
* typing over a selection and pasting over one both delete and then insert
|
|
45
|
+
* inside a single keydown. Measured at 29% collision without the counter — and
|
|
46
|
+
* a collision means `acceptChanges(deletionId)` silently also accepts the
|
|
47
|
+
* replacement insertion, so a reviewer cannot accept a deletion and reject
|
|
48
|
+
* what replaced it. Exported so there is ONE producer; there used to be three,
|
|
49
|
+
* and only this one had the guard.
|
|
50
|
+
*/
|
|
51
|
+
export declare const changeId: () => string;
|
|
52
|
+
/**
|
|
53
|
+
* Insertion and deletion marks.
|
|
54
|
+
*
|
|
55
|
+
* Both are CONTAINERS — the text inside stays ordinary editable text, because a
|
|
56
|
+
* reviewer needs to be able to put the caret in a proposed sentence and adjust
|
|
57
|
+
* it before accepting. They hold no state beyond their attributes: what a change
|
|
58
|
+
* is, and who made it, is written in the document, not remembered by an
|
|
59
|
+
* instance that would not survive a round trip.
|
|
60
|
+
*/
|
|
61
|
+
export declare class TosiIns extends HTMLElement {
|
|
62
|
+
}
|
|
63
|
+
export declare class TosiDel extends HTMLElement {
|
|
64
|
+
}
|
|
65
|
+
export declare function defineChanges(): void;
|
|
66
|
+
/** Every tracked change in a root, in document order. */
|
|
67
|
+
export declare function changesIn(root: Element): TrackedChange[];
|
|
68
|
+
/**
|
|
69
|
+
* Accept a change: the insertion becomes ordinary text, the deletion goes.
|
|
70
|
+
*
|
|
71
|
+
* Accept and reject are deliberately symmetric and deliberately *dumb* — each
|
|
72
|
+
* one resolves exactly one mark. Anything cleverer (accept all by this author,
|
|
73
|
+
* accept this paragraph) is a filter over `changesIn` plus a loop, which is the
|
|
74
|
+
* caller's policy rather than ours.
|
|
75
|
+
*/
|
|
76
|
+
export declare function acceptChange(el: Element): void;
|
|
77
|
+
/** Reject a change: the insertion goes, the deletion becomes ordinary text. */
|
|
78
|
+
export declare function rejectChange(el: Element): void;
|
|
79
|
+
/**
|
|
80
|
+
* A word-level diff.
|
|
81
|
+
*
|
|
82
|
+
* Word-level, not character-level, because the unit has to be something a
|
|
83
|
+
* reviewer can meaningfully accept or reject. A character diff turns
|
|
84
|
+
* `teh -> the` into three separate changes and a rewritten sentence into
|
|
85
|
+
* confetti.
|
|
86
|
+
*
|
|
87
|
+
* Longest-common-subsequence over word tokens. Whitespace rides along with the
|
|
88
|
+
* word that follows it so that rejoining is lossless — the output of a diff with
|
|
89
|
+
* no changes is byte-identical to its input.
|
|
90
|
+
*/
|
|
91
|
+
export declare function tokenize(text: string): string[];
|
|
92
|
+
export type DiffOp = {
|
|
93
|
+
op: 'same';
|
|
94
|
+
text: string;
|
|
95
|
+
} | {
|
|
96
|
+
op: 'insert';
|
|
97
|
+
text: string;
|
|
98
|
+
} | {
|
|
99
|
+
op: 'delete';
|
|
100
|
+
text: string;
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* Above this many tokens on either side, fall back to replace-the-whole-thing.
|
|
104
|
+
*
|
|
105
|
+
* The LCS table is (n+1)x(m+1) numbers, and one side of this diff is a REMOTE
|
|
106
|
+
* RESPONSE — whatever the proofreader returned. Measured: 8k tokens is 429 ms
|
|
107
|
+
* and +366 MB; 16k (a 78 kB text node) is 1.7 s and +1.2 GB, synchronously on
|
|
108
|
+
* the main thread. The asymmetric case is worse and cheaper to trigger: a
|
|
109
|
+
* 200-word paragraph against a 200k-word response is +226 MB PER TEXT NODE.
|
|
110
|
+
* 4000 tokens is a very long paragraph and costs about 128 MB worst case.
|
|
111
|
+
*
|
|
112
|
+
* Past the cap the change is still correct, just coarser: one deletion and one
|
|
113
|
+
* insertion rather than a word-level diff. Degrading the review experience
|
|
114
|
+
* beats freezing the tab.
|
|
115
|
+
*/
|
|
116
|
+
export declare const MAX_DIFF_TOKENS = 4000;
|
|
117
|
+
export declare function diffWords(before: string, after: string): DiffOp[];
|
|
118
|
+
/**
|
|
119
|
+
* Replace a text node's contents with the tracked result of revising it.
|
|
120
|
+
*
|
|
121
|
+
* Returns the number of changes introduced. Zero means the revision was
|
|
122
|
+
* identical and the document was not touched at all — which matters, because a
|
|
123
|
+
* proofreading pass that changes nothing should not dirty the document or
|
|
124
|
+
* produce an undo step.
|
|
125
|
+
*/
|
|
126
|
+
export declare function applyRevision(node: Text, revised: string, author: ChangeAuthor): number;
|