tosijs-styled-editor 0.4.3 → 0.4.5
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 +311 -0
- package/NOTICE +16 -0
- package/README.md +74 -7
- package/SECURITY.md +32 -0
- package/dist/commands.d.ts +11 -1
- package/dist/dom-utils.d.ts +13 -0
- package/dist/index.js +5 -5
- package/dist/module.js +78 -34
- package/dist/tosijs-styled-editor.d.ts +58 -2
- package/dist/version.d.ts +1 -1
- package/package.json +9 -2
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.4.5] - 2026-09-17
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`SECURITY.md`**, with the one thing it needs to say: a sanitizer bypass
|
|
15
|
+
belongs to [`tosijs-kilpi`](https://github.com/tonioloewald/kilpi/issues),
|
|
16
|
+
because that is where the code lives. The README's security section now points
|
|
17
|
+
at kilpi's policy as authoritative rather than restating it — a copy of a
|
|
18
|
+
policy drifts from the policy, which is the same failure the extraction
|
|
19
|
+
removed from the code.
|
|
20
|
+
- **`NOTICE`**, for the three Apache-2.0 works the drop-in `dist/index.js`
|
|
21
|
+
bundles.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- **Sanitization moved to [`tosijs-kilpi`](https://github.com/tonioloewald/kilpi)**,
|
|
26
|
+
the same code extracted as a standalone library so it is not maintained in two
|
|
27
|
+
places. No API change: `sanitizeInPlace` and `isSafeNavigationUrl` are still
|
|
28
|
+
exported from this package, `editor.sanitize` still works the same way, and
|
|
29
|
+
behaviour is identical.
|
|
30
|
+
|
|
31
|
+
The reason it matters is not tidiness. When the sanitizer briefly existed
|
|
32
|
+
twice, a URL-normalization fix reached one copy and not the other — recorded
|
|
33
|
+
as M1 in the 0.4.4 review. Across two repositories that drift would not even
|
|
34
|
+
appear in a diff. kilpi carries DOMPurify's published 223-fixture corpus as a
|
|
35
|
+
hard publish gate, which this package could not run on its own.
|
|
36
|
+
|
|
37
|
+
`tosijs-kilpi` is a real dependency (this package's first — tosijs and
|
|
38
|
+
tosijs-ui remain peers), at `^1.0.0`. kilpi went 1.0.0 for that reason alone:
|
|
39
|
+
`^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 *is* the XSS defence
|
|
41
|
+
a range that blocks propagation is a defect in itself. It is external in `dist/module.js`, so a consumer who
|
|
42
|
+
also depends on it directly gets one copy, and bundled into `dist/index.js`,
|
|
43
|
+
which assumes no installs.
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
## [0.4.4] - 2026-09-16
|
|
47
|
+
|
|
48
|
+
First release since 0.4.3 to reach npm. 0.4.4 and 0.4.5 were versioned in the
|
|
49
|
+
repo during development and **never published**, so this is numbered 0.4.4:
|
|
50
|
+
semver describes what consumers observe between releases, and consumers
|
|
51
|
+
observed none of it.
|
|
52
|
+
|
|
53
|
+
Everything below through the 0.2.0 heading shipped across 0.4.2–0.4.4. The
|
|
54
|
+
earlier 0.4.x releases went out without changelog sections of their own, so
|
|
55
|
+
they are collected here rather than reconstructed inaccurately.
|
|
56
|
+
|
|
57
|
+
### Security
|
|
58
|
+
|
|
59
|
+
- **Pasted and dropped HTML is now sanitized** before it enters the document.
|
|
60
|
+
The editor replaced `contentEditable` but not the sanitization the browser
|
|
61
|
+
was doing on its behalf: clipboard and drop HTML went in through `innerHTML`
|
|
62
|
+
verbatim, so `<img onerror>`, `<svg onload>`, `javascript:` URLs and
|
|
63
|
+
`<script>` reached the live document — and from there `value`,
|
|
64
|
+
`internals.setFormValue` and every undo snapshot, meaning a host storing
|
|
65
|
+
`value` stored the payload. Handlers, executing elements and unsafe URL
|
|
66
|
+
schemes are now stripped at the single shared paste/drop choke point.
|
|
67
|
+
Ordinary formatting and unregistered custom elements are preserved.
|
|
68
|
+
`editor.sanitize` is a swappable hook if you would rather supply your own
|
|
69
|
+
(DOMPurify drops in; see the README).
|
|
70
|
+
*This path was unreachable in 0.4.2–0.4.3 only because `insertionPoint()` was
|
|
71
|
+
broken; fixing that is what made it live again.*
|
|
72
|
+
- **Ctrl/Cmd-clicking a link checks the URL scheme** and always opens a new
|
|
73
|
+
context. `javascript:` executes in the embedding page's origin and `noopener`
|
|
74
|
+
does not prevent it; `_self`/`_top` are resolved before `noopener` is
|
|
75
|
+
consulted, so a document-supplied `target` could run it same-origin.
|
|
76
|
+
`setLink` validates the scheme too.
|
|
77
|
+
- **Commands built from runtime values no longer go through the string form.**
|
|
78
|
+
`executeCommand` splits on `;`, and a data URI contains `;` by spec — so
|
|
79
|
+
every dropped image produced `<img src="data:image/png">` plus a bogus second
|
|
80
|
+
command, and a crafted filename could inject one. `doCommandWith(name, ...args)`
|
|
81
|
+
passes arguments without parsing.
|
|
82
|
+
|
|
83
|
+
### Fixed
|
|
84
|
+
|
|
85
|
+
- **Mobile browsers no longer zoom when you select text.** Two independent
|
|
86
|
+
causes: the caret is a real `<input>` (that is what raises the mobile
|
|
87
|
+
keyboard) and inherited the UA default form-control font size of 11px, and
|
|
88
|
+
iOS Safari zooms the page on focus below 16px; and double-tap selects a word
|
|
89
|
+
here, which a touch browser reads as zoom. Fixed by sizing the caret at 16px
|
|
90
|
+
and setting `touch-action: manipulation` on the document, which keeps panning
|
|
91
|
+
and pinch-zoom. Deliberately NOT fixed with `user-scalable=no`, which would
|
|
92
|
+
fail WCAG 1.4.4.
|
|
93
|
+
- **The caret no longer strands itself when the document scrolls.** The overlay
|
|
94
|
+
is positioned in viewport coordinates but only repainted when the selection
|
|
95
|
+
bounds changed, so scrolling left it where the text used to be. Scroll, window
|
|
96
|
+
resize and a `ResizeObserver` on the document now repaint it; measured drift is
|
|
97
|
+
0 at every scroll offset, and it hides correctly once its line scrolls out of
|
|
98
|
+
view.
|
|
99
|
+
- **Commands that insert at the caret worked again.** `insertionPoint()` selected
|
|
100
|
+
`input.caret`, which has matched nothing since the bound markers stopped being
|
|
101
|
+
`<input>` elements — so `insertFootnote`, `insertTable`, `insertImage` and
|
|
102
|
+
`setLink` all bailed out silently. Its unit test passed throughout because it
|
|
103
|
+
built the `<input>` itself and asserted the selector found it; the test now
|
|
104
|
+
uses the real `createBounds()`.
|
|
105
|
+
|
|
106
|
+
### Changed
|
|
107
|
+
|
|
108
|
+
- Both `setTimeout` calls replaced with the signals they were approximating: a
|
|
109
|
+
`ResizeObserver` tracks the touch-affordance padding transition continuously
|
|
110
|
+
instead of waiting a guessed 160ms (and no longer leaves the affordances
|
|
111
|
+
invisible if the transition never runs), and the touch menu's dismiss handler
|
|
112
|
+
ignores the event that opened it by identity instead of deferring its own
|
|
113
|
+
subscription.
|
|
114
|
+
- The drop-in `dist/index.js` build is minified: 83.5kB → 70.8kB gzipped.
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
### Added
|
|
118
|
+
|
|
119
|
+
- **Drag and drop editing.** Selected text is a real draggable object, offering
|
|
120
|
+
`text/html` and `text/plain` so the receiver picks — which means dragging works
|
|
121
|
+
between windows, between browsers, and to and from the desktop. Move within
|
|
122
|
+
the editor, Alt to copy, and leaving the editor is always a copy. Dropped image
|
|
123
|
+
files come in as data URIs; dropped HTML runs through the same `pastemode`
|
|
124
|
+
path as a paste.
|
|
125
|
+
|
|
126
|
+
- **Links**: `setLink <url> [target]` and `removeLink`. Defaults to
|
|
127
|
+
`target="_blank"` with `rel="noopener"`. Clicking a link in the editor places
|
|
128
|
+
the caret; Ctrl/Cmd-click follows it.
|
|
129
|
+
- **Images**: `insertImage <url> [alt…]`. An `<img>` is already a leaf node, so
|
|
130
|
+
selection and deletion treat it as one thing with no special casing.
|
|
131
|
+
- **Footnotes**: `insertFootnote [text…]` and `renumberFootnotes`. Numbers are
|
|
132
|
+
derived from document order rather than stored, so inserting in the middle
|
|
133
|
+
renumbers the rest and reorders the list. Note that renumbering happens at
|
|
134
|
+
INSERTION time only — deleting a reference with Backspace currently leaves its
|
|
135
|
+
entry orphaned in the list and does not renumber the survivors. Tracked in
|
|
136
|
+
`TODO.md`; `EXTENSIBILITY.md` covers why the fix is a lifecycle change rather
|
|
137
|
+
than another call to `renumberFootnotes`.
|
|
138
|
+
- An **Insert** menu carrying all three.
|
|
139
|
+
|
|
140
|
+
- **Localization**, following tosijs-ui's conventions rather than a private
|
|
141
|
+
scheme: `localized` on the element translates the built-in widgets and adds a
|
|
142
|
+
flag-only language picker. Toolbar buttons carry `data-tosi-localized` (a JSON
|
|
143
|
+
attribute-to-key map, re-applied on locale change), menus set `localized`, and
|
|
144
|
+
menu labels are `<tosi-localized>`, so custom widgets get the same treatment.
|
|
145
|
+
- `localized-strings.tsv` — a sample table in English and Suomi covering all 51
|
|
146
|
+
UI strings. Adding a language is adding a column. Column 0 is both the lookup
|
|
147
|
+
key and the English text, so missing cells fall back to English and a
|
|
148
|
+
half-translated column is safe to ship.
|
|
149
|
+
- README is pinned to the top of the doc-site nav.
|
|
150
|
+
- A Right-to-Left doc page with live examples: RTL blocks in Arabic and Hebrew,
|
|
151
|
+
LTR-with-embedded-RTL, and RTL blocks with embedded LTR runs (inline code,
|
|
152
|
+
URLs, version numbers) — the cases where visual and logical order disagree and
|
|
153
|
+
a DOM-only selection has to earn its keep.
|
|
154
|
+
- Menu dropdowns are compacted (30px rows, 16px horizontal padding) and scoped
|
|
155
|
+
to this editor's own menus.
|
|
156
|
+
|
|
157
|
+
- `setList ul | ol | none` — bulleted and numbered list formatting, exposed as
|
|
158
|
+
toolbar buttons and Style menu entries. Adjacent selected blocks become one
|
|
159
|
+
list, a converted block merges into an adjacent list of the same type (so
|
|
160
|
+
`<ol>` numbering continues), and re-applying the current type toggles it off.
|
|
161
|
+
- Live behaviour tests that run in a real browser, covering the click
|
|
162
|
+
positioning that happy-dom cannot see.
|
|
163
|
+
- `llms.txt`, a sitemap, and prerendered doc pages, generated by the doc system.
|
|
164
|
+
- `.haltija.json` pinning agent browser commands to this project's dev origin.
|
|
165
|
+
|
|
166
|
+
### Fixed
|
|
167
|
+
|
|
168
|
+
- **The caret no longer reshapes the text it sits in.** It was an `<input>`
|
|
169
|
+
between characters, and a replaced element breaks the shaping run: measured on
|
|
170
|
+
Arabic, a neighbouring glyph's advance moved 6.2 → 6.7 even though the caret's
|
|
171
|
+
box was already width-neutral, and absolute positioning did not rescue it. The
|
|
172
|
+
in-text anchors are now plain spans — bit-identical to no markup at all — and
|
|
173
|
+
the focusable caret is positioned over the text instead of inside it. Placing
|
|
174
|
+
the caret in an Arabic paragraph now leaves its width unchanged at 565.7px.
|
|
175
|
+
A collapsed selection shows an ordinary caret; an expanded one keeps its two
|
|
176
|
+
edges distinguishable.
|
|
177
|
+
|
|
178
|
+
- **Arabic text jittered when you moused over it.** Not a shaping problem, as it
|
|
179
|
+
appeared: spanification preserves shaping, ligatures and per-glyph positions
|
|
180
|
+
exactly (measured). The cause was wrapping each SPACE in its own span, which
|
|
181
|
+
changes which spaces CSS collapses — the same number collapse, but not the
|
|
182
|
+
same ones, so words merge and gaps open mid-word. Whitespace now stays a text
|
|
183
|
+
node, which reproduces the original layout space for space.
|
|
184
|
+
|
|
185
|
+
- **Live examples now fill their preview instead of taking a fixed height.**
|
|
186
|
+
`tosi-example` is `height: var(--tosi-example-height)` (320px) and becomes
|
|
187
|
+
`100vh` when maximized, so the editor's hardcoded 340px both overflowed the
|
|
188
|
+
normal case — the EXAMPLE scrolled rather than the document — and ignored the
|
|
189
|
+
space when maximized. The sizing and the preview's padding reset now live in
|
|
190
|
+
the site config's `headExtra`, so every doc page gets them, including the RTL
|
|
191
|
+
page which had no CSS block of its own.
|
|
192
|
+
|
|
193
|
+
- **The selection was unreadable in dark mode.** `.selected` was a hardcoded
|
|
194
|
+
`rgba(0,0,255,0.3)` and `.selected-block` a hardcoded `#ddf` — a pale blue
|
|
195
|
+
that light text disappears into — and the caret/bounds were `background: black`,
|
|
196
|
+
invisible on a dark page. All three now derive from `--editor-ink` mixed into
|
|
197
|
+
`--editor-surface` (and `currentColor` for the bounds), so they tint whichever
|
|
198
|
+
way the page is themed. The band is lifted toward white BEFORE the ink is
|
|
199
|
+
mixed in — nearly a no-op on an already-white page, but it raises a dark one
|
|
200
|
+
clear of the background, which a plain ink-into-surface mix cannot do because
|
|
201
|
+
the surface dominates. Dark is deliberately given MORE measured separation
|
|
202
|
+
than light, because light-on-dark halates and reads as less contrast at the
|
|
203
|
+
same numbers. Measured light 0.75 against a 0.99 page (separation 0.24), dark
|
|
204
|
+
0.44 against 0.11 (separation 0.33).
|
|
205
|
+
|
|
206
|
+
- **Emoji were torn in half.** `spanify` split text with `split('')`, which
|
|
207
|
+
splits by UTF-16 code UNIT, so an emoji's surrogate pair became two lone
|
|
208
|
+
surrogates rendering as `?`. Splitting is now by grapheme cluster via
|
|
209
|
+
`Intl.Segmenter`, which also keeps flags (two regional indicators), skin-tone
|
|
210
|
+
modifiers, ZWJ sequences and combining marks whole — every one of those is one
|
|
211
|
+
thing a user clicks on or deletes.
|
|
212
|
+
- **Dark mode: the document was black text on a near-black page.** The surface
|
|
213
|
+
followed the page theme but `color` was the system `CanvasText`, which does
|
|
214
|
+
not. Text and surface are now a paired `--editor-text` / `--editor-surface`,
|
|
215
|
+
and a consumer that themes one must theme both.
|
|
216
|
+
- Added **Cut** to the touch selection menu, which had Copy and Paste but no Cut.
|
|
217
|
+
|
|
218
|
+
- **The caret sat on the wrong side of the line when typing LTR into an RTL
|
|
219
|
+
block** (and vice versa). The caret is an element, and bidi treats an empty
|
|
220
|
+
inline as a NEUTRAL, so it resolved against the block's base direction instead
|
|
221
|
+
of the run being typed. Typing across a direction boundary now wraps the run
|
|
222
|
+
and the caret in a `<span dir>` isolate, extending one isolate rather than
|
|
223
|
+
creating one per keystroke. Neutral characters take whichever run they land in.
|
|
224
|
+
|
|
225
|
+
- **Typing over a selection deleted it and inserted nothing.** A regression from
|
|
226
|
+
the double-click fix: `resetBounds()` derives bounds from `.selected`, which
|
|
227
|
+
lands the caret INSIDE the last selected character, and `deleteSelection()`
|
|
228
|
+
then removed the caret along with the selection — leaving no insertion point,
|
|
229
|
+
so every keystroke was silently swallowed. The caret is now moved out of the
|
|
230
|
+
way before the selected chains are deleted.
|
|
231
|
+
|
|
232
|
+
- Left-to-right runs inside right-to-left paragraphs — `<code>`, `<kbd>`,
|
|
233
|
+
`<samp>` — inherited the paragraph's base direction, so a URL's slashes or a
|
|
234
|
+
trailing period resolved to the wrong end. They now get their own
|
|
235
|
+
`direction: ltr; unicode-bidi: isolate`, which the new RTL page surfaced.
|
|
236
|
+
|
|
237
|
+
- Double-clicking left the caret blinking where the click landed instead of at
|
|
238
|
+
the end of the selected word: word and block gestures expanded the marked
|
|
239
|
+
range without moving the `.sel-start`/`.sel-end` elements.
|
|
240
|
+
- Clicking in the dead space to the right of a line did nothing, and
|
|
241
|
+
double-clicking there selected a word around stale bounds. Click position now
|
|
242
|
+
resolves to the nearest character on the clicked line.
|
|
243
|
+
- `editable.commands` was never consulted — `executeCommand` resolved names
|
|
244
|
+
against the module-level registry, so the documented way to add a custom
|
|
245
|
+
command had no effect. The registry now travels on `EditableContext`.
|
|
246
|
+
- README and the component doc comment advertised `<tosi-editable>`, which was
|
|
247
|
+
never the registered tag. It is `<tosijs-styled-editor>`.
|
|
248
|
+
|
|
249
|
+
### Changed
|
|
250
|
+
|
|
251
|
+
- The Highlight button now uses Lucide's `highlighter` icon, registered through
|
|
252
|
+
`defineIcons`. The previous `penTool` read as a fountain pen — a different
|
|
253
|
+
tool. Stored following tosijs-ui's own convention: no `xmlns`, `width`,
|
|
254
|
+
`height`, `fill`, `stroke` or `stroke-*`, since the host supplies all of that
|
|
255
|
+
from `--tosi-icon-*` and a hardwired stroke would ignore the current colour.
|
|
256
|
+
|
|
257
|
+
- **Renamed to one name everywhere: `tosijs-styled-editor`.** The element is now
|
|
258
|
+
`<tosijs-styled-editor>` (was `<tosi-styled-editor>`), the class is
|
|
259
|
+
`TosijsStyledEditor`, the creator is `tosijsStyledEditor()`, and the source is
|
|
260
|
+
`src/tosijs-styled-editor.ts`. Only the repo directory stays `tosijs-editor`.
|
|
261
|
+
Breaking, and free to take now because the package is unpublished.
|
|
262
|
+
- Doc pages have distinct titles — "A Rich Text Editor Component" (README) and
|
|
263
|
+
"Editor Component" — so the site nav no longer shows two near-identical entries.
|
|
264
|
+
- Site icon, header mark and social image now use `static/tosijs-editor.svg`.
|
|
265
|
+
- Chrome restyled around a pen-ink-blue accent (`#27488c`), shared by the
|
|
266
|
+
component and the doc site. The menubar, toolbar and document now read as
|
|
267
|
+
three distinct surfaces, mixed from a single `--editor-ink` custom property so
|
|
268
|
+
a consumer can re-theme the whole thing by setting one value. Toolbar buttons
|
|
269
|
+
are compact 26px squares, styled by the component rather than left to each
|
|
270
|
+
consumer to re-invent. Those styles ship as `lightStyleSpec`, not
|
|
271
|
+
`::slotted()`: slotted content is light DOM, so the host page's own `button`
|
|
272
|
+
rules win the cascade — which had left the buttons as white chips on the
|
|
273
|
+
tinted bars.
|
|
274
|
+
|
|
275
|
+
- **Build**: replaced the bespoke `dev.ts` with `bin/site.ts`, a thin wrapper
|
|
276
|
+
over tosijs-ui's doc system (`buildSite`/`devServer`). The full build is
|
|
277
|
+
`bun run make` — there is deliberately no `build` script, because `bun build`
|
|
278
|
+
is a Bun builtin and the two would differ.
|
|
279
|
+
- Bundling moved out of the long-lived watch process into child processes;
|
|
280
|
+
Bun's bundler never returns its native arena (oven-sh/bun#34053).
|
|
281
|
+
- `docs/` is now the generated Pages web root and is committed.
|
|
282
|
+
- Peer floors raised to `tosijs ^1.10.1` / `tosijs-ui ^1.13.0`.
|
|
283
|
+
- Migrated off `elementCreator({ tag })` and `static styleSpec`, both deprecated
|
|
284
|
+
in the upgrade, to `static preferredTagName` and `static shadowStyleSpec`.
|
|
285
|
+
|
|
286
|
+
### Security
|
|
287
|
+
|
|
288
|
+
- `happy-dom` → 20.14.0 (GHSA-w4gp-fjgq-3q4g, GHSA-6q6h-j7hj-3r64) and a minimal
|
|
289
|
+
`ws` override → `^8.21.0` (GHSA-96hv-2xvq-fx4p), both surfaced by the build's
|
|
290
|
+
dependency audit gate.
|
|
291
|
+
- `tls/` is gitignored as an allowlist so a dev TLS private key cannot be staged.
|
|
292
|
+
|
|
293
|
+
### Removed
|
|
294
|
+
|
|
295
|
+
- The legacy jQuery implementation (`edx-*.js`, `lib/jquery-2.1.4.js`, and the
|
|
296
|
+
orphaned HTML pages) — 336K that nothing in the build referenced. Recover from
|
|
297
|
+
git history if ever needed: `git show 298bf16 -- edx-editable.js`.
|
|
298
|
+
|
|
299
|
+
## [0.2.0]
|
|
300
|
+
|
|
301
|
+
### Added
|
|
302
|
+
|
|
303
|
+
- Touch selection affordances and context menu; component renamed to
|
|
304
|
+
`<tosijs-styled-editor>`.
|
|
305
|
+
|
|
306
|
+
## [0.1.0]
|
|
307
|
+
|
|
308
|
+
### Added
|
|
309
|
+
|
|
310
|
+
- Initial rewrite as a tosijs web component: custom selection via spanification,
|
|
311
|
+
command-based editing, grid tables, and toolbar/menubar factories.
|
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
|
@@ -27,7 +27,11 @@ A pure web-component. What it does **not** use:
|
|
|
27
27
|
|
|
28
28
|
- No `document.execCommand`
|
|
29
29
|
- No `contentEditable`
|
|
30
|
-
- No
|
|
30
|
+
- No `getSelection`, no browser selection model, no `execCommand`-era APIs
|
|
31
|
+
|
|
32
|
+
Range is used, but only as a **measuring tape** — `getBoundingClientRect()` to
|
|
33
|
+
ask the layout engine where a character is. It is never a selection model, and
|
|
34
|
+
nothing is handed back to the browser to edit.
|
|
31
35
|
|
|
32
36
|
What you get instead:
|
|
33
37
|
|
|
@@ -59,7 +63,13 @@ Live site and docs: <https://editor.tosijs.net>
|
|
|
59
63
|
npm install tosijs-styled-editor
|
|
60
64
|
```
|
|
61
65
|
|
|
62
|
-
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.
|
|
63
73
|
|
|
64
74
|
## Usage
|
|
65
75
|
|
|
@@ -110,11 +120,68 @@ for (const widget of defaultToolbar()) {
|
|
|
110
120
|
The editor uses three layers:
|
|
111
121
|
|
|
112
122
|
1. **DOM utilities** (`dom-utils.ts`) — leaf-node traversal; nearly all operations work with leaf nodes
|
|
113
|
-
2. **Selection** (`selection.ts`) — custom selection
|
|
123
|
+
2. **Selection** (`selection.ts`) — a custom selection model. Character positions are found by MEASURING with a Range, which does not touch the document; wrapping characters in spans to measure them changes the thing being measured (it breaks shaping, so cursive scripts come apart and lines re-wrap)
|
|
114
124
|
3. **Commands** (`commands.ts`) — extensible command system for formatting and editing
|
|
115
125
|
|
|
116
126
|
The caret is an `<input>` element, so mobile browsers show their keyboard automatically.
|
|
117
127
|
|
|
128
|
+
## Security: what is sanitized, and what is not
|
|
129
|
+
|
|
130
|
+
Replacing `contentEditable` also means replacing the sanitization the browser was
|
|
131
|
+
doing on your behalf.
|
|
132
|
+
|
|
133
|
+
**Sanitized** — content arriving from outside the document, which is the path an
|
|
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).
|
|
148
|
+
|
|
149
|
+
**NOT sanitized** — content you supply, which is inside your own trust boundary:
|
|
150
|
+
|
|
151
|
+
- `editor.value = html`
|
|
152
|
+
- initial light-DOM content
|
|
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
|
+
|
|
159
|
+
**Using a different sanitizer.** `editor.sanitize` is the hook — it receives a
|
|
160
|
+
detached element and mutates it:
|
|
161
|
+
|
|
162
|
+
```javascript
|
|
163
|
+
editor.sanitize = (root) => {
|
|
164
|
+
DOMPurify.sanitize(root, {
|
|
165
|
+
IN_PLACE: true,
|
|
166
|
+
FORBID_TAGS: ['style'],
|
|
167
|
+
CUSTOM_ELEMENT_HANDLING: {
|
|
168
|
+
tagNameCheck: /^[a-z][a-z0-9]*-[a-z0-9-]*$/,
|
|
169
|
+
attributeNameCheck: /^data-|^slot$|^dir$/,
|
|
170
|
+
},
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
It takes an element rather than an HTML string on purpose: a string signature
|
|
176
|
+
would force a serialize-and-reparse round trip, and that round trip is where
|
|
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
|
+
|
|
118
185
|
## Keyboard Behavior
|
|
119
186
|
|
|
120
187
|
### General editing
|
|
@@ -216,10 +283,10 @@ setBlocks line-height 2.5
|
|
|
216
283
|
|
|
217
284
|
### Drag and drop
|
|
218
285
|
|
|
219
|
-
Selected text becomes a real draggable object —
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
286
|
+
Selected text becomes a real draggable object — the selection is marked on
|
|
287
|
+
elements, which is exactly what HTML5 drag and drop wants, so dragging works
|
|
288
|
+
between windows, between browsers, and to and from the desktop with no extra
|
|
289
|
+
machinery.
|
|
223
290
|
|
|
224
291
|
Every drag offers **both representations**, and the receiver picks:
|
|
225
292
|
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
## Reporting
|
|
4
|
+
|
|
5
|
+
- **A sanitizer bypass** — pasted or dropped content that reaches the document
|
|
6
|
+
still able to execute — belongs to
|
|
7
|
+
[`tosijs-kilpi`](https://github.com/tonioloewald/kilpi/issues). That is where
|
|
8
|
+
the filtering code lives; this package only calls it.
|
|
9
|
+
- **Anything else** — [this repository's
|
|
10
|
+
issues](https://github.com/tonioloewald/tosijs-editor/issues).
|
|
11
|
+
|
|
12
|
+
If you would rather not disclose publicly first, open an issue with no details
|
|
13
|
+
and we will find a private channel.
|
|
14
|
+
|
|
15
|
+
## What this package is responsible for
|
|
16
|
+
|
|
17
|
+
- applying sanitization at the **paste and drop choke point**, before any node
|
|
18
|
+
enters the document (`insertTransfer`)
|
|
19
|
+
- the `editor.sanitize` hook, so a host can substitute its own sanitizer
|
|
20
|
+
- the two URL guards this package owns: `setLink`, and following a link on
|
|
21
|
+
Ctrl/Cmd-click
|
|
22
|
+
|
|
23
|
+
## What it is NOT responsible for
|
|
24
|
+
|
|
25
|
+
- **the sanitization policy itself** — that is kilpi's, and
|
|
26
|
+
[kilpi's SECURITY.md](https://github.com/tonioloewald/kilpi/blob/main/SECURITY.md)
|
|
27
|
+
is authoritative. It is deliberately not restated here, because a copy of a
|
|
28
|
+
policy drifts from the policy.
|
|
29
|
+
- **content the host supplies**: `editor.value = html` and initial light-DOM
|
|
30
|
+
content are inside your trust boundary and are not filtered.
|
|
31
|
+
- **documents stored before 0.4.4**, which may already contain a pasted payload.
|
|
32
|
+
Setting `value` does not filter, so sanitize your corpus as part of upgrading.
|
package/dist/commands.d.ts
CHANGED
|
@@ -21,7 +21,7 @@ export interface EditableContext {
|
|
|
21
21
|
findAll(selector: string): Element[];
|
|
22
22
|
selectedLeafNodes(): Node[];
|
|
23
23
|
selectedBlocks(): Element[];
|
|
24
|
-
insertionPoint():
|
|
24
|
+
insertionPoint(): HTMLElement | null;
|
|
25
25
|
block(node: Node): Element | null;
|
|
26
26
|
normalize(): void;
|
|
27
27
|
focus(): void;
|
|
@@ -44,4 +44,14 @@ export declare const commands: Record<string, Command>;
|
|
|
44
44
|
* Supports chained commands separated by semicolons.
|
|
45
45
|
* Example: "setText font-weight bold; setBlockType h2"
|
|
46
46
|
*/
|
|
47
|
+
/**
|
|
48
|
+
* Run a single command with already-separated arguments.
|
|
49
|
+
*
|
|
50
|
+
* The string form splits on `;` and whitespace, so any runtime value
|
|
51
|
+
* interpolated into it can inject a second command — and a data URI contains
|
|
52
|
+
* `;` BY SPEC (`data:image/png;base64,…`), which silently broke every dropped
|
|
53
|
+
* image. Call sites that build a command from a URL, a filename or anything
|
|
54
|
+
* else the user did not type must use this form instead.
|
|
55
|
+
*/
|
|
56
|
+
export declare function runCommand(ctx: EditableContext, name: string, ...args: string[]): void;
|
|
47
57
|
export declare function executeCommand(ctx: EditableContext, commandString: string): void;
|
package/dist/dom-utils.d.ts
CHANGED
|
@@ -83,3 +83,16 @@ export declare function caretGeometryAt(marker: Element, root: Element): {
|
|
|
83
83
|
top: number;
|
|
84
84
|
height: number;
|
|
85
85
|
} | null;
|
|
86
|
+
/**
|
|
87
|
+
* Sanitization lives in `tosijs-kilpi` — the same code, extracted so it is not
|
|
88
|
+
* maintained in two places.
|
|
89
|
+
*
|
|
90
|
+
* It was duplicated briefly, and that is exactly the shape that produced review
|
|
91
|
+
* finding M1 of the 0.4.4 cycle: one URL-normalization defect fixed in one of
|
|
92
|
+
* two copies, silently leaving the other. Across two repositories that drift
|
|
93
|
+
* would not even be visible in a diff.
|
|
94
|
+
*
|
|
95
|
+
* Re-exported here so the editor's public API is unchanged and every call site
|
|
96
|
+
* keeps importing from `./dom-utils`.
|
|
97
|
+
*/
|
|
98
|
+
export { sanitizeInPlace, isSafeNavigationUrl } from 'tosijs-kilpi';
|