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 +202 -3
- package/NOTICE +16 -0
- package/README.md +339 -28
- package/SECURITY.md +32 -0
- package/dist/changes.d.ts +126 -0
- package/dist/commands.d.ts +45 -0
- package/dist/dom-utils.d.ts +16 -17
- package/dist/footnote.d.ts +36 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -7
- package/dist/module.js +933 -165
- 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 +12 -5
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.
|
|
@@ -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;
|
package/dist/commands.d.ts
CHANGED
|
@@ -26,6 +26,51 @@ export interface EditableContext {
|
|
|
26
26
|
normalize(): void;
|
|
27
27
|
focus(): void;
|
|
28
28
|
updateUndo(command?: string, reason?: string): void;
|
|
29
|
+
/**
|
|
30
|
+
* Delete a node — or, under change tracking, mark it deleted in place.
|
|
31
|
+
*
|
|
32
|
+
* **This is how a command deletes anything.** A bare `node.remove()` is
|
|
33
|
+
* correct only when you know tracking is off, and a command cannot know
|
|
34
|
+
* that. The fix for tracked deletion originally landed on the component's
|
|
35
|
+
* private helper and stopped at the keydown handlers, so the two table
|
|
36
|
+
* commands — and every host-authored command added through
|
|
37
|
+
* `editor.commands.x = fn` — removed content with no `<tosi-del>`, no entry
|
|
38
|
+
* in `changes`, and nothing for `rejectChanges()` to restore.
|
|
39
|
+
*/
|
|
40
|
+
removeNode(node: Node): void;
|
|
41
|
+
/**
|
|
42
|
+
* Decline a structural edit, audibly.
|
|
43
|
+
*
|
|
44
|
+
* Returns true when the caller should go ahead and refuse; a host that calls
|
|
45
|
+
* `preventDefault()` on the `structural-edit-refused` event gets false back
|
|
46
|
+
* and the edit proceeds. Pair it with `tracksChanges()`:
|
|
47
|
+
*
|
|
48
|
+
* ```typescript
|
|
49
|
+
* if (ctx.tracksChanges() && ctx.refuseStructural('delete-table-row')) return
|
|
50
|
+
* ```
|
|
51
|
+
*
|
|
52
|
+
* A bare `return` is not good enough: `preventDefault()` has already run by
|
|
53
|
+
* the time a command executes, so a silent refusal is a dead menu item with
|
|
54
|
+
* no signal at any layer. Both table commands shipped that way for one
|
|
55
|
+
* commit while the README promised the event.
|
|
56
|
+
*
|
|
57
|
+
* **An override means the edit happens UNTRACKED.** If tracking could have
|
|
58
|
+
* represented it, the command would not have been refusing. So a command
|
|
59
|
+
* whose refusal was overridden should delete raw rather than through
|
|
60
|
+
* `removeNode` — half-tracking a structural edit is worse than either
|
|
61
|
+
* choice made cleanly.
|
|
62
|
+
*/
|
|
63
|
+
refuseStructural(reason: string): boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Whether edits are being recorded as tracked changes.
|
|
66
|
+
*
|
|
67
|
+
* A command that RESTRUCTURES rather than deletes text — removing a table
|
|
68
|
+
* row, merging blocks — should check this and decline, because a change mark
|
|
69
|
+
* wraps content and structure is not content. Declining is the house rule:
|
|
70
|
+
* silently restructuring with nothing in `changes` to show for it is the
|
|
71
|
+
* failure this whole mechanism exists to prevent.
|
|
72
|
+
*/
|
|
73
|
+
tracksChanges(): boolean;
|
|
29
74
|
}
|
|
30
75
|
/** Parse a "key value key value" argument list into a CSS object */
|
|
31
76
|
export declare function makeCSS(args: string[]): Record<string, string> | null;
|
package/dist/dom-utils.d.ts
CHANGED
|
@@ -84,26 +84,25 @@ export declare function caretGeometryAt(marker: Element, root: Element): {
|
|
|
84
84
|
height: number;
|
|
85
85
|
} | null;
|
|
86
86
|
/**
|
|
87
|
-
*
|
|
87
|
+
* Sanitization lives in `tosijs-kilpi` — the same code, extracted so it is not
|
|
88
|
+
* maintained in two places.
|
|
88
89
|
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
* on undo. Must run BEFORE any node enters the document.
|
|
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
94
|
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* which must survive — see EXTENSIBILITY.md), whereas unknown SCHEMES are not.
|
|
95
|
+
* Re-exported here so the editor's public API is unchanged and every call site
|
|
96
|
+
* keeps importing from `./dom-utils`.
|
|
98
97
|
*/
|
|
99
|
-
export
|
|
98
|
+
export { sanitizeInPlace, isSafeNavigationUrl } from 'tosijs-kilpi';
|
|
100
99
|
/**
|
|
101
|
-
*
|
|
100
|
+
* A block with nothing left in it worth keeping.
|
|
102
101
|
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
102
|
+
* "No text" is not enough on its own: an image, a rule, a line break or a
|
|
103
|
+
* table is content with no text content, and a `<tosi-del>` is text somebody
|
|
104
|
+
* has proposed to remove and a reviewer still has to see. Two copies of this
|
|
105
|
+
* test existed and had already drifted in opposite directions — one checked
|
|
106
|
+
* the replaced elements and not the change mark, the other the reverse.
|
|
108
107
|
*/
|
|
109
|
-
export declare function
|
|
108
|
+
export declare function blockIsEmpty(block: Element): boolean;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<tosi-footnote>` — a footnote reference that maintains its own entry.
|
|
3
|
+
*
|
|
4
|
+
* This is the first feature converted to the web-component substrate described
|
|
5
|
+
* in EXTENSIBILITY.md, and it exists because of a shipped bug: `insertFootnote`
|
|
6
|
+
* called `renumberFootnotes` at insertion time and nothing called it again, so
|
|
7
|
+
* deleting a reference with Backspace left its text orphaned in the list and
|
|
8
|
+
* the survivors mis-numbered.
|
|
9
|
+
*
|
|
10
|
+
* `renumberFootnotes` was never wrong — it already removes orphans and derives
|
|
11
|
+
* numbers from document order. What was missing was anything to *call* it when
|
|
12
|
+
* the document changed. Custom element lifecycle is that caller, and it is a
|
|
13
|
+
* better one than a global "document changed" hook would have been: it fires
|
|
14
|
+
* per node, only for the nodes that actually moved, and it fires for edits
|
|
15
|
+
* nobody wrote code for — a deletion, a drag, a paste, an undo.
|
|
16
|
+
*/
|
|
17
|
+
export declare class TosiFootnote extends HTMLElement {
|
|
18
|
+
/**
|
|
19
|
+
* Captured on connect, because `disconnectedCallback` runs when this element
|
|
20
|
+
* is ALREADY detached — `closest()` would find nothing at the only moment we
|
|
21
|
+
* most need the document.
|
|
22
|
+
*/
|
|
23
|
+
private editorRoot;
|
|
24
|
+
connectedCallback(): void;
|
|
25
|
+
disconnectedCallback(): void;
|
|
26
|
+
}
|
|
27
|
+
/** The tag name, so callers do not hard-code a string that could drift. */
|
|
28
|
+
export declare const FOOTNOTE_TAG = "tosi-footnote";
|
|
29
|
+
/**
|
|
30
|
+
* Register the element.
|
|
31
|
+
*
|
|
32
|
+
* Idempotent, and safe to call in a non-browser environment — the editor's
|
|
33
|
+
* tests run under happy-dom, and a plugin should never be the reason an import
|
|
34
|
+
* throws somewhere it was not designed for.
|
|
35
|
+
*/
|
|
36
|
+
export declare function defineFootnote(): void;
|
package/dist/index.d.ts
CHANGED
|
@@ -2,6 +2,9 @@ export * from './dom-utils';
|
|
|
2
2
|
export * from './selection';
|
|
3
3
|
export * from './commands';
|
|
4
4
|
export * from './tosijs-styled-editor';
|
|
5
|
+
export * from './changes';
|
|
6
|
+
export * from './spelling';
|
|
7
|
+
export * from './footnote';
|
|
5
8
|
export * from './toolbar';
|
|
6
9
|
export * from './table-utils';
|
|
7
10
|
export { version } from './version';
|