@quillmark/svelte 0.0.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/LICENSE +201 -0
- package/NOTICE +58 -0
- package/README.md +241 -0
- package/THEMING.md +153 -0
- package/dist/core/address.d.ts +56 -0
- package/dist/core/address.js +95 -0
- package/dist/core/bloom.d.ts +37 -0
- package/dist/core/bloom.js +116 -0
- package/dist/core/codec/anchor.d.ts +19 -0
- package/dist/core/codec/anchor.js +36 -0
- package/dist/core/codec/code.d.ts +12 -0
- package/dist/core/codec/code.js +118 -0
- package/dist/core/codec/decode.d.ts +24 -0
- package/dist/core/codec/decode.js +270 -0
- package/dist/core/codec/encode.d.ts +71 -0
- package/dist/core/codec/encode.js +480 -0
- package/dist/core/codec/field.d.ts +171 -0
- package/dist/core/codec/field.js +493 -0
- package/dist/core/codec/index.d.ts +22 -0
- package/dist/core/codec/index.js +43 -0
- package/dist/core/codec/inputrules.d.ts +7 -0
- package/dist/core/codec/inputrules.js +168 -0
- package/dist/core/codec/islands.d.ts +36 -0
- package/dist/core/codec/islands.js +82 -0
- package/dist/core/codec/keymap.d.ts +13 -0
- package/dist/core/codec/keymap.js +29 -0
- package/dist/core/codec/lists.d.ts +22 -0
- package/dist/core/codec/lists.js +104 -0
- package/dist/core/codec/marks.d.ts +37 -0
- package/dist/core/codec/marks.js +74 -0
- package/dist/core/codec/positions.d.ts +17 -0
- package/dist/core/codec/positions.js +108 -0
- package/dist/core/codec/prose.css +458 -0
- package/dist/core/codec/reconcile.d.ts +26 -0
- package/dist/core/codec/reconcile.js +64 -0
- package/dist/core/codec/schema.d.ts +13 -0
- package/dist/core/codec/schema.js +155 -0
- package/dist/core/codec/slash.d.ts +79 -0
- package/dist/core/codec/slash.js +0 -0
- package/dist/core/codec/table-view.d.ts +50 -0
- package/dist/core/codec/table-view.js +928 -0
- package/dist/core/codec/table.d.ts +85 -0
- package/dist/core/codec/table.js +224 -0
- package/dist/core/errors.d.ts +73 -0
- package/dist/core/errors.js +24 -0
- package/dist/core/index.d.ts +5 -0
- package/dist/core/index.js +19 -0
- package/dist/core/lifecycle.d.ts +7 -0
- package/dist/core/lifecycle.js +21 -0
- package/dist/core/rebind.svelte.d.ts +18 -0
- package/dist/core/rebind.svelte.js +44 -0
- package/dist/core/teardown.d.ts +20 -0
- package/dist/core/teardown.js +37 -0
- package/dist/core/theme.css +276 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +9 -0
- package/dist/preset/index.css +10 -0
- package/dist/preset/recipes.css +188 -0
- package/dist/preset/scale.css +97 -0
- package/dist/preview/Preview.svelte +118 -0
- package/dist/preview/Preview.svelte.d.ts +39 -0
- package/dist/preview/bridge.d.ts +13 -0
- package/dist/preview/bridge.js +88 -0
- package/dist/preview/controller.d.ts +40 -0
- package/dist/preview/controller.js +141 -0
- package/dist/preview/geometry.d.ts +37 -0
- package/dist/preview/geometry.js +45 -0
- package/dist/preview/index.d.ts +5 -0
- package/dist/preview/index.js +14 -0
- package/dist/preview/overlay.d.ts +10 -0
- package/dist/preview/overlay.js +82 -0
- package/dist/preview/paint.d.ts +17 -0
- package/dist/preview/paint.js +262 -0
- package/dist/preview/strings.d.ts +14 -0
- package/dist/preview/strings.js +22 -0
- package/dist/visual/ArrayField.svelte +362 -0
- package/dist/visual/ArrayField.svelte.d.ts +24 -0
- package/dist/visual/BooleanField.svelte +88 -0
- package/dist/visual/BooleanField.svelte.d.ts +20 -0
- package/dist/visual/Card.svelte +849 -0
- package/dist/visual/Card.svelte.d.ts +43 -0
- package/dist/visual/CardControls.svelte +92 -0
- package/dist/visual/CardControls.svelte.d.ts +11 -0
- package/dist/visual/DateField.svelte +203 -0
- package/dist/visual/DateField.svelte.d.ts +28 -0
- package/dist/visual/DiagnosticList.svelte +50 -0
- package/dist/visual/DiagnosticList.svelte.d.ts +7 -0
- package/dist/visual/EnumField.svelte +169 -0
- package/dist/visual/EnumField.svelte.d.ts +25 -0
- package/dist/visual/Field.svelte +270 -0
- package/dist/visual/Field.svelte.d.ts +37 -0
- package/dist/visual/FieldHint.svelte +185 -0
- package/dist/visual/FieldHint.svelte.d.ts +13 -0
- package/dist/visual/FieldLabel.svelte +141 -0
- package/dist/visual/FieldLabel.svelte.d.ts +19 -0
- package/dist/visual/FormatPopover.svelte +396 -0
- package/dist/visual/FormatPopover.svelte.d.ts +9 -0
- package/dist/visual/NumberField.svelte +72 -0
- package/dist/visual/NumberField.svelte.d.ts +19 -0
- package/dist/visual/ObjectField.svelte +131 -0
- package/dist/visual/ObjectField.svelte.d.ts +17 -0
- package/dist/visual/ProseArrayElement.svelte +98 -0
- package/dist/visual/ProseArrayElement.svelte.d.ts +19 -0
- package/dist/visual/ProseField.svelte +213 -0
- package/dist/visual/ProseField.svelte.d.ts +50 -0
- package/dist/visual/SlashMenu.svelte +126 -0
- package/dist/visual/SlashMenu.svelte.d.ts +14 -0
- package/dist/visual/TextField.svelte +63 -0
- package/dist/visual/TextField.svelte.d.ts +23 -0
- package/dist/visual/TipsCard.svelte +191 -0
- package/dist/visual/TipsCard.svelte.d.ts +13 -0
- package/dist/visual/VisualEditor.svelte +96 -0
- package/dist/visual/VisualEditor.svelte.d.ts +14 -0
- package/dist/visual/VisualEditorInner.svelte +1032 -0
- package/dist/visual/VisualEditorInner.svelte.d.ts +17 -0
- package/dist/visual/controls.css +272 -0
- package/dist/visual/diagnostics.d.ts +52 -0
- package/dist/visual/diagnostics.js +83 -0
- package/dist/visual/domid.d.ts +21 -0
- package/dist/visual/domid.js +36 -0
- package/dist/visual/ext.d.ts +19 -0
- package/dist/visual/ext.js +29 -0
- package/dist/visual/icons/Icon.svelte +39 -0
- package/dist/visual/icons/Icon.svelte.d.ts +12 -0
- package/dist/visual/icons/nodes.d.ts +103 -0
- package/dist/visual/icons/nodes.js +55 -0
- package/dist/visual/index.d.ts +9 -0
- package/dist/visual/index.js +24 -0
- package/dist/visual/motion.d.ts +18 -0
- package/dist/visual/motion.js +53 -0
- package/dist/visual/props.d.ts +77 -0
- package/dist/visual/props.js +1 -0
- package/dist/visual/signals.d.ts +59 -0
- package/dist/visual/signals.js +1 -0
- package/dist/visual/strings.d.ts +123 -0
- package/dist/visual/strings.js +80 -0
- package/dist/visual/structure.d.ts +224 -0
- package/dist/visual/structure.js +270 -0
- package/dist/visual/synced.svelte.d.ts +15 -0
- package/dist/visual/synced.svelte.js +34 -0
- package/dist/visual/tips.d.ts +24 -0
- package/dist/visual/tips.js +50 -0
- package/package.json +102 -0
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
import { mapPos, isAnchorMark } from '@quillmark/wasm';
|
|
2
|
+
import { codePoints, usvLength } from './decode.js';
|
|
3
|
+
import { ISLAND_SLOT, islandEntryFromNode } from './islands.js';
|
|
4
|
+
import { contentDescriptorFromPM, descriptorOf, markKey } from './marks.js';
|
|
5
|
+
import { valueEqual } from './reconcile.js';
|
|
6
|
+
/** Append `s` to the accumulated text, advancing the running USV length. */
|
|
7
|
+
function appendText(acc, s) {
|
|
8
|
+
acc.text += s;
|
|
9
|
+
acc.usvEnd += usvLength(s);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Walk a PM document once, producing the content projection AND the position-map
|
|
13
|
+
* runs (they share the traversal so they can never disagree). This is the single
|
|
14
|
+
* source both `pmToContent` and the position map draw from.
|
|
15
|
+
*/
|
|
16
|
+
export function scanDoc(doc) {
|
|
17
|
+
const acc = {
|
|
18
|
+
text: '',
|
|
19
|
+
lines: [],
|
|
20
|
+
marks: [],
|
|
21
|
+
islands: [],
|
|
22
|
+
runs: [],
|
|
23
|
+
pmEnd: doc.content.size,
|
|
24
|
+
usvEnd: 0,
|
|
25
|
+
rawMarks: [],
|
|
26
|
+
lastContentEndPm: 0
|
|
27
|
+
};
|
|
28
|
+
scanBlocks(acc, doc, 0, []);
|
|
29
|
+
acc.marks = mergeMarks(acc.rawMarks);
|
|
30
|
+
return acc;
|
|
31
|
+
}
|
|
32
|
+
/** `pmToContent`: the pure inverse of decode (drops the position runs). */
|
|
33
|
+
export function pmToContent(doc) {
|
|
34
|
+
const { text, lines, marks, islands } = scanDoc(doc);
|
|
35
|
+
return { text, lines, marks, islands };
|
|
36
|
+
}
|
|
37
|
+
/** Walk a block-content fragment; `contentStart` is the PM pos before its first child. */
|
|
38
|
+
function scanBlocks(acc, parent, contentStart, containers) {
|
|
39
|
+
let pm = contentStart;
|
|
40
|
+
parent.forEach((child) => {
|
|
41
|
+
scanBlock(acc, child, pm, containers);
|
|
42
|
+
pm += child.nodeSize;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
function scanBlock(acc, node, nodePos, containers) {
|
|
46
|
+
const name = node.type.name;
|
|
47
|
+
const contentStart = nodePos + 1;
|
|
48
|
+
switch (name) {
|
|
49
|
+
case 'paragraph':
|
|
50
|
+
case 'heading': {
|
|
51
|
+
// One kind value per block: the opening line and its hard-break
|
|
52
|
+
// continuations read the SAME object, so they cannot disagree.
|
|
53
|
+
const kind = textblockKind(node);
|
|
54
|
+
beginLine(acc, contentStart, containers, kind);
|
|
55
|
+
scanInline(acc, node, contentStart, containers, kind);
|
|
56
|
+
acc.lastContentEndPm = contentStart + node.content.size;
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
case 'code_block': {
|
|
60
|
+
const lang = node.attrs.lang ?? undefined;
|
|
61
|
+
const kind = lang != null ? { kind: 'code', lang } : { kind: 'code' };
|
|
62
|
+
beginLine(acc, contentStart, containers, kind);
|
|
63
|
+
const codeText = node.textContent;
|
|
64
|
+
if (codeText.length)
|
|
65
|
+
emitText(acc, codeText, contentStart, []);
|
|
66
|
+
// Internal `\n`s already sit in the text (and its run); add the extra
|
|
67
|
+
// content lines they imply as `continues` code lines.
|
|
68
|
+
const extra = codeText.split('\n').length - 1;
|
|
69
|
+
for (let i = 0; i < extra; i++)
|
|
70
|
+
acc.lines.push({ containers, continues: true, ...kind });
|
|
71
|
+
acc.lastContentEndPm = contentStart + node.content.size;
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
case 'horizontal_rule':
|
|
75
|
+
beginLine(acc, nodePos, containers, { kind: 'rule' });
|
|
76
|
+
acc.lastContentEndPm = nodePos + 1;
|
|
77
|
+
break;
|
|
78
|
+
case 'island_block':
|
|
79
|
+
beginLine(acc, nodePos, containers, { kind: 'island' });
|
|
80
|
+
acc.runs.push({ kind: 'atom', pmStart: nodePos, usvStart: acc.usvEnd });
|
|
81
|
+
appendText(acc, ISLAND_SLOT);
|
|
82
|
+
acc.islands.push(islandEntryFromNode(node.attrs));
|
|
83
|
+
acc.lastContentEndPm = nodePos + 1;
|
|
84
|
+
break;
|
|
85
|
+
case 'blockquote':
|
|
86
|
+
scanBlocks(acc, node, contentStart, [...containers, { container: 'quote' }]);
|
|
87
|
+
break;
|
|
88
|
+
case 'unknown_container':
|
|
89
|
+
scanBlocks(acc, node, contentStart, [
|
|
90
|
+
...containers,
|
|
91
|
+
{ container: node.attrs.container, attrs: node.attrs.attrs }
|
|
92
|
+
]);
|
|
93
|
+
break;
|
|
94
|
+
case 'bullet_list':
|
|
95
|
+
case 'ordered_list': {
|
|
96
|
+
const ordered = name === 'ordered_list';
|
|
97
|
+
const start = ordered ? node.attrs.start : 1;
|
|
98
|
+
let itemPos = contentStart;
|
|
99
|
+
node.forEach((item, _off, index) => {
|
|
100
|
+
const container = {
|
|
101
|
+
container: 'list_item',
|
|
102
|
+
ordered,
|
|
103
|
+
start,
|
|
104
|
+
ordinal: index
|
|
105
|
+
};
|
|
106
|
+
scanBlocks(acc, item, itemPos + 1, [...containers, container]);
|
|
107
|
+
itemPos += item.nodeSize;
|
|
108
|
+
});
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
default:
|
|
112
|
+
// Unknown block type: treat as an empty paragraph line (defensive).
|
|
113
|
+
beginLine(acc, contentStart, containers, { kind: 'para' });
|
|
114
|
+
acc.lastContentEndPm = contentStart;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** A textblock's line kind: a heading's `level`, or (for a paragraph) `para`, or
|
|
118
|
+
* the unknown kind it carries (schema.ts) re-emitted verbatim. */
|
|
119
|
+
function textblockKind(node) {
|
|
120
|
+
if (node.type.name === 'heading')
|
|
121
|
+
return { kind: 'heading', level: node.attrs.level };
|
|
122
|
+
const u = node.attrs.unknown;
|
|
123
|
+
return u ? { kind: u.kind, attrs: u.attrs } : { kind: 'para' };
|
|
124
|
+
}
|
|
125
|
+
/** Open a new content line: emit the boundary `\n` (except the first line) + record. */
|
|
126
|
+
function beginLine(acc, thisContentStart, containers, kind) {
|
|
127
|
+
if (acc.lines.length > 0) {
|
|
128
|
+
acc.runs.push({
|
|
129
|
+
kind: 'nl',
|
|
130
|
+
pmStart: acc.lastContentEndPm,
|
|
131
|
+
pmEnd: thisContentStart,
|
|
132
|
+
usvStart: acc.usvEnd
|
|
133
|
+
});
|
|
134
|
+
appendText(acc, '\n');
|
|
135
|
+
}
|
|
136
|
+
acc.lines.push({ containers, ...kind });
|
|
137
|
+
}
|
|
138
|
+
/** Walk a textblock's inline content; emit text/atom/hard-break runs + marks. */
|
|
139
|
+
function scanInline(acc, block, contentStart, containers, kind) {
|
|
140
|
+
let pm = contentStart;
|
|
141
|
+
block.forEach((child) => {
|
|
142
|
+
if (child.isText) {
|
|
143
|
+
emitText(acc, child.text ?? '', pm, child.marks);
|
|
144
|
+
}
|
|
145
|
+
else if (child.type.name === 'hard_break') {
|
|
146
|
+
// A within-block hard break: the content `\n` (a `continues` line).
|
|
147
|
+
acc.runs.push({ kind: 'nl', pmStart: pm, pmEnd: pm + 1, usvStart: acc.usvEnd });
|
|
148
|
+
appendText(acc, '\n');
|
|
149
|
+
acc.lines.push({ containers, continues: true, ...kind });
|
|
150
|
+
}
|
|
151
|
+
else if (child.type.name === 'island_inline') {
|
|
152
|
+
acc.runs.push({ kind: 'atom', pmStart: pm, usvStart: acc.usvEnd });
|
|
153
|
+
appendText(acc, ISLAND_SLOT);
|
|
154
|
+
acc.islands.push(islandEntryFromNode(child.attrs));
|
|
155
|
+
}
|
|
156
|
+
pm += child.nodeSize;
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
/** Append a text node's chars: one position run + a content mark per formatting/unknown mark. */
|
|
160
|
+
function emitText(acc, s, pmStart, marks) {
|
|
161
|
+
if (!s.length)
|
|
162
|
+
return;
|
|
163
|
+
const usvStart = acc.usvEnd;
|
|
164
|
+
acc.runs.push({ kind: 'text', pmStart, usvStart, s });
|
|
165
|
+
appendText(acc, s);
|
|
166
|
+
for (const mark of marks) {
|
|
167
|
+
acc.rawMarks.push({
|
|
168
|
+
start: usvStart,
|
|
169
|
+
end: acc.usvEnd,
|
|
170
|
+
...contentDescriptorFromPM(mark)
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Merge adjacent/overlapping same-descriptor marks into maximal ranges: the
|
|
176
|
+
* per-text-node marks the walk emits, folded into the content's normalized form.
|
|
177
|
+
* Same two primitives the mark diff runs on (`groupFormatting` + `union`), so the
|
|
178
|
+
* projection and the diff group and merge by one rule.
|
|
179
|
+
*/
|
|
180
|
+
function mergeMarks(raw) {
|
|
181
|
+
const out = [];
|
|
182
|
+
for (const g of groupFormatting(raw, (m) => ({ start: m.start, end: m.end })).values()) {
|
|
183
|
+
for (const iv of union(g.intervals))
|
|
184
|
+
out.push({ ...iv, ...g.descriptor });
|
|
185
|
+
}
|
|
186
|
+
// Stable, content-like order: by start, then end.
|
|
187
|
+
return out.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
188
|
+
}
|
|
189
|
+
/** The edit `oldRt` → `newRt`: diffs the text once. */
|
|
190
|
+
export function contentEdit(oldRt, newRt) {
|
|
191
|
+
const delta = diffText(oldRt.text, newRt.text);
|
|
192
|
+
return delta ? { oldRt, newRt, delta } : { oldRt, newRt };
|
|
193
|
+
}
|
|
194
|
+
/** Lower an edit to a `ChangeBundle`: the old→new content diff, as ops. */
|
|
195
|
+
export function lower(edit, opts = {}) {
|
|
196
|
+
const { oldRt, newRt } = edit;
|
|
197
|
+
const { delta, islandOps, insertedAt } = splitIslands(edit);
|
|
198
|
+
const lineOps = diffLines(oldRt, newRt);
|
|
199
|
+
const markOps = diffMarks(oldRt, newRt, delta, insertedAt, opts);
|
|
200
|
+
const bundle = {};
|
|
201
|
+
if (delta)
|
|
202
|
+
bundle.delta = delta;
|
|
203
|
+
if (islandOps.length)
|
|
204
|
+
bundle.islandOps = islandOps;
|
|
205
|
+
if (lineOps.length)
|
|
206
|
+
bundle.lineOps = lineOps;
|
|
207
|
+
if (markOps.length)
|
|
208
|
+
bundle.markOps = markOps;
|
|
209
|
+
return bundle;
|
|
210
|
+
}
|
|
211
|
+
/** A minimal single-splice text delta over USV code points, or `undefined` if equal. */
|
|
212
|
+
function diffText(oldText, newText) {
|
|
213
|
+
if (oldText === newText)
|
|
214
|
+
return undefined;
|
|
215
|
+
const a = codePoints(oldText);
|
|
216
|
+
const b = codePoints(newText);
|
|
217
|
+
let p = 0;
|
|
218
|
+
const min = Math.min(a.length, b.length);
|
|
219
|
+
while (p < min && a[p] === b[p])
|
|
220
|
+
p++;
|
|
221
|
+
let s = 0;
|
|
222
|
+
while (s < min - p && a[a.length - 1 - s] === b[b.length - 1 - s])
|
|
223
|
+
s++;
|
|
224
|
+
const ops = [];
|
|
225
|
+
if (p > 0)
|
|
226
|
+
ops.push({ retain: p });
|
|
227
|
+
const del = a.length - p - s;
|
|
228
|
+
if (del > 0)
|
|
229
|
+
ops.push({ delete: del });
|
|
230
|
+
const ins = b.slice(p, b.length - s).join('');
|
|
231
|
+
if (ins.length > 0)
|
|
232
|
+
ops.push({ insert: ins });
|
|
233
|
+
if (s > 0)
|
|
234
|
+
ops.push({ retain: s });
|
|
235
|
+
return { ops };
|
|
236
|
+
}
|
|
237
|
+
/** Per-line `setContainers` / `setKind` / `setContinues` when line metadata changed; else none. */
|
|
238
|
+
function diffLines(oldRt, newRt) {
|
|
239
|
+
if (lineMetaEqual(oldRt.lines, newRt.lines))
|
|
240
|
+
return [];
|
|
241
|
+
const ops = [];
|
|
242
|
+
// Force every new line's metadata. Redundant ops are safe no-ops (verified),
|
|
243
|
+
// so this is correct regardless of how the delta's split/join inheritance left
|
|
244
|
+
// the intermediate metadata. `continues` is a boundary property of the `\n`
|
|
245
|
+
// preceding a line: line 0 has no predecessor, never carries it, and the op
|
|
246
|
+
// rejects it there, so it's set only for lines ≥ 1.
|
|
247
|
+
for (let i = 0; i < newRt.lines.length; i++) {
|
|
248
|
+
const l = newRt.lines[i];
|
|
249
|
+
ops.push({ op: 'setContainers', line: i, containers: l.containers });
|
|
250
|
+
ops.push(kindOp(i, l));
|
|
251
|
+
if (i > 0)
|
|
252
|
+
ops.push({ op: 'setContinues', line: i, continues: !!l.continues });
|
|
253
|
+
}
|
|
254
|
+
return ops;
|
|
255
|
+
}
|
|
256
|
+
/** A line minus its `containers` / `continues` envelope: exactly `setKind`'s
|
|
257
|
+
* payload, which the boundary names `ContentLineKind`. Lifting it whole is what
|
|
258
|
+
* keeps this arm-agnostic: `kind` is an OPEN set, so an arm-by-arm switch would
|
|
259
|
+
* have to guess at the unknown arm's payload and would drift on every arm
|
|
260
|
+
* upstream adds. */
|
|
261
|
+
function kindPart(l) {
|
|
262
|
+
const { containers: _containers, continues: _continues, ...kind } = l;
|
|
263
|
+
return kind;
|
|
264
|
+
}
|
|
265
|
+
function kindOp(line, l) {
|
|
266
|
+
return { op: 'setKind', line, ...kindPart(l) };
|
|
267
|
+
}
|
|
268
|
+
function lineMetaEqual(a, b) {
|
|
269
|
+
if (a.length !== b.length)
|
|
270
|
+
return false;
|
|
271
|
+
for (let i = 0; i < a.length; i++) {
|
|
272
|
+
if (!!a[i].continues !== !!b[i].continues)
|
|
273
|
+
return false;
|
|
274
|
+
if (kindKey(a[i]) !== kindKey(b[i]))
|
|
275
|
+
return false;
|
|
276
|
+
if (JSON.stringify(a[i].containers) !== JSON.stringify(b[i].containers))
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
/** A line kind's comparison key: key-order-insensitive, because the two sides come
|
|
282
|
+
* from different producers (a WASM read and this scan). */
|
|
283
|
+
function kindKey(l) {
|
|
284
|
+
return JSON.stringify(Object.entries(kindPart(l)).sort(([x], [y]) => (x < y ? -1 : 1)));
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Split the edit's islands off the text splice. `applyChange` throws
|
|
288
|
+
* `IslandSlotInInsert` on a `delta` that types a slot, so a slot NEVER rides the
|
|
289
|
+
* delta: every slot inside the splice's inserted region is stripped from it and
|
|
290
|
+
* placed by `{ op: 'insert', at }`, which carries the backing entry in the same
|
|
291
|
+
* op. Every other island kept its slot, so a changed payload is `{ op: 'set' }`,
|
|
292
|
+
* addressed by the id both sides carry. A DELETED island needs no op at all: the
|
|
293
|
+
* delta that removes its slot drops the entry.
|
|
294
|
+
*
|
|
295
|
+
* `at` is the slot's position in the FINAL text: inserts apply left to right, so
|
|
296
|
+
* by the time the k-th lands, the k slots before it are back in place.
|
|
297
|
+
*
|
|
298
|
+
* The pairing is positional for `insert` and by id for `set`, which is what each
|
|
299
|
+
* addresses by. An id the store does not carry emits nothing rather than a `set`
|
|
300
|
+
* that would throw `UnknownIslandId`: nothing mints or renames an island id, so
|
|
301
|
+
* it is unreachable.
|
|
302
|
+
*/
|
|
303
|
+
function splitIslands(edit) {
|
|
304
|
+
const { oldRt, newRt, delta } = edit;
|
|
305
|
+
const slots = slotPositions(newRt.text);
|
|
306
|
+
const { start, end } = insertedRegion(delta);
|
|
307
|
+
const oldById = new Map(oldRt.islands.map((isl) => [isl.id, isl]));
|
|
308
|
+
const islandOps = [];
|
|
309
|
+
const insertedAt = [];
|
|
310
|
+
newRt.islands.forEach((isl, i) => {
|
|
311
|
+
const at = slots[i];
|
|
312
|
+
if (at !== undefined && at >= start && at < end) {
|
|
313
|
+
islandOps.push({ op: 'insert', at, ...isl });
|
|
314
|
+
insertedAt.push(at);
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
const before = oldById.get(isl.id);
|
|
318
|
+
if (before && !valueEqual(before, isl))
|
|
319
|
+
islandOps.push({ op: 'set', ...isl });
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
return {
|
|
323
|
+
delta: insertedAt.length && delta ? withoutSlots(delta) : delta,
|
|
324
|
+
islandOps,
|
|
325
|
+
insertedAt
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
/** The USV positions of `text`'s island slots, in document order: the i-th is the
|
|
329
|
+
* i-th island's, since the scan appends an entry per slot it writes. */
|
|
330
|
+
function slotPositions(text) {
|
|
331
|
+
const out = [];
|
|
332
|
+
let usv = 0;
|
|
333
|
+
for (const cp of text) {
|
|
334
|
+
if (cp === ISLAND_SLOT)
|
|
335
|
+
out.push(usv);
|
|
336
|
+
usv++;
|
|
337
|
+
}
|
|
338
|
+
return out;
|
|
339
|
+
}
|
|
340
|
+
/** The splice's inserted region `[start, end)` in FINAL coords. `diffText` emits
|
|
341
|
+
* one `[retain?][delete?][insert?][retain?]`, so the leading retain is where the
|
|
342
|
+
* insert lands and its length is how far it reaches. */
|
|
343
|
+
function insertedRegion(delta) {
|
|
344
|
+
if (!delta)
|
|
345
|
+
return { start: 0, end: 0 };
|
|
346
|
+
const lead = delta.ops[0];
|
|
347
|
+
const start = lead && 'retain' in lead ? lead.retain : 0;
|
|
348
|
+
const insert = delta.ops.find((op) => 'insert' in op);
|
|
349
|
+
return { start, end: start + (insert ? usvLength(insert.insert) : 0) };
|
|
350
|
+
}
|
|
351
|
+
/** The delta with every island slot removed from its insert, or `undefined` when
|
|
352
|
+
* that leaves nothing but retains. Retain/delete arithmetic reads the OLD text,
|
|
353
|
+
* so dropping inserted characters leaves the rest of the splice intact. */
|
|
354
|
+
function withoutSlots(delta) {
|
|
355
|
+
const ops = [];
|
|
356
|
+
let splices = false;
|
|
357
|
+
for (const op of delta.ops) {
|
|
358
|
+
if ('insert' in op) {
|
|
359
|
+
const kept = op.insert.split(ISLAND_SLOT).join('');
|
|
360
|
+
if (kept.length) {
|
|
361
|
+
ops.push({ insert: kept });
|
|
362
|
+
splices = true;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
if ('delete' in op)
|
|
367
|
+
splices = true;
|
|
368
|
+
ops.push(op);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return splices ? { ops } : undefined;
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* A post-delta position carried through the bundle's island inserts, which move
|
|
375
|
+
* text exactly as the delta does and rebase marks by the same rule (start-assoc
|
|
376
|
+
* `after`, end-assoc `before`; an anchor is a point and holds `before`). Ascending
|
|
377
|
+
* `at`s apply left to right, so each comparison reads the RUNNING position.
|
|
378
|
+
*/
|
|
379
|
+
function shiftPastIslands(pos, insertedAt, assoc) {
|
|
380
|
+
let p = pos;
|
|
381
|
+
for (const at of insertedAt)
|
|
382
|
+
if (assoc === 'after' ? p >= at : p > at)
|
|
383
|
+
p++;
|
|
384
|
+
return p;
|
|
385
|
+
}
|
|
386
|
+
/** Formatting/unknown marks → add/remove ops; anchors → add/removeAnchor by id. */
|
|
387
|
+
function diffMarks(oldRt, newRt, delta, insertedAt, opts) {
|
|
388
|
+
const ops = [];
|
|
389
|
+
// Rebase old marks through BOTH text-moving channels to final coords, exactly
|
|
390
|
+
// as `applyChange` does internally (start assoc `after`, end assoc `before`).
|
|
391
|
+
const rebase = (pos, assoc) => shiftPastIslands(delta ? mapPos(delta, pos, assoc) : pos, insertedAt, assoc);
|
|
392
|
+
// Group both sides by descriptor key (excluding anchors).
|
|
393
|
+
const oldGroups = groupFormatting(oldRt.marks, (m) => ({
|
|
394
|
+
start: rebase(m.start, 'after'),
|
|
395
|
+
end: rebase(m.end, 'before')
|
|
396
|
+
}));
|
|
397
|
+
const newGroups = groupFormatting(newRt.marks, (m) => ({ start: m.start, end: m.end }));
|
|
398
|
+
const keys = new Set([...oldGroups.keys(), ...newGroups.keys()]);
|
|
399
|
+
for (const key of keys) {
|
|
400
|
+
const oldG = oldGroups.get(key);
|
|
401
|
+
const newG = newGroups.get(key);
|
|
402
|
+
const descriptor = (newG ?? oldG).descriptor;
|
|
403
|
+
const oldCov = union((oldG?.intervals ?? []).filter((iv) => iv.end > iv.start));
|
|
404
|
+
const newCov = union((newG?.intervals ?? []).filter((iv) => iv.end > iv.start));
|
|
405
|
+
for (const iv of subtract(oldCov, newCov))
|
|
406
|
+
ops.push({ op: 'remove', start: iv.start, end: iv.end, ...descriptor });
|
|
407
|
+
for (const iv of subtract(newCov, oldCov))
|
|
408
|
+
ops.push({ op: 'add', start: iv.start, end: iv.end, ...descriptor });
|
|
409
|
+
}
|
|
410
|
+
// Anchors: diff the decoration sets by id (positions already in final coords).
|
|
411
|
+
if (opts.oldAnchors || opts.newAnchors) {
|
|
412
|
+
const oldA = new Map((opts.oldAnchors ?? []).map((a) => [
|
|
413
|
+
a.id,
|
|
414
|
+
shiftPastIslands(delta ? mapPos(delta, a.pos, 'after') : a.pos, insertedAt, 'before')
|
|
415
|
+
]));
|
|
416
|
+
const newA = new Map((opts.newAnchors ?? []).map((a) => [a.id, a.pos]));
|
|
417
|
+
for (const [id, pos] of newA) {
|
|
418
|
+
if (!oldA.has(id) || oldA.get(id) !== pos) {
|
|
419
|
+
if (oldA.has(id))
|
|
420
|
+
ops.push({ op: 'removeAnchor', id });
|
|
421
|
+
ops.push({ op: 'add', start: pos, end: pos, type: 'anchor', id });
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
for (const id of oldA.keys())
|
|
425
|
+
if (!newA.has(id))
|
|
426
|
+
ops.push({ op: 'removeAnchor', id });
|
|
427
|
+
}
|
|
428
|
+
return ops;
|
|
429
|
+
}
|
|
430
|
+
/** Group non-anchor marks by descriptor key, mapping each to an interval. */
|
|
431
|
+
function groupFormatting(marks, toInterval) {
|
|
432
|
+
const groups = new Map();
|
|
433
|
+
for (const m of marks) {
|
|
434
|
+
if (isAnchorMark(m))
|
|
435
|
+
continue;
|
|
436
|
+
const descriptor = descriptorOf(m);
|
|
437
|
+
const key = markKey(descriptor);
|
|
438
|
+
let g = groups.get(key);
|
|
439
|
+
if (!g) {
|
|
440
|
+
g = { descriptor, intervals: [] };
|
|
441
|
+
groups.set(key, g);
|
|
442
|
+
}
|
|
443
|
+
g.intervals.push(toInterval(m));
|
|
444
|
+
}
|
|
445
|
+
return groups;
|
|
446
|
+
}
|
|
447
|
+
/** Union a set of intervals into sorted, disjoint, maximal intervals. */
|
|
448
|
+
function union(intervals) {
|
|
449
|
+
if (!intervals.length)
|
|
450
|
+
return [];
|
|
451
|
+
const sorted = [...intervals].sort((a, b) => a.start - b.start || a.end - b.end);
|
|
452
|
+
const out = [{ ...sorted[0] }];
|
|
453
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
454
|
+
const last = out[out.length - 1];
|
|
455
|
+
if (sorted[i].start <= last.end)
|
|
456
|
+
last.end = Math.max(last.end, sorted[i].end);
|
|
457
|
+
else
|
|
458
|
+
out.push({ ...sorted[i] });
|
|
459
|
+
}
|
|
460
|
+
return out;
|
|
461
|
+
}
|
|
462
|
+
/** Interval-set subtraction `a \ b` (both assumed unioned/sorted). */
|
|
463
|
+
function subtract(a, b) {
|
|
464
|
+
const out = [];
|
|
465
|
+
for (const iv of a) {
|
|
466
|
+
let cursor = iv.start;
|
|
467
|
+
for (const cut of b) {
|
|
468
|
+
if (cut.end <= cursor || cut.start >= iv.end)
|
|
469
|
+
continue;
|
|
470
|
+
if (cut.start > cursor)
|
|
471
|
+
out.push({ start: cursor, end: Math.min(cut.start, iv.end) });
|
|
472
|
+
cursor = Math.max(cursor, cut.end);
|
|
473
|
+
if (cursor >= iv.end)
|
|
474
|
+
break;
|
|
475
|
+
}
|
|
476
|
+
if (cursor < iv.end)
|
|
477
|
+
out.push({ start: cursor, end: iv.end });
|
|
478
|
+
}
|
|
479
|
+
return out.filter((iv) => iv.end > iv.start);
|
|
480
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import type { Schema } from 'prosemirror-model';
|
|
2
|
+
import { Plugin } from 'prosemirror-state';
|
|
3
|
+
import { EditorView } from 'prosemirror-view';
|
|
4
|
+
import type { Document, Content, Addr, Quill } from '@quillmark/wasm';
|
|
5
|
+
import type { EditorErrorHandler } from '../errors.js';
|
|
6
|
+
import { type TableChromeStrings } from './table-view.js';
|
|
7
|
+
import { type SlashItem, type SlashState, type SlashStrings } from './slash.js';
|
|
8
|
+
/** Options for {@link createField}. */
|
|
9
|
+
export interface CreateFieldOpts {
|
|
10
|
+
doc: Document;
|
|
11
|
+
/** The schema the leaf reads through: `quill.reader(doc).getContent` decodes by
|
|
12
|
+
* declared type, which is the only thing that says whether a stored string is
|
|
13
|
+
* markdown or literal text. */
|
|
14
|
+
quill: Quill;
|
|
15
|
+
addr: Addr;
|
|
16
|
+
container: HTMLElement;
|
|
17
|
+
/** Constrained single-textblock schema (a `richtext(inline)` field). */
|
|
18
|
+
inline?: boolean;
|
|
19
|
+
/** Inline + marks/islands stripped (a `plaintext` field). Implies `inline`. */
|
|
20
|
+
plaintext?: boolean;
|
|
21
|
+
/** Suppress the markdown-shorthand input rules. */
|
|
22
|
+
noInputRules?: boolean;
|
|
23
|
+
/** The island chrome's wording, read LIVE (per render) so a consumer swapping
|
|
24
|
+
* locale mid-session re-renders rather than freezing it at mount. Absent leaves
|
|
25
|
+
* the package's English; an inline leaf never asks (it holds no island). */
|
|
26
|
+
tableStrings?: () => TableChromeStrings;
|
|
27
|
+
/** The slash menu's wording, read live for the same reason. */
|
|
28
|
+
slashStrings?: () => SlashStrings;
|
|
29
|
+
/**
|
|
30
|
+
* The slash menu's live state, or `undefined` when it is closed: the channel the
|
|
31
|
+
* chrome draws from (`visual/SlashMenu.svelte`).
|
|
32
|
+
*
|
|
33
|
+
* Its PRESENCE is what mounts the menu at all. The trigger and its keys are the
|
|
34
|
+
* leaf's, but a surface only a keyboard can reach (claiming Enter, Escape and the
|
|
35
|
+
* arrows with nothing on screen) is worse than no surface, so the door exists
|
|
36
|
+
* exactly where something can draw it. A constrained inline leaf never has one:
|
|
37
|
+
* it holds no island and no block to convert.
|
|
38
|
+
*/
|
|
39
|
+
onSlash?(state: SlashState | undefined): void;
|
|
40
|
+
/** Accessible name → `aria-label` on the `contenteditable`. For a leaf NOTHING
|
|
41
|
+
* else names: an array element (the field label plus its 1-based index), the
|
|
42
|
+
* card body (no visible label at all). A leaf with a field label takes
|
|
43
|
+
* `labelledBy` instead: `for` cannot reach a `contenteditable`, which is not a
|
|
44
|
+
* labelable element, so the association runs the other way. */
|
|
45
|
+
label?: string;
|
|
46
|
+
/** The field label's own id → `aria-labelledby`, which supersedes `label`. */
|
|
47
|
+
labelledBy?: string;
|
|
48
|
+
/** The parked `description` → `aria-describedby`; announced after the name. */
|
|
49
|
+
describedBy?: string;
|
|
50
|
+
/** Ghost text shown on the empty leaf: the resolved `default:` a field ghosts,
|
|
51
|
+
* or a body's invitation. The INITIAL value; {@link
|
|
52
|
+
* FieldController.setPlaceholder} moves it after mount. Empty/absent
|
|
53
|
+
* shows no ghost. */
|
|
54
|
+
placeholder?: string;
|
|
55
|
+
onFocus?(addr: Addr): void;
|
|
56
|
+
/** Fired with the new USV caret after an edit or a selection move. */
|
|
57
|
+
onCaretMove?(addr: Addr, pos: number): void;
|
|
58
|
+
/**
|
|
59
|
+
* Fired after an edit COMMITTED to the document: the leaf's change signal, and
|
|
60
|
+
* the reason it is not `onCaretMove`. That one also fires on a bare selection
|
|
61
|
+
* move, so a host driving a recompile off it recompiles on every arrow key.
|
|
62
|
+
*
|
|
63
|
+
* Fires for every commit regardless of which branch it took (`applyChange`, the
|
|
64
|
+
* first-edit `overwrite`, the fallback `overwrite`) and for an anchor mutation,
|
|
65
|
+
* which changes no text. A commit that failed outright (`commit-lost`) does not
|
|
66
|
+
* fire it: nothing landed.
|
|
67
|
+
*/
|
|
68
|
+
onChange?(addr: Addr): void;
|
|
69
|
+
/** Recovered failures on the commit path ({@link EditorErrorHandler}). */
|
|
70
|
+
onError?: EditorErrorHandler;
|
|
71
|
+
}
|
|
72
|
+
/** The prose-leaf handle (VISUAL_EDITOR §Surface). */
|
|
73
|
+
export interface FieldController {
|
|
74
|
+
/** The mounted `container`: the leaf's element, for a caller that has to reach the
|
|
75
|
+
* DOM the view sits in (the arrival wash, `core/bloom.ts`) rather than re-find it. */
|
|
76
|
+
readonly el: HTMLElement;
|
|
77
|
+
/** Place the caret at USV `pos` (preview onCaretPick → usvToPM → here). */
|
|
78
|
+
setCaret(pos: number): void;
|
|
79
|
+
/** External content change → re-hydrate this leaf (gated by reconcile). */
|
|
80
|
+
applyExternal(): void;
|
|
81
|
+
/**
|
|
82
|
+
* Move the empty-leaf ghost after mount: a card retyped to another kind takes
|
|
83
|
+
* its new kind's wording without remounting, which it must not do (the leaf key
|
|
84
|
+
* is the card's session id, so a remount would cost the caret).
|
|
85
|
+
*
|
|
86
|
+
* Refreshes the decoration WITHOUT a transaction: a placeholder is chrome, and
|
|
87
|
+
* a transaction would fire `onCaretMove` at a moment the caret did not move.
|
|
88
|
+
*/
|
|
89
|
+
setPlaceholder(text: string | undefined): void;
|
|
90
|
+
focus(): void;
|
|
91
|
+
/** The current stored content for this addr (for tests / reconcile). */
|
|
92
|
+
getContent(): Content;
|
|
93
|
+
/**
|
|
94
|
+
* Insert an identity anchor `id` at USV `pos`: the seam that gives
|
|
95
|
+
* `anchor` the toggle the six formatting marks have. Zero-width; it folds into the
|
|
96
|
+
* plugin's position set and commits through the mark-diff `anchor` op, so it
|
|
97
|
+
* survives later edits like any anchor. The id is caller-supplied and must be
|
|
98
|
+
* unique + invariant (the 0.97 anchor-id policy); a duplicate id is a no-op.
|
|
99
|
+
*/
|
|
100
|
+
insertAnchor(id: string, pos: number): void;
|
|
101
|
+
/** Remove the identity anchor `id` (the inverse of {@link insertAnchor}); a no-op if absent. */
|
|
102
|
+
removeAnchor(id: string): void;
|
|
103
|
+
/** Ids of the anchors within USV range `[from, to]`: a selection's anchor state. */
|
|
104
|
+
anchorsInRange(from: number, to: number): string[];
|
|
105
|
+
/**
|
|
106
|
+
* Move the slash menu's cursor onto item `id`: what a POINTER entering an item
|
|
107
|
+
* calls. The keyboard cursor and the pointer's highlight are one state, so the
|
|
108
|
+
* chrome moves the one the keys already drive rather than painting a second.
|
|
109
|
+
*/
|
|
110
|
+
slashFocus(id: string): void;
|
|
111
|
+
/** Run slash item `id` (a click on one): the same path Enter takes. A no-op with
|
|
112
|
+
* no menu open, which is the state a stale click lands in. */
|
|
113
|
+
slashPick(id: string): void;
|
|
114
|
+
/** The current PM selection as a USV `{ from, to }` range (the boundary currency). */
|
|
115
|
+
selectionRange(): {
|
|
116
|
+
from: number;
|
|
117
|
+
to: number;
|
|
118
|
+
};
|
|
119
|
+
destroy(): void;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* The ProseMirror handles a chrome surface reaches a mounted leaf through. Not part
|
|
123
|
+
* of {@link FieldController}: they are PM internals, and a consumer's contract is the
|
|
124
|
+
* controller's verbs. The format popover is the one caller and it needs both:
|
|
125
|
+
* `view` to name the leaf's own document, `focusedView` to act on wherever the caret
|
|
126
|
+
* actually is, which inside a table island is a NESTED cell view (`table-view.ts`).
|
|
127
|
+
* The two being different is exactly what tells the popover to withhold its
|
|
128
|
+
* `anchor` button: an anchor is the FIELD's coordinate space, and a cell is not in it.
|
|
129
|
+
*/
|
|
130
|
+
export interface LeafViews {
|
|
131
|
+
view: EditorView;
|
|
132
|
+
focusedView(): EditorView;
|
|
133
|
+
/** Every view nested inside this leaf, in mount order: one per table cell. */
|
|
134
|
+
nestedViews(): EditorView[];
|
|
135
|
+
}
|
|
136
|
+
/** The canonical empty `Content`: one empty `para` line. The zero value a prose
|
|
137
|
+
* leaf (or an array's prose element) seeds from. */
|
|
138
|
+
export declare function emptyContent(): Content;
|
|
139
|
+
export declare function createField(opts: CreateFieldOpts): FieldController;
|
|
140
|
+
/**
|
|
141
|
+
* The prose-leaf plugin stack (VISUAL_EDITOR §Surface): shared by
|
|
142
|
+
* {@link createField} and the array-element inline editor (`ProseArrayElement`),
|
|
143
|
+
* so the two never fork the keymap/plugin ordering. History first, then any
|
|
144
|
+
* leaf-specific plugins (`afterHistory`: the addressed leaf passes its
|
|
145
|
+
* anchor-position plugin; a by-value array element passes none), the
|
|
146
|
+
* markdown-shorthand input rules, then the field keymap over the base keymap, and
|
|
147
|
+
* last the two that answer to a caret or a selection BESIDE a block: the gap cursor
|
|
148
|
+
* and {@link pastAtomPlugin}.
|
|
149
|
+
*
|
|
150
|
+
* A `plaintext` field carries no marks (decode strips them, the keymap suppresses
|
|
151
|
+
* Mod-b/i/u), so it also skips the input rules: `inlineSchema` still declares the
|
|
152
|
+
* mark types, so a `**bold**` rule would apply a strong mark AND eat the literal
|
|
153
|
+
* delimiters. The inline schema has no block nodes, so those rules are the ONLY
|
|
154
|
+
* ones it would add: skip all.
|
|
155
|
+
*/
|
|
156
|
+
export declare function proseLeafPlugins(schema: Schema, opts: {
|
|
157
|
+
inline: boolean;
|
|
158
|
+
plaintext: boolean;
|
|
159
|
+
/** The slash menu's model and its report channel; absent mounts no menu and
|
|
160
|
+
* leaves Enter/Escape/the arrows to the links below (`keymap.ts`). */
|
|
161
|
+
slash?: {
|
|
162
|
+
items: () => SlashItem[];
|
|
163
|
+
onState: (state: SlashState | undefined) => void;
|
|
164
|
+
};
|
|
165
|
+
noInputRules?: boolean;
|
|
166
|
+
/** Read live, not captured: the ghost can move after mount
|
|
167
|
+
* ({@link FieldController.setPlaceholder}), and a re-hydration rebuilds this
|
|
168
|
+
* stack, so the plugin must ask rather than hold. */
|
|
169
|
+
placeholder?: () => string | undefined;
|
|
170
|
+
afterHistory?: Plugin[];
|
|
171
|
+
}): Plugin[];
|