@codemerge/kernel 2.4.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 +21 -0
- package/dist/commands.d.cts +25 -0
- package/dist/commands.d.ts +25 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/document.d.cts +24 -0
- package/dist/document.d.ts +24 -0
- package/dist/document.d.ts.map +1 -0
- package/dist/history.d.cts +24 -0
- package/dist/history.d.ts +24 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/index.cjs +2034 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +12 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +1968 -0
- package/dist/index.mjs.map +1 -0
- package/dist/normalize.d.cts +5 -0
- package/dist/normalize.d.ts +5 -0
- package/dist/normalize.d.ts.map +1 -0
- package/dist/operations.d.cts +105 -0
- package/dist/operations.d.ts +105 -0
- package/dist/operations.d.ts.map +1 -0
- package/dist/schema.d.cts +23 -0
- package/dist/schema.d.ts +23 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/selection.d.cts +14 -0
- package/dist/selection.d.ts +14 -0
- package/dist/selection.d.ts.map +1 -0
- package/dist/structure.d.cts +29 -0
- package/dist/structure.d.ts +29 -0
- package/dist/structure.d.ts.map +1 -0
- package/dist/transaction.d.cts +18 -0
- package/dist/transaction.d.ts +18 -0
- package/dist/transaction.d.ts.map +1 -0
- package/dist/transform.d.cts +15 -0
- package/dist/transform.d.ts +15 -0
- package/dist/transform.d.ts.map +1 -0
- package/dist/types.d.cts +27 -0
- package/dist/types.d.ts +27 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +43 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1968 @@
|
|
|
1
|
+
//#region src/document.ts
|
|
2
|
+
var idCounter = 0;
|
|
3
|
+
function resetIdCounter(n = 0) {
|
|
4
|
+
idCounter = n;
|
|
5
|
+
}
|
|
6
|
+
function nextId(prefix = "n") {
|
|
7
|
+
idCounter += 1;
|
|
8
|
+
return `${prefix}${idCounter}`;
|
|
9
|
+
}
|
|
10
|
+
function createText(text, marks = []) {
|
|
11
|
+
return {
|
|
12
|
+
marks: marks.length > 0 ? marks.map((m) => ({ ...m })) : void 0,
|
|
13
|
+
text,
|
|
14
|
+
type: "text"
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function createParagraph(content = [createText("")], attrs) {
|
|
18
|
+
return {
|
|
19
|
+
attrs: copyAttrs(attrs),
|
|
20
|
+
content: [...content],
|
|
21
|
+
id: nextId("p"),
|
|
22
|
+
type: "paragraph"
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function createDoc(content) {
|
|
26
|
+
return {
|
|
27
|
+
content: content !== void 0 && content.length > 0 ? [...content] : [createParagraph()],
|
|
28
|
+
id: nextId("doc"),
|
|
29
|
+
type: "doc"
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function isAttrsRecord(value) {
|
|
33
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
34
|
+
}
|
|
35
|
+
/** Null-prototype attrs bag (no __proto__ pollution). */
|
|
36
|
+
function emptyAttrsRecord() {
|
|
37
|
+
const raw = Object.create(null);
|
|
38
|
+
if (!isAttrsRecord(raw)) return {};
|
|
39
|
+
return raw;
|
|
40
|
+
}
|
|
41
|
+
/** Copy own enumerable attrs onto a null-prototype bag (no __proto__ pollution). */
|
|
42
|
+
function copyAttrs(src) {
|
|
43
|
+
if (!src) return;
|
|
44
|
+
const out = emptyAttrsRecord();
|
|
45
|
+
for (const key of Object.keys(src)) out[key] = src[key];
|
|
46
|
+
return Object.keys(out).length > 0 ? out : void 0;
|
|
47
|
+
}
|
|
48
|
+
/** Shallow clone node; children array is new, child refs shared (structural sharing). */
|
|
49
|
+
function cloneNode(node) {
|
|
50
|
+
return {
|
|
51
|
+
...node,
|
|
52
|
+
attrs: copyAttrs(node.attrs),
|
|
53
|
+
content: node.content ? [...node.content] : void 0,
|
|
54
|
+
marks: node.marks ? node.marks.map((m) => ({
|
|
55
|
+
...m,
|
|
56
|
+
attrs: copyAttrs(m.attrs)
|
|
57
|
+
})) : void 0,
|
|
58
|
+
text: node.text
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/** Deep clone node tree (content recursively cloned). */
|
|
62
|
+
function deepCloneNode(node) {
|
|
63
|
+
const cloned = cloneNode(node);
|
|
64
|
+
if (cloned.content) cloned.content = cloned.content.map(deepCloneNode);
|
|
65
|
+
return cloned;
|
|
66
|
+
}
|
|
67
|
+
/** Assign stable ids to every non-text node missing `id`. */
|
|
68
|
+
function ensureNodeIds(node) {
|
|
69
|
+
const out = {
|
|
70
|
+
...node,
|
|
71
|
+
content: node.content ? node.content.map(ensureNodeIds) : void 0,
|
|
72
|
+
marks: node.marks ? node.marks.map((m) => ({ ...m })) : void 0
|
|
73
|
+
};
|
|
74
|
+
if (out.type !== "text" && !out.id) out.id = nextId(out.type === "doc" ? "doc" : out.type === "paragraph" ? "p" : out.type === "listItem" ? "li" : "n");
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
function deepClone(value) {
|
|
78
|
+
return structuredClone(value);
|
|
79
|
+
}
|
|
80
|
+
/** Document JSON format version — bump when the on-disk shape changes. */
|
|
81
|
+
var JSON_DOC_VERSION = 1;
|
|
82
|
+
function docToJSON(doc) {
|
|
83
|
+
return {
|
|
84
|
+
doc: deepClone(doc),
|
|
85
|
+
version: 1
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function isJSONDoc(value) {
|
|
89
|
+
if (!("version" in value) || !("doc" in value)) return false;
|
|
90
|
+
const doc = Reflect.get(value, "doc");
|
|
91
|
+
return typeof doc === "object" && doc !== null;
|
|
92
|
+
}
|
|
93
|
+
function isDocNode(value) {
|
|
94
|
+
return typeof Reflect.get(value, "type") === "string";
|
|
95
|
+
}
|
|
96
|
+
function docFromJSON(json) {
|
|
97
|
+
if (isJSONDoc(json)) {
|
|
98
|
+
if (json.version !== 1) throw new Error(`Unsupported JSONDoc version: ${json.version}`);
|
|
99
|
+
if (json.doc.type !== "doc") throw new Error("Expected root type \"doc\"");
|
|
100
|
+
assertTreeDepth(json.doc);
|
|
101
|
+
return ensureNodeIds(deepClone(json.doc));
|
|
102
|
+
}
|
|
103
|
+
if (isDocNode(json) && json.type === "doc") {
|
|
104
|
+
assertTreeDepth(json);
|
|
105
|
+
return ensureNodeIds(deepClone(json));
|
|
106
|
+
}
|
|
107
|
+
throw new Error("Expected root type \"doc\"");
|
|
108
|
+
}
|
|
109
|
+
function assertTreeDepth(node, max = 64) {
|
|
110
|
+
const stack = [{
|
|
111
|
+
n: node,
|
|
112
|
+
d: 0
|
|
113
|
+
}];
|
|
114
|
+
while (stack.length > 0) {
|
|
115
|
+
const { n, d } = stack.pop();
|
|
116
|
+
if (d > max) throw new Error(`Tree exceeds MAX_TREE_DEPTH (${max})`);
|
|
117
|
+
for (const child of n.content ?? []) stack.push({
|
|
118
|
+
n: child,
|
|
119
|
+
d: d + 1
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function getNodeAt(doc, path) {
|
|
124
|
+
let cur = doc;
|
|
125
|
+
for (const index of path) {
|
|
126
|
+
if (!cur.content || index < 0 || index >= cur.content.length) throw new Error(`Invalid path: ${path.join(".")}`);
|
|
127
|
+
cur = cur.content[index];
|
|
128
|
+
}
|
|
129
|
+
return cur;
|
|
130
|
+
}
|
|
131
|
+
function replaceAt(doc, path, next) {
|
|
132
|
+
if (path.length === 0) return next;
|
|
133
|
+
const [head, ...rest] = path, cloned = cloneNode(doc);
|
|
134
|
+
if (!cloned.content) throw new Error("Cannot replace in leaf");
|
|
135
|
+
cloned.content[head] = replaceAt(cloned.content[head], rest, next);
|
|
136
|
+
return cloned;
|
|
137
|
+
}
|
|
138
|
+
function textLength(node) {
|
|
139
|
+
if (node.type === "text") return node.text?.length ?? 0;
|
|
140
|
+
if (!node.content) return 0;
|
|
141
|
+
return node.content.reduce((sum, child) => sum + textLength(child), 0);
|
|
142
|
+
}
|
|
143
|
+
//#endregion
|
|
144
|
+
//#region src/selection.ts
|
|
145
|
+
function createSelection(anchor, focus = anchor) {
|
|
146
|
+
return {
|
|
147
|
+
anchor: {
|
|
148
|
+
...anchor,
|
|
149
|
+
path: [...anchor.path]
|
|
150
|
+
},
|
|
151
|
+
focus: {
|
|
152
|
+
...focus,
|
|
153
|
+
path: [...focus.path]
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function collapsedAt(path, offset) {
|
|
158
|
+
return createSelection({
|
|
159
|
+
offset,
|
|
160
|
+
path: [...path]
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
function isCollapsed(sel) {
|
|
164
|
+
return sel.anchor.offset === sel.focus.offset && sel.anchor.path.length === sel.focus.path.length && sel.anchor.path.every((v, i) => v === sel.focus.path[i]);
|
|
165
|
+
}
|
|
166
|
+
/** Resolve a point inside a paragraph path to ensure offset is within text bounds. */
|
|
167
|
+
function clampPoint(doc, point) {
|
|
168
|
+
try {
|
|
169
|
+
let path = [...point.path];
|
|
170
|
+
let node = getNodeAt(doc, path);
|
|
171
|
+
while (node.type === "text" && path.length > 0) {
|
|
172
|
+
path = path.slice(0, -1);
|
|
173
|
+
node = path.length === 0 ? doc : getNodeAt(doc, path);
|
|
174
|
+
}
|
|
175
|
+
if (path.length === 0 || node.type === "doc") return {
|
|
176
|
+
offset: 0,
|
|
177
|
+
path: [0]
|
|
178
|
+
};
|
|
179
|
+
const max = textLength(node);
|
|
180
|
+
return {
|
|
181
|
+
offset: Math.max(0, Math.min(point.offset, max)),
|
|
182
|
+
path
|
|
183
|
+
};
|
|
184
|
+
} catch {
|
|
185
|
+
return {
|
|
186
|
+
offset: 0,
|
|
187
|
+
path: [0]
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
function clampSelection(doc, sel) {
|
|
192
|
+
return {
|
|
193
|
+
anchor: clampPoint(doc, sel.anchor),
|
|
194
|
+
focus: clampPoint(doc, sel.focus)
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
/** Compare points: -1 if a < b, 0 equal, 1 if a > b (path then offset). */
|
|
198
|
+
function comparePoints(a, b) {
|
|
199
|
+
const len = Math.max(a.path.length, b.path.length);
|
|
200
|
+
for (let i = 0; i < len; i++) {
|
|
201
|
+
const av = a.path[i] ?? -1, bv = b.path[i] ?? -1;
|
|
202
|
+
if (av !== bv) return av < bv ? -1 : 1;
|
|
203
|
+
}
|
|
204
|
+
if (a.offset !== b.offset) return a.offset < b.offset ? -1 : 1;
|
|
205
|
+
return 0;
|
|
206
|
+
}
|
|
207
|
+
function orderedRange(sel) {
|
|
208
|
+
return comparePoints(sel.anchor, sel.focus) <= 0 ? {
|
|
209
|
+
from: sel.anchor,
|
|
210
|
+
to: sel.focus
|
|
211
|
+
} : {
|
|
212
|
+
from: sel.focus,
|
|
213
|
+
to: sel.anchor
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
//#endregion
|
|
217
|
+
//#region src/schema.ts
|
|
218
|
+
function createSchema() {
|
|
219
|
+
const schema = {
|
|
220
|
+
marks: /* @__PURE__ */ new Map(),
|
|
221
|
+
nodes: /* @__PURE__ */ new Map(),
|
|
222
|
+
sealed: false
|
|
223
|
+
};
|
|
224
|
+
registerNode(schema, {
|
|
225
|
+
group: "block",
|
|
226
|
+
name: "doc"
|
|
227
|
+
});
|
|
228
|
+
registerNode(schema, {
|
|
229
|
+
group: "block",
|
|
230
|
+
name: "paragraph"
|
|
231
|
+
});
|
|
232
|
+
registerNode(schema, {
|
|
233
|
+
group: "inline",
|
|
234
|
+
name: "text"
|
|
235
|
+
});
|
|
236
|
+
registerNode(schema, {
|
|
237
|
+
atom: true,
|
|
238
|
+
group: "inline",
|
|
239
|
+
name: "hardBreak"
|
|
240
|
+
});
|
|
241
|
+
registerMark(schema, { name: "bold" });
|
|
242
|
+
registerMark(schema, { name: "italic" });
|
|
243
|
+
registerMark(schema, { name: "underline" });
|
|
244
|
+
registerMark(schema, { name: "strike" });
|
|
245
|
+
return schema;
|
|
246
|
+
}
|
|
247
|
+
function registerNode(schema, spec) {
|
|
248
|
+
if (schema.sealed) throw new Error("Schema is sealed");
|
|
249
|
+
if (schema.nodes.has(spec.name)) throw new Error(`Node already registered: ${spec.name}`);
|
|
250
|
+
schema.nodes.set(spec.name, spec);
|
|
251
|
+
}
|
|
252
|
+
function registerMark(schema, spec) {
|
|
253
|
+
if (schema.sealed) throw new Error("Schema is sealed");
|
|
254
|
+
if (schema.marks.has(spec.name)) throw new Error(`Mark already registered: ${spec.name}`);
|
|
255
|
+
schema.marks.set(spec.name, spec);
|
|
256
|
+
}
|
|
257
|
+
function sealSchema(schema) {
|
|
258
|
+
schema.sealed = true;
|
|
259
|
+
}
|
|
260
|
+
function assertMark(schema, mark) {
|
|
261
|
+
if (!schema.marks.has(mark.type)) throw new Error(`Unknown mark: ${mark.type}`);
|
|
262
|
+
}
|
|
263
|
+
function assertNodeType(schema, type) {
|
|
264
|
+
if (!schema.nodes.has(type)) throw new Error(`Unknown node type: ${type}`);
|
|
265
|
+
}
|
|
266
|
+
//#endregion
|
|
267
|
+
//#region src/operations.ts
|
|
268
|
+
var BLOCK_CHILD_TYPES = /* @__PURE__ */ new Set([
|
|
269
|
+
"paragraph",
|
|
270
|
+
"heading",
|
|
271
|
+
"listItem",
|
|
272
|
+
"blockquote",
|
|
273
|
+
"codeBlock",
|
|
274
|
+
"bulletList",
|
|
275
|
+
"orderedList",
|
|
276
|
+
"table",
|
|
277
|
+
"tableRow",
|
|
278
|
+
"tableCell"
|
|
279
|
+
]);
|
|
280
|
+
function assertIntegerPath(path) {
|
|
281
|
+
for (const seg of path) if (!Number.isInteger(seg)) throw new TypeError(`Path segments must be integers: ${path.join(".")}`);
|
|
282
|
+
}
|
|
283
|
+
function assertLeafTextBlock(node) {
|
|
284
|
+
if (node.type === "text") throw new Error("Text ops require a leaf text block");
|
|
285
|
+
if ((node.content ?? []).some((c) => BLOCK_CHILD_TYPES.has(c.type))) throw new Error("Text ops require a leaf text block");
|
|
286
|
+
}
|
|
287
|
+
function flattenText(paragraph) {
|
|
288
|
+
const runs = [];
|
|
289
|
+
const keeps = [];
|
|
290
|
+
let text = "";
|
|
291
|
+
for (const child of paragraph.content ?? []) if (child.type === "text") {
|
|
292
|
+
const start = text.length;
|
|
293
|
+
const chunk = child.text ?? "";
|
|
294
|
+
text += chunk;
|
|
295
|
+
runs.push({
|
|
296
|
+
start,
|
|
297
|
+
end: text.length,
|
|
298
|
+
node: child
|
|
299
|
+
});
|
|
300
|
+
} else keeps.push({
|
|
301
|
+
at: text.length,
|
|
302
|
+
node: deepCloneNode(child)
|
|
303
|
+
});
|
|
304
|
+
return {
|
|
305
|
+
text,
|
|
306
|
+
runs,
|
|
307
|
+
keeps
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
function rebuildParagraph(paragraph, text, markAt, keeps = []) {
|
|
311
|
+
const keepsSorted = [...keeps].toSorted((a, b) => a.at - b.at);
|
|
312
|
+
if (text.length === 0) {
|
|
313
|
+
if (keepsSorted.length === 0) return {
|
|
314
|
+
...cloneNode(paragraph),
|
|
315
|
+
content: [createText("")]
|
|
316
|
+
};
|
|
317
|
+
return {
|
|
318
|
+
...cloneNode(paragraph),
|
|
319
|
+
content: keepsSorted.map((k) => deepCloneNode(k.node))
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
const content = [];
|
|
323
|
+
let keepIdx = 0;
|
|
324
|
+
let i = 0;
|
|
325
|
+
while (keepIdx < keepsSorted.length && keepsSorted[keepIdx].at <= 0) {
|
|
326
|
+
content.push(deepCloneNode(keepsSorted[keepIdx].node));
|
|
327
|
+
keepIdx++;
|
|
328
|
+
}
|
|
329
|
+
while (i < text.length) {
|
|
330
|
+
const marks = markAt(i);
|
|
331
|
+
let j = i + 1;
|
|
332
|
+
const nextKeepAt = keepsSorted[keepIdx]?.at ?? text.length + 1;
|
|
333
|
+
while (j < text.length && j < nextKeepAt && sameMarks(markAt(j), marks)) j++;
|
|
334
|
+
const end = Math.min(j, nextKeepAt, text.length);
|
|
335
|
+
if (end > i) {
|
|
336
|
+
content.push(createText(text.slice(i, end), marks));
|
|
337
|
+
i = end;
|
|
338
|
+
}
|
|
339
|
+
while (keepIdx < keepsSorted.length && keepsSorted[keepIdx].at === i) {
|
|
340
|
+
content.push(deepCloneNode(keepsSorted[keepIdx].node));
|
|
341
|
+
keepIdx++;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
while (keepIdx < keepsSorted.length && keepsSorted[keepIdx].at >= text.length) {
|
|
345
|
+
content.push(deepCloneNode(keepsSorted[keepIdx].node));
|
|
346
|
+
keepIdx++;
|
|
347
|
+
}
|
|
348
|
+
if (content.length === 0) content.push(createText(""));
|
|
349
|
+
return {
|
|
350
|
+
...cloneNode(paragraph),
|
|
351
|
+
content
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
function shiftKeeps(keeps, offset, delta) {
|
|
355
|
+
return keeps.map((k) => k.at >= offset ? {
|
|
356
|
+
...k,
|
|
357
|
+
at: k.at + delta
|
|
358
|
+
} : k);
|
|
359
|
+
}
|
|
360
|
+
function deleteKeepsInRange(keeps, offset, length) {
|
|
361
|
+
const end = offset + length;
|
|
362
|
+
return keeps.filter((k) => k.at <= offset || k.at >= end).map((k) => k.at >= end ? {
|
|
363
|
+
...k,
|
|
364
|
+
at: k.at - length
|
|
365
|
+
} : k);
|
|
366
|
+
}
|
|
367
|
+
function captureKeepsInRange(keeps, from, to) {
|
|
368
|
+
return keeps.filter((k) => k.at > from && k.at < to).map((k) => ({
|
|
369
|
+
at: k.at - from,
|
|
370
|
+
node: deepCloneNode(k.node)
|
|
371
|
+
}));
|
|
372
|
+
}
|
|
373
|
+
function mergeSliceKeeps(base, from, sliceKeeps) {
|
|
374
|
+
if (sliceKeeps === void 0 || sliceKeeps.length === 0) return base;
|
|
375
|
+
return [...base, ...sliceKeeps.map((k) => ({
|
|
376
|
+
at: from + k.at,
|
|
377
|
+
node: deepCloneNode(k.node)
|
|
378
|
+
}))];
|
|
379
|
+
}
|
|
380
|
+
function assertOpSchema(schema, op) {
|
|
381
|
+
if (!schema) return;
|
|
382
|
+
if (op.type === "set_mark") assertMark(schema, op.mark);
|
|
383
|
+
else if (op.type === "insert_text") {
|
|
384
|
+
if (op.marks) for (const m of op.marks) assertMark(schema, m);
|
|
385
|
+
if (op.marksByOffset) for (const ms of op.marksByOffset) for (const m of ms) assertMark(schema, m);
|
|
386
|
+
} else if (op.type === "replace_slice") {
|
|
387
|
+
for (const run of op.runs) for (const m of run.marks) assertMark(schema, m);
|
|
388
|
+
for (const k of op.keeps ?? []) {
|
|
389
|
+
assertMaxTreeDepth(k.node);
|
|
390
|
+
assertNodeTree(schema, k.node);
|
|
391
|
+
}
|
|
392
|
+
} else if (op.type === "split_paragraph") {
|
|
393
|
+
if (op.rightNode) {
|
|
394
|
+
assertMaxTreeDepth(op.rightNode);
|
|
395
|
+
assertNodeTree(schema, op.rightNode);
|
|
396
|
+
}
|
|
397
|
+
} else if (op.type === "insert_node") assertNodeTree(schema, op.node);
|
|
398
|
+
}
|
|
399
|
+
function assertNodeTree(schema, node, depthChecked = false) {
|
|
400
|
+
if (!depthChecked) assertMaxTreeDepth(node);
|
|
401
|
+
assertNodeType(schema, node.type);
|
|
402
|
+
if (node.marks) for (const m of node.marks) assertMark(schema, m);
|
|
403
|
+
for (const child of node.content ?? []) assertNodeTree(schema, child, true);
|
|
404
|
+
}
|
|
405
|
+
function sameMarks(a, b) {
|
|
406
|
+
if (a.length !== b.length) return false;
|
|
407
|
+
return a.every((m, i) => m.type === b[i].type && JSON.stringify(m.attrs ?? {}) === JSON.stringify(b[i].attrs ?? {}));
|
|
408
|
+
}
|
|
409
|
+
function cloneMarks(marks) {
|
|
410
|
+
return marks.map((m) => ({
|
|
411
|
+
...m,
|
|
412
|
+
attrs: m.attrs ? { ...m.attrs } : void 0
|
|
413
|
+
}));
|
|
414
|
+
}
|
|
415
|
+
function marksAt(runs, index) {
|
|
416
|
+
for (const run of runs) if (index >= run.start && index < run.end) return run.node.marks ? cloneMarks(run.node.marks) : [];
|
|
417
|
+
const lastRun = runs.at(-1);
|
|
418
|
+
if (lastRun && index === lastRun.end) return lastRun.node.marks ? cloneMarks(lastRun.node.marks) : [];
|
|
419
|
+
return [];
|
|
420
|
+
}
|
|
421
|
+
function captureRuns(text, runs, from, to) {
|
|
422
|
+
const result = [];
|
|
423
|
+
let i = from;
|
|
424
|
+
while (i < to) {
|
|
425
|
+
const marks = marksAt(runs, i);
|
|
426
|
+
let j = i + 1;
|
|
427
|
+
while (j < to && sameMarks(marksAt(runs, j), marks)) j++;
|
|
428
|
+
result.push({
|
|
429
|
+
text: text.slice(i, j),
|
|
430
|
+
marks
|
|
431
|
+
});
|
|
432
|
+
i = j;
|
|
433
|
+
}
|
|
434
|
+
return result;
|
|
435
|
+
}
|
|
436
|
+
/** True when every offset in `[from, to)` carries `markType`. Empty range → false. */
|
|
437
|
+
function rangeHasMark(paragraph, from, to, markType) {
|
|
438
|
+
const { text, runs } = flattenText(paragraph);
|
|
439
|
+
const start = Math.max(0, Math.min(from, text.length));
|
|
440
|
+
const end = Math.max(start, Math.min(to, text.length));
|
|
441
|
+
if (start >= end) return false;
|
|
442
|
+
for (let i = start; i < end; i++) if (!marksAt(runs, i).some((m) => m.type === markType)) return false;
|
|
443
|
+
return true;
|
|
444
|
+
}
|
|
445
|
+
/** Mark presence at a single offset (caret / left of caret). */
|
|
446
|
+
function offsetHasMark(paragraph, offset, markType) {
|
|
447
|
+
const { text, runs } = flattenText(paragraph);
|
|
448
|
+
if (text.length === 0) return false;
|
|
449
|
+
return marksAt(runs, Math.max(0, Math.min(offset, text.length - 1))).some((m) => m.type === markType);
|
|
450
|
+
}
|
|
451
|
+
/** Merge marks by type — `extra` wins on conflict. */
|
|
452
|
+
function mergeMarksByType(base, extra) {
|
|
453
|
+
const map = /* @__PURE__ */ new Map();
|
|
454
|
+
for (const m of base) map.set(m.type, {
|
|
455
|
+
...m,
|
|
456
|
+
attrs: m.attrs ? { ...m.attrs } : void 0
|
|
457
|
+
});
|
|
458
|
+
for (const m of extra) map.set(m.type, {
|
|
459
|
+
...m,
|
|
460
|
+
attrs: m.attrs ? { ...m.attrs } : void 0
|
|
461
|
+
});
|
|
462
|
+
return [...map.values()];
|
|
463
|
+
}
|
|
464
|
+
/** Hard cap on a single insert_text / replace_slice payload (paste / programmatic flood). */
|
|
465
|
+
var MAX_INSERT_CHARS = 1e5;
|
|
466
|
+
/** Cap on keep nodes injected via replace_slice (hardBreak floods). */
|
|
467
|
+
var MAX_SLICE_KEEPS = 1e4;
|
|
468
|
+
/** Max nesting depth for op/JSON trees (empty structural bombs). */
|
|
469
|
+
var MAX_TREE_DEPTH = 64;
|
|
470
|
+
/** Iterative depth check — fail closed before recursive clone/walk. */
|
|
471
|
+
function assertMaxTreeDepth(node, max = 64) {
|
|
472
|
+
const stack = [{
|
|
473
|
+
n: node,
|
|
474
|
+
d: 0
|
|
475
|
+
}];
|
|
476
|
+
while (stack.length > 0) {
|
|
477
|
+
const { n, d } = stack.pop();
|
|
478
|
+
if (d > max) throw new Error(`Tree exceeds MAX_TREE_DEPTH (${max})`);
|
|
479
|
+
for (const child of n.content ?? []) stack.push({
|
|
480
|
+
n: child,
|
|
481
|
+
d: d + 1
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
function applyOp(doc, op, schema) {
|
|
486
|
+
if ("path" in op) assertIntegerPath(op.path);
|
|
487
|
+
assertOpSchema(schema, op);
|
|
488
|
+
switch (op.type) {
|
|
489
|
+
case "insert_text": {
|
|
490
|
+
if (op.text.length > 1e5) throw new Error(`insert_text exceeds MAX_INSERT_CHARS (${MAX_INSERT_CHARS})`);
|
|
491
|
+
const para = getNodeAt(doc, op.path);
|
|
492
|
+
assertLeafTextBlock(para);
|
|
493
|
+
const { text, runs, keeps } = flattenText(para);
|
|
494
|
+
const offset = Math.max(0, Math.min(op.offset, text.length));
|
|
495
|
+
const nextPara = rebuildParagraph(para, text.slice(0, offset) + op.text + text.slice(offset), (i) => {
|
|
496
|
+
if (i >= offset && i < offset + op.text.length) {
|
|
497
|
+
if (op.marksByOffset) return cloneMarks(op.marksByOffset[i - offset] ?? []);
|
|
498
|
+
const inherited = marksAt(runs, Math.max(0, offset - 1));
|
|
499
|
+
return op.marks && op.marks.length > 0 ? mergeMarksByType(inherited, op.marks) : inherited;
|
|
500
|
+
}
|
|
501
|
+
const src = i < offset ? i : i - op.text.length;
|
|
502
|
+
return marksAt(runs, src);
|
|
503
|
+
}, shiftKeeps(keeps, offset, op.text.length));
|
|
504
|
+
return {
|
|
505
|
+
doc: replaceAt(doc, op.path, nextPara),
|
|
506
|
+
inverse: {
|
|
507
|
+
type: "delete_text",
|
|
508
|
+
path: op.path,
|
|
509
|
+
offset,
|
|
510
|
+
length: op.text.length
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
case "delete_text": {
|
|
515
|
+
const para = getNodeAt(doc, op.path);
|
|
516
|
+
assertLeafTextBlock(para);
|
|
517
|
+
const { text, runs, keeps } = flattenText(para);
|
|
518
|
+
const offset = Math.max(0, Math.min(op.offset, text.length));
|
|
519
|
+
const length = Math.min(op.length, text.length - offset);
|
|
520
|
+
const deletedKeeps = captureKeepsInRange(keeps, offset, offset + length);
|
|
521
|
+
const captured = captureRuns(text, runs, offset, offset + length);
|
|
522
|
+
const nextPara = rebuildParagraph(para, text.slice(0, offset) + text.slice(offset + length), (i) => {
|
|
523
|
+
const src = i < offset ? i : i + length;
|
|
524
|
+
return marksAt(runs, src);
|
|
525
|
+
}, deleteKeepsInRange(keeps, offset, length));
|
|
526
|
+
return {
|
|
527
|
+
doc: replaceAt(doc, op.path, nextPara),
|
|
528
|
+
inverse: {
|
|
529
|
+
type: "replace_slice",
|
|
530
|
+
path: op.path,
|
|
531
|
+
from: offset,
|
|
532
|
+
to: offset,
|
|
533
|
+
runs: captured,
|
|
534
|
+
keeps: deletedKeeps
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
case "split_paragraph": {
|
|
539
|
+
const parentPath = op.path.slice(0, -1);
|
|
540
|
+
const index = op.path.at(-1);
|
|
541
|
+
if (index === void 0) throw new Error("split_paragraph path is empty");
|
|
542
|
+
assertIntegerPath(parentPath);
|
|
543
|
+
const parent = parentPath.length > 0 ? getNodeAt(doc, parentPath) : doc;
|
|
544
|
+
const para = getNodeAt(doc, op.path);
|
|
545
|
+
assertLeafTextBlock(para);
|
|
546
|
+
const { text, runs, keeps } = flattenText(para);
|
|
547
|
+
const offset = Math.max(0, Math.min(op.offset, text.length));
|
|
548
|
+
const leftKeeps = keeps.filter((k) => k.at < offset);
|
|
549
|
+
const left = rebuildParagraph(para, text.slice(0, offset), (i) => marksAt(runs, i), leftKeeps);
|
|
550
|
+
let rightRebuilt;
|
|
551
|
+
if (op.rightNode) rightRebuilt = deepCloneNode(op.rightNode);
|
|
552
|
+
else {
|
|
553
|
+
const rightKeeps = keeps.filter((k) => k.at >= offset).map((k) => ({
|
|
554
|
+
...k,
|
|
555
|
+
at: k.at - offset
|
|
556
|
+
}));
|
|
557
|
+
rightRebuilt = rebuildParagraph({
|
|
558
|
+
...cloneNode(para),
|
|
559
|
+
content: [createText("")],
|
|
560
|
+
id: nextId(para.type === "listItem" ? "li" : "p")
|
|
561
|
+
}, text.slice(offset), (i) => marksAt(runs, offset + i), rightKeeps);
|
|
562
|
+
}
|
|
563
|
+
const nextParent = cloneNode(parent);
|
|
564
|
+
if (!nextParent.content) throw new Error("split parent has no content");
|
|
565
|
+
nextParent.content.splice(index, 1, left, rightRebuilt);
|
|
566
|
+
return {
|
|
567
|
+
doc: parentPath.length > 0 ? replaceAt(doc, parentPath, nextParent) : nextParent,
|
|
568
|
+
inverse: {
|
|
569
|
+
type: "merge_paragraph",
|
|
570
|
+
path: [...parentPath, index + 1]
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
case "merge_paragraph": {
|
|
575
|
+
const parentPath = op.path.slice(0, -1);
|
|
576
|
+
const index = op.path.at(-1);
|
|
577
|
+
if (index === void 0 || index <= 0) throw new Error("Cannot merge first paragraph");
|
|
578
|
+
assertIntegerPath(parentPath);
|
|
579
|
+
const parent = parentPath.length > 0 ? getNodeAt(doc, parentPath) : doc;
|
|
580
|
+
const left = getNodeAt(doc, [...parentPath, index - 1]);
|
|
581
|
+
const right = getNodeAt(doc, op.path);
|
|
582
|
+
assertLeafTextBlock(left);
|
|
583
|
+
assertLeafTextBlock(right);
|
|
584
|
+
const rightSnap = deepCloneNode(right);
|
|
585
|
+
const leftFlat = flattenText(left);
|
|
586
|
+
const rightFlat = flattenText(right);
|
|
587
|
+
const mergedText = leftFlat.text + rightFlat.text;
|
|
588
|
+
const joinAt = leftFlat.text.length;
|
|
589
|
+
const merged = rebuildParagraph(left, mergedText, (i) => {
|
|
590
|
+
if (i < joinAt) return marksAt(leftFlat.runs, i);
|
|
591
|
+
return marksAt(rightFlat.runs, i - joinAt);
|
|
592
|
+
}, [...leftFlat.keeps, ...rightFlat.keeps.map((k) => ({
|
|
593
|
+
...k,
|
|
594
|
+
at: k.at + joinAt
|
|
595
|
+
}))]);
|
|
596
|
+
const nextParent = cloneNode(parent);
|
|
597
|
+
if (!nextParent.content) throw new Error("merge parent has no content");
|
|
598
|
+
nextParent.content.splice(index - 1, 2, merged);
|
|
599
|
+
return {
|
|
600
|
+
doc: parentPath.length > 0 ? replaceAt(doc, parentPath, nextParent) : nextParent,
|
|
601
|
+
inverse: {
|
|
602
|
+
type: "split_paragraph",
|
|
603
|
+
path: [...parentPath, index - 1],
|
|
604
|
+
offset: joinAt,
|
|
605
|
+
rightNode: rightSnap
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
case "set_selection": return {
|
|
610
|
+
doc,
|
|
611
|
+
inverse: op
|
|
612
|
+
};
|
|
613
|
+
case "set_attrs": {
|
|
614
|
+
const node = getNodeAt(doc, op.path);
|
|
615
|
+
const prevSnapshot = copyAttrs(node.attrs) ?? emptyAttrsRecord();
|
|
616
|
+
let nextAttrs;
|
|
617
|
+
if (op.replace) nextAttrs = copyAttrs(op.attrs) ?? emptyAttrsRecord();
|
|
618
|
+
else {
|
|
619
|
+
nextAttrs = copyAttrs(node.attrs) ?? emptyAttrsRecord();
|
|
620
|
+
for (const [key, value] of Object.entries(op.attrs)) if (value === null) delete nextAttrs[key];
|
|
621
|
+
else nextAttrs[key] = value;
|
|
622
|
+
}
|
|
623
|
+
const next = {
|
|
624
|
+
...cloneNode(node),
|
|
625
|
+
attrs: Object.keys(nextAttrs).length > 0 ? nextAttrs : void 0
|
|
626
|
+
};
|
|
627
|
+
return {
|
|
628
|
+
doc: replaceAt(doc, op.path, next),
|
|
629
|
+
inverse: {
|
|
630
|
+
type: "set_attrs",
|
|
631
|
+
path: op.path,
|
|
632
|
+
attrs: prevSnapshot,
|
|
633
|
+
replace: true
|
|
634
|
+
}
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
case "set_mark": {
|
|
638
|
+
const para = getNodeAt(doc, op.path);
|
|
639
|
+
assertLeafTextBlock(para);
|
|
640
|
+
const { text, runs, keeps } = flattenText(para);
|
|
641
|
+
const from = Math.max(0, Math.min(op.from, text.length));
|
|
642
|
+
const to = Math.max(from, Math.min(op.to, text.length));
|
|
643
|
+
const captured = captureRuns(text, runs, from, to);
|
|
644
|
+
const lostKeeps = captureKeepsInRange(keeps, from, to);
|
|
645
|
+
const nextPara = rebuildParagraph(para, text, (i) => {
|
|
646
|
+
const base = marksAt(runs, i);
|
|
647
|
+
if (i >= from && i < to) return [...base.filter((m) => m.type !== op.mark.type), {
|
|
648
|
+
...op.mark,
|
|
649
|
+
attrs: copyAttrs(op.mark.attrs)
|
|
650
|
+
}];
|
|
651
|
+
return base;
|
|
652
|
+
}, keeps);
|
|
653
|
+
return {
|
|
654
|
+
doc: replaceAt(doc, op.path, nextPara),
|
|
655
|
+
inverse: {
|
|
656
|
+
type: "replace_slice",
|
|
657
|
+
path: op.path,
|
|
658
|
+
from,
|
|
659
|
+
to,
|
|
660
|
+
runs: captured,
|
|
661
|
+
keeps: lostKeeps
|
|
662
|
+
}
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
case "remove_mark": {
|
|
666
|
+
const para = getNodeAt(doc, op.path);
|
|
667
|
+
assertLeafTextBlock(para);
|
|
668
|
+
const { text, runs, keeps } = flattenText(para);
|
|
669
|
+
const from = Math.max(0, Math.min(op.from, text.length));
|
|
670
|
+
const to = Math.max(from, Math.min(op.to, text.length));
|
|
671
|
+
const captured = captureRuns(text, runs, from, to);
|
|
672
|
+
const lostKeeps = captureKeepsInRange(keeps, from, to);
|
|
673
|
+
const nextPara = rebuildParagraph(para, text, (i) => {
|
|
674
|
+
const base = marksAt(runs, i);
|
|
675
|
+
if (i >= from && i < to) return base.filter((m) => m.type !== op.markType);
|
|
676
|
+
return base;
|
|
677
|
+
}, keeps);
|
|
678
|
+
return {
|
|
679
|
+
doc: replaceAt(doc, op.path, nextPara),
|
|
680
|
+
inverse: {
|
|
681
|
+
type: "replace_slice",
|
|
682
|
+
path: op.path,
|
|
683
|
+
from,
|
|
684
|
+
to,
|
|
685
|
+
runs: captured,
|
|
686
|
+
keeps: lostKeeps
|
|
687
|
+
}
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
case "replace_slice": {
|
|
691
|
+
const para = getNodeAt(doc, op.path);
|
|
692
|
+
assertLeafTextBlock(para);
|
|
693
|
+
const { text, runs, keeps } = flattenText(para);
|
|
694
|
+
const from = Math.max(0, Math.min(op.from, text.length));
|
|
695
|
+
const to = Math.max(from, Math.min(op.to, text.length));
|
|
696
|
+
const captured = captureRuns(text, runs, from, to);
|
|
697
|
+
const lostKeeps = captureKeepsInRange(keeps, from, to);
|
|
698
|
+
const newText = op.runs.map((r) => r.text).join("");
|
|
699
|
+
if (newText.length > 1e5) throw new Error(`replace_slice exceeds MAX_INSERT_CHARS (${MAX_INSERT_CHARS})`);
|
|
700
|
+
if ((op.keeps?.length ?? 0) > 1e4) throw new Error(`replace_slice exceeds MAX_SLICE_KEEPS (${MAX_SLICE_KEEPS})`);
|
|
701
|
+
const insertedMarks = [];
|
|
702
|
+
for (const run of op.runs) {
|
|
703
|
+
const marks = cloneMarks(run.marks);
|
|
704
|
+
for (const _ of run.text) insertedMarks.push(cloneMarks(marks));
|
|
705
|
+
}
|
|
706
|
+
const nextText = text.slice(0, from) + newText + text.slice(to);
|
|
707
|
+
const delta = newText.length - (to - from);
|
|
708
|
+
const nextPara = rebuildParagraph(para, nextText, (i) => {
|
|
709
|
+
if (i >= from && i < from + newText.length) return insertedMarks[i - from] ?? [];
|
|
710
|
+
const src = i < from ? i : i - delta;
|
|
711
|
+
return marksAt(runs, src);
|
|
712
|
+
}, mergeSliceKeeps(keeps.filter((k) => k.at <= from || k.at >= to).map((k) => k.at >= to ? {
|
|
713
|
+
...k,
|
|
714
|
+
at: k.at + delta
|
|
715
|
+
} : k), from, op.keeps));
|
|
716
|
+
return {
|
|
717
|
+
doc: replaceAt(doc, op.path, nextPara),
|
|
718
|
+
inverse: {
|
|
719
|
+
type: "replace_slice",
|
|
720
|
+
path: op.path,
|
|
721
|
+
from,
|
|
722
|
+
to: from + newText.length,
|
|
723
|
+
runs: captured,
|
|
724
|
+
keeps: lostKeeps
|
|
725
|
+
}
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
case "insert_node": {
|
|
729
|
+
assertMaxTreeDepth(op.node);
|
|
730
|
+
if (textLength(op.node) > 1e5) throw new Error(`insert_node exceeds MAX_INSERT_CHARS (${MAX_INSERT_CHARS})`);
|
|
731
|
+
const nextParent = cloneNode(op.path.length > 0 ? getNodeAt(doc, op.path) : doc);
|
|
732
|
+
nextParent.content ?? (nextParent.content = []);
|
|
733
|
+
const index = Math.max(0, Math.min(op.index, nextParent.content.length));
|
|
734
|
+
nextParent.content.splice(index, 0, ensureNodeIds(deepCloneNode(op.node)));
|
|
735
|
+
return {
|
|
736
|
+
doc: op.path.length > 0 ? replaceAt(doc, op.path, nextParent) : nextParent,
|
|
737
|
+
inverse: {
|
|
738
|
+
type: "remove_node",
|
|
739
|
+
path: op.path,
|
|
740
|
+
index
|
|
741
|
+
}
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
case "remove_node": {
|
|
745
|
+
const nextParent = cloneNode(op.path.length > 0 ? getNodeAt(doc, op.path) : doc);
|
|
746
|
+
if (!nextParent.content || op.index < 0 || op.index >= nextParent.content.length) throw new Error("remove_node out of bounds");
|
|
747
|
+
const [removed] = nextParent.content.splice(op.index, 1);
|
|
748
|
+
return {
|
|
749
|
+
doc: op.path.length > 0 ? replaceAt(doc, op.path, nextParent) : nextParent,
|
|
750
|
+
inverse: {
|
|
751
|
+
type: "insert_node",
|
|
752
|
+
path: op.path,
|
|
753
|
+
index: op.index,
|
|
754
|
+
node: removed
|
|
755
|
+
}
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
default: throw new Error(`Unknown op: ${JSON.stringify(op)}`);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
function applyOps(doc, ops, selection, schema) {
|
|
762
|
+
let current = doc;
|
|
763
|
+
let sel = selection;
|
|
764
|
+
const inverses = [];
|
|
765
|
+
for (const op of ops) {
|
|
766
|
+
if (op.type === "set_selection") {
|
|
767
|
+
inverses.push({
|
|
768
|
+
type: "set_selection",
|
|
769
|
+
selection: sel
|
|
770
|
+
});
|
|
771
|
+
sel = clampSelection(current, op.selection);
|
|
772
|
+
continue;
|
|
773
|
+
}
|
|
774
|
+
const result = applyOp(current, op, schema);
|
|
775
|
+
current = result.doc;
|
|
776
|
+
inverses.push(result.inverse);
|
|
777
|
+
if (op.type === "insert_text") sel = collapsedAt(op.path, op.offset + op.text.length);
|
|
778
|
+
else if (op.type === "delete_text") sel = collapsedAt(op.path, op.offset);
|
|
779
|
+
else if (op.type === "split_paragraph") {
|
|
780
|
+
const parentPath = op.path.slice(0, -1);
|
|
781
|
+
const index = op.path.at(-1);
|
|
782
|
+
if (index === void 0) throw new Error("split_paragraph path is empty");
|
|
783
|
+
sel = collapsedAt([...parentPath, index + 1], 0);
|
|
784
|
+
} else if (op.type === "merge_paragraph") {
|
|
785
|
+
const inv = result.inverse;
|
|
786
|
+
if (inv.type === "split_paragraph") sel = collapsedAt(inv.path, inv.offset);
|
|
787
|
+
} else if (op.type === "insert_node") {
|
|
788
|
+
const index = result.inverse.type === "remove_node" ? result.inverse.index : op.index;
|
|
789
|
+
sel = {
|
|
790
|
+
anchor: {
|
|
791
|
+
offset: sel.anchor.offset,
|
|
792
|
+
path: remapPathSibling(sel.anchor.path, op.path, index, 1)
|
|
793
|
+
},
|
|
794
|
+
focus: {
|
|
795
|
+
offset: sel.focus.offset,
|
|
796
|
+
path: remapPathSibling(sel.focus.path, op.path, index, 1)
|
|
797
|
+
}
|
|
798
|
+
};
|
|
799
|
+
sel = clampSelection(current, sel);
|
|
800
|
+
} else if (op.type === "remove_node") {
|
|
801
|
+
sel = {
|
|
802
|
+
anchor: {
|
|
803
|
+
offset: sel.anchor.offset,
|
|
804
|
+
path: remapPathSibling(sel.anchor.path, op.path, op.index, -1)
|
|
805
|
+
},
|
|
806
|
+
focus: {
|
|
807
|
+
offset: sel.focus.offset,
|
|
808
|
+
path: remapPathSibling(sel.focus.path, op.path, op.index, -1)
|
|
809
|
+
}
|
|
810
|
+
};
|
|
811
|
+
sel = clampSelection(current, sel);
|
|
812
|
+
} else sel = clampSelection(current, sel);
|
|
813
|
+
}
|
|
814
|
+
return {
|
|
815
|
+
doc: current,
|
|
816
|
+
selection: clampSelection(current, sel),
|
|
817
|
+
inverses: inverses.toReversed()
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
function remapPathSibling(path, parent, index, delta) {
|
|
821
|
+
const d = parent.length;
|
|
822
|
+
if (path.length <= d) return path;
|
|
823
|
+
for (let i = 0; i < d; i++) if (path[i] !== parent[i]) return path;
|
|
824
|
+
const at = path[d];
|
|
825
|
+
if (at === void 0) return path;
|
|
826
|
+
if (delta < 0 && at === index) return path;
|
|
827
|
+
if (delta > 0 && at >= index || delta < 0 && at > index) {
|
|
828
|
+
const next = [...path];
|
|
829
|
+
next[d] = at + delta;
|
|
830
|
+
return next;
|
|
831
|
+
}
|
|
832
|
+
return path;
|
|
833
|
+
}
|
|
834
|
+
function selectionTextRange(sel) {
|
|
835
|
+
const { from, to } = orderedRange(sel);
|
|
836
|
+
if (from.path.join(".") !== to.path.join(".")) return null;
|
|
837
|
+
return {
|
|
838
|
+
path: from.path,
|
|
839
|
+
from: from.offset,
|
|
840
|
+
to: to.offset
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
//#endregion
|
|
844
|
+
//#region src/transform.ts
|
|
845
|
+
function pathsEqual(a, b) {
|
|
846
|
+
if (a.length !== b.length) return false;
|
|
847
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
848
|
+
return true;
|
|
849
|
+
}
|
|
850
|
+
function pathPrefix(longer, shorter) {
|
|
851
|
+
if (longer.length < shorter.length) return false;
|
|
852
|
+
for (let i = 0; i < shorter.length; i++) if (longer[i] !== shorter[i]) return false;
|
|
853
|
+
return true;
|
|
854
|
+
}
|
|
855
|
+
/** Shift path index at `depth` by `delta` when the path is at/after `index`. */
|
|
856
|
+
function shiftPath(path, parentPath, index, delta) {
|
|
857
|
+
if (!pathPrefix(path, parentPath) || path.length <= parentPath.length) return path;
|
|
858
|
+
const at = parentPath.length;
|
|
859
|
+
const cur = path[at];
|
|
860
|
+
if (cur === void 0 || cur < index) return path;
|
|
861
|
+
const next = path.slice();
|
|
862
|
+
next[at] = cur + delta;
|
|
863
|
+
return next;
|
|
864
|
+
}
|
|
865
|
+
function mapPath(op, map) {
|
|
866
|
+
switch (op.type) {
|
|
867
|
+
case "set_selection": return op;
|
|
868
|
+
case "insert_text":
|
|
869
|
+
case "delete_text":
|
|
870
|
+
case "split_paragraph":
|
|
871
|
+
case "merge_paragraph":
|
|
872
|
+
case "set_mark":
|
|
873
|
+
case "remove_mark":
|
|
874
|
+
case "replace_slice":
|
|
875
|
+
case "set_attrs":
|
|
876
|
+
case "insert_node":
|
|
877
|
+
case "remove_node": return {
|
|
878
|
+
...op,
|
|
879
|
+
path: map(op.path)
|
|
880
|
+
};
|
|
881
|
+
default: return op;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
function mapOffset(op, path, map) {
|
|
885
|
+
if (!("path" in op) || !pathsEqual(op.path, path)) return op;
|
|
886
|
+
switch (op.type) {
|
|
887
|
+
case "insert_text": return {
|
|
888
|
+
...op,
|
|
889
|
+
offset: map(op.offset)
|
|
890
|
+
};
|
|
891
|
+
case "delete_text": {
|
|
892
|
+
const start = map(op.offset);
|
|
893
|
+
const end = map(op.offset + op.length);
|
|
894
|
+
return {
|
|
895
|
+
...op,
|
|
896
|
+
offset: start,
|
|
897
|
+
length: Math.max(0, end - start)
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
case "split_paragraph": return {
|
|
901
|
+
...op,
|
|
902
|
+
offset: map(op.offset)
|
|
903
|
+
};
|
|
904
|
+
case "set_mark":
|
|
905
|
+
case "remove_mark":
|
|
906
|
+
case "replace_slice": {
|
|
907
|
+
const from = map(op.from);
|
|
908
|
+
const to = map(op.to);
|
|
909
|
+
return {
|
|
910
|
+
...op,
|
|
911
|
+
from,
|
|
912
|
+
to: Math.max(from, to)
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
case "merge_paragraph":
|
|
916
|
+
case "insert_node":
|
|
917
|
+
case "remove_node":
|
|
918
|
+
case "set_attrs": return op;
|
|
919
|
+
default: return op;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
/**
|
|
923
|
+
* Transform `op` so it can be applied after `against` was already applied
|
|
924
|
+
* (ProseMirror-style: rebase client op against server op).
|
|
925
|
+
* Returns null if `op` becomes a no-op.
|
|
926
|
+
*/
|
|
927
|
+
function transformOp(op, against) {
|
|
928
|
+
if (op.type === "set_selection" || against.type === "set_selection") return op.type === "set_selection" ? null : op;
|
|
929
|
+
if (against.type === "insert_node") {
|
|
930
|
+
let next = mapPath(op, (p) => shiftPath(p, against.path, against.index, 1));
|
|
931
|
+
if ((next.type === "insert_node" || next.type === "remove_node") && pathsEqual(next.path, against.path) && next.index >= against.index) next = {
|
|
932
|
+
...next,
|
|
933
|
+
index: next.index + 1
|
|
934
|
+
};
|
|
935
|
+
return next;
|
|
936
|
+
}
|
|
937
|
+
if (against.type === "remove_node") {
|
|
938
|
+
if ((op.type === "insert_node" || op.type === "remove_node") && pathsEqual(op.path, against.path)) {
|
|
939
|
+
if (op.index === against.index && op.type === "remove_node") return null;
|
|
940
|
+
if (op.index > against.index) return {
|
|
941
|
+
...op,
|
|
942
|
+
index: op.index - 1
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
const removedPath = [...against.path, against.index];
|
|
946
|
+
if ("path" in op && pathPrefix(op.path, removedPath) && op.path.length >= removedPath.length && pathsEqual(op.path.slice(0, removedPath.length), removedPath)) return null;
|
|
947
|
+
let next = mapPath(op, (p) => shiftPath(p, against.path, against.index + 1, -1));
|
|
948
|
+
if ((next.type === "insert_node" || next.type === "remove_node") && pathsEqual(next.path, against.path) && next.index > against.index) next = {
|
|
949
|
+
...next,
|
|
950
|
+
index: next.index - 1
|
|
951
|
+
};
|
|
952
|
+
return next;
|
|
953
|
+
}
|
|
954
|
+
if (against.type === "split_paragraph") {
|
|
955
|
+
const parentPath = against.path.slice(0, -1);
|
|
956
|
+
const index = against.path.at(-1);
|
|
957
|
+
if (index === void 0) return op;
|
|
958
|
+
if ("path" in op && pathsEqual(op.path, against.path)) {
|
|
959
|
+
if (op.type === "insert_text") {
|
|
960
|
+
if (op.offset <= against.offset) return op;
|
|
961
|
+
return {
|
|
962
|
+
...op,
|
|
963
|
+
path: [...parentPath, index + 1],
|
|
964
|
+
offset: op.offset - against.offset
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
if (op.type === "delete_text") {
|
|
968
|
+
if (op.offset + op.length <= against.offset) return op;
|
|
969
|
+
if (op.offset >= against.offset) return {
|
|
970
|
+
...op,
|
|
971
|
+
path: [...parentPath, index + 1],
|
|
972
|
+
offset: op.offset - against.offset
|
|
973
|
+
};
|
|
974
|
+
return {
|
|
975
|
+
...op,
|
|
976
|
+
length: against.offset - op.offset
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
if (op.type === "split_paragraph") {
|
|
980
|
+
if (op.offset <= against.offset) return op;
|
|
981
|
+
return {
|
|
982
|
+
...op,
|
|
983
|
+
path: [...parentPath, index + 1],
|
|
984
|
+
offset: op.offset - against.offset
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
return mapPath(op, (p) => shiftPath(p, parentPath, index + 1, 1));
|
|
989
|
+
}
|
|
990
|
+
if (against.type === "merge_paragraph") {
|
|
991
|
+
const parentPath = against.path.slice(0, -1);
|
|
992
|
+
const index = against.path.at(-1);
|
|
993
|
+
if (index === void 0 || index <= 0) return op;
|
|
994
|
+
const leftPath = [...parentPath, index - 1];
|
|
995
|
+
const rightPath = against.path;
|
|
996
|
+
if ("path" in op && pathsEqual(op.path, rightPath)) return mapPath(op, () => leftPath);
|
|
997
|
+
return mapPath(op, (p) => shiftPath(p, parentPath, index, -1));
|
|
998
|
+
}
|
|
999
|
+
if (against.type === "insert_text" && "path" in op && pathsEqual(op.path, against.path)) return mapOffset(op, against.path, (o) => o >= against.offset ? o + against.text.length : o);
|
|
1000
|
+
if (against.type === "delete_text" && "path" in op && pathsEqual(op.path, against.path)) {
|
|
1001
|
+
const delStart = against.offset;
|
|
1002
|
+
const delEnd = against.offset + against.length;
|
|
1003
|
+
return mapOffset(op, against.path, (o) => {
|
|
1004
|
+
if (o >= delEnd) return o - against.length;
|
|
1005
|
+
if (o >= delStart) return delStart;
|
|
1006
|
+
return o;
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
if (against.type === "replace_slice" && "path" in op && pathsEqual(op.path, against.path)) {
|
|
1010
|
+
const removed = against.to - against.from;
|
|
1011
|
+
const inserted = against.runs.reduce((n, r) => n + r.text.length, 0);
|
|
1012
|
+
const delta = inserted - removed;
|
|
1013
|
+
return mapOffset(op, against.path, (o) => {
|
|
1014
|
+
if (o >= against.to) return o + delta;
|
|
1015
|
+
if (o >= against.from) return against.from + inserted;
|
|
1016
|
+
return o;
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
return op;
|
|
1020
|
+
}
|
|
1021
|
+
/** Transform a sequence of ops against a single already-applied op. */
|
|
1022
|
+
function transformOps(ops, against) {
|
|
1023
|
+
const out = [];
|
|
1024
|
+
for (const op of ops) {
|
|
1025
|
+
const next = transformOp(op, against);
|
|
1026
|
+
if (next) out.push(next);
|
|
1027
|
+
}
|
|
1028
|
+
return out;
|
|
1029
|
+
}
|
|
1030
|
+
/**
|
|
1031
|
+
* Rebase client ops that were based on `baseVersion` against server ops
|
|
1032
|
+
* that landed since (in order). Returns ops safe to apply on the new tip.
|
|
1033
|
+
*/
|
|
1034
|
+
function rebaseOps(clientOps, serverOps) {
|
|
1035
|
+
let pending = clientOps.filter((o) => o.type !== "set_selection");
|
|
1036
|
+
for (const serverOp of serverOps) {
|
|
1037
|
+
if (serverOp.type === "set_selection") continue;
|
|
1038
|
+
pending = transformOps(pending, serverOp);
|
|
1039
|
+
}
|
|
1040
|
+
return pending;
|
|
1041
|
+
}
|
|
1042
|
+
//#endregion
|
|
1043
|
+
//#region src/structure.ts
|
|
1044
|
+
var TEXT_BLOCKS = /* @__PURE__ */ new Set([
|
|
1045
|
+
"paragraph",
|
|
1046
|
+
"heading",
|
|
1047
|
+
"listItem",
|
|
1048
|
+
"blockquote",
|
|
1049
|
+
"codeBlock"
|
|
1050
|
+
]);
|
|
1051
|
+
var LIST_TYPES = /* @__PURE__ */ new Set(["bulletList", "orderedList"]);
|
|
1052
|
+
function isTextBlock(type) {
|
|
1053
|
+
return TEXT_BLOCKS.has(type);
|
|
1054
|
+
}
|
|
1055
|
+
function isListType(type) {
|
|
1056
|
+
return LIST_TYPES.has(type);
|
|
1057
|
+
}
|
|
1058
|
+
/** Flatten text of a text-bearing node (paragraph / heading / listItem). */
|
|
1059
|
+
function plainText(node) {
|
|
1060
|
+
if (node.type === "text") return node.text ?? "";
|
|
1061
|
+
let out = "";
|
|
1062
|
+
for (const child of node.content ?? []) if (child.type === "text") out += child.text ?? "";
|
|
1063
|
+
else if (child.content) out += plainText(child);
|
|
1064
|
+
return out;
|
|
1065
|
+
}
|
|
1066
|
+
/**
|
|
1067
|
+
* Resolve selection path to a text-bearing leaf.
|
|
1068
|
+
* Lists → first/only item; listItem → nested paragraph; table → first cell paragraph.
|
|
1069
|
+
*/
|
|
1070
|
+
function resolveTextPath(doc, path) {
|
|
1071
|
+
if (path.length === 0) return [0];
|
|
1072
|
+
let cur = getNodeAt(doc, path);
|
|
1073
|
+
const resolved = [...path];
|
|
1074
|
+
if (isListType(cur.type)) {
|
|
1075
|
+
if ((cur.content?.length ?? 0) === 0) return resolved;
|
|
1076
|
+
resolved.push(0);
|
|
1077
|
+
cur = cur.content[0];
|
|
1078
|
+
}
|
|
1079
|
+
if (cur.type === "table") {
|
|
1080
|
+
const row = cur.content?.[0];
|
|
1081
|
+
const cell = row?.content?.[0];
|
|
1082
|
+
if (row && cell) {
|
|
1083
|
+
resolved.push(0, 0);
|
|
1084
|
+
cur = cell;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
if (cur.type === "tableRow") {
|
|
1088
|
+
const cell = cur.content?.[0];
|
|
1089
|
+
if (cell) {
|
|
1090
|
+
resolved.push(0);
|
|
1091
|
+
cur = cell;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
if (cur.type === "tableCell") {
|
|
1095
|
+
const para = cur.content?.[0];
|
|
1096
|
+
if (para && isTextBlock(para.type)) {
|
|
1097
|
+
resolved.push(0);
|
|
1098
|
+
cur = para;
|
|
1099
|
+
} else if (!para) return resolved;
|
|
1100
|
+
}
|
|
1101
|
+
if (cur.type === "listItem") {
|
|
1102
|
+
const first = cur.content?.[0];
|
|
1103
|
+
if (first && first.type !== "text" && isTextBlock(first.type) && first.type !== "listItem") {
|
|
1104
|
+
resolved.push(0);
|
|
1105
|
+
cur = first;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
if (cur.type === "blockquote" || cur.type === "codeBlock") {
|
|
1109
|
+
const first = cur.content?.[0];
|
|
1110
|
+
if (first && isTextBlock(first.type)) {
|
|
1111
|
+
resolved.push(0);
|
|
1112
|
+
return resolveTextPath(doc, resolved);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
return resolved;
|
|
1116
|
+
}
|
|
1117
|
+
function createListItem(content = [createText("")]) {
|
|
1118
|
+
return {
|
|
1119
|
+
type: "listItem",
|
|
1120
|
+
content: [...content]
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
function createList(listType, items) {
|
|
1124
|
+
return {
|
|
1125
|
+
type: listType,
|
|
1126
|
+
id: `list_${Date.now()}`,
|
|
1127
|
+
content: items.length > 0 ? items : [createListItem()]
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
/** Extract a paragraph to lift out of a list item. */
|
|
1131
|
+
function liftedParagraphFromItem(item) {
|
|
1132
|
+
const content = item.content ?? [];
|
|
1133
|
+
const first = content[0];
|
|
1134
|
+
if (first !== void 0 && first.type === "paragraph") return deepCloneNode(first);
|
|
1135
|
+
if (first !== void 0 && isTextBlock(first.type) && first.type !== "listItem" && !isListType(first.type)) return deepCloneNode(first);
|
|
1136
|
+
return createParagraph(content.length > 0 ? content.map(deepCloneNode) : [createText("")]);
|
|
1137
|
+
}
|
|
1138
|
+
/**
|
|
1139
|
+
* Lift list item `itemIndex` out of the list at `listPath` into the list's parent.
|
|
1140
|
+
* Removes the list, reinserts before-list (if any), lifted paragraph, after-list (if any).
|
|
1141
|
+
*/
|
|
1142
|
+
function liftIntoParent(doc, listPath, itemIndex) {
|
|
1143
|
+
const list = getNodeAt(doc, listPath);
|
|
1144
|
+
const items = list.content ?? [];
|
|
1145
|
+
const listIndex = listPath.at(-1);
|
|
1146
|
+
if (listIndex === void 0) throw new Error("liftIntoParent: empty list path");
|
|
1147
|
+
const parentPath = listPath.slice(0, -1);
|
|
1148
|
+
const item = items[itemIndex];
|
|
1149
|
+
if (item === void 0) throw new Error("liftIntoParent: item out of bounds");
|
|
1150
|
+
const before = items.slice(0, itemIndex);
|
|
1151
|
+
const after = items.slice(itemIndex + 1);
|
|
1152
|
+
const para = liftedParagraphFromItem(item);
|
|
1153
|
+
const ops = [];
|
|
1154
|
+
ops.push({
|
|
1155
|
+
type: "remove_node",
|
|
1156
|
+
path: parentPath,
|
|
1157
|
+
index: listIndex
|
|
1158
|
+
});
|
|
1159
|
+
let insertAt = listIndex;
|
|
1160
|
+
if (before.length > 0) {
|
|
1161
|
+
ops.push({
|
|
1162
|
+
type: "insert_node",
|
|
1163
|
+
path: parentPath,
|
|
1164
|
+
index: insertAt,
|
|
1165
|
+
node: {
|
|
1166
|
+
...list,
|
|
1167
|
+
content: before,
|
|
1168
|
+
id: `list_${Date.now()}_a`
|
|
1169
|
+
}
|
|
1170
|
+
});
|
|
1171
|
+
insertAt += 1;
|
|
1172
|
+
}
|
|
1173
|
+
ops.push({
|
|
1174
|
+
type: "insert_node",
|
|
1175
|
+
path: parentPath,
|
|
1176
|
+
index: insertAt,
|
|
1177
|
+
node: para
|
|
1178
|
+
});
|
|
1179
|
+
const liftedIndex = insertAt;
|
|
1180
|
+
const selectionPath = [...parentPath, insertAt];
|
|
1181
|
+
insertAt += 1;
|
|
1182
|
+
if (after.length > 0) ops.push({
|
|
1183
|
+
type: "insert_node",
|
|
1184
|
+
path: parentPath,
|
|
1185
|
+
index: insertAt,
|
|
1186
|
+
node: {
|
|
1187
|
+
...list,
|
|
1188
|
+
content: after,
|
|
1189
|
+
id: `list_${Date.now()}_b`
|
|
1190
|
+
}
|
|
1191
|
+
});
|
|
1192
|
+
return {
|
|
1193
|
+
ops,
|
|
1194
|
+
selectionPath,
|
|
1195
|
+
liftedIndex
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
/** Exit empty list item: lift to paragraph in the list's parent (any depth). */
|
|
1199
|
+
function exitListItemOps(doc, listPath, itemIndex) {
|
|
1200
|
+
const { ops, selectionPath } = liftIntoParent(doc, listPath, itemIndex);
|
|
1201
|
+
return {
|
|
1202
|
+
ops,
|
|
1203
|
+
selectionPath
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1206
|
+
function textBlockLength(doc, path) {
|
|
1207
|
+
try {
|
|
1208
|
+
return textLength(getNodeAt(doc, path));
|
|
1209
|
+
} catch {
|
|
1210
|
+
return 0;
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
//#endregion
|
|
1214
|
+
//#region src/normalize.ts
|
|
1215
|
+
/** Idempotent normalize: ensure doc has content, text blocks have text leaves, coalesce adjacent same-mark texts. */
|
|
1216
|
+
function normalize(doc) {
|
|
1217
|
+
if (doc.type !== "doc") throw new Error("normalize expects doc");
|
|
1218
|
+
const next = cloneNode(doc);
|
|
1219
|
+
if (!next.content || next.content.length === 0) {
|
|
1220
|
+
next.content = [{
|
|
1221
|
+
content: [createText("")],
|
|
1222
|
+
id: "p_empty",
|
|
1223
|
+
type: "paragraph"
|
|
1224
|
+
}];
|
|
1225
|
+
return next;
|
|
1226
|
+
}
|
|
1227
|
+
next.content = next.content.map(normalizeBlock);
|
|
1228
|
+
if (next.content.length === 0) next.content = [{
|
|
1229
|
+
content: [createText("")],
|
|
1230
|
+
id: "p_empty",
|
|
1231
|
+
type: "paragraph"
|
|
1232
|
+
}];
|
|
1233
|
+
return next;
|
|
1234
|
+
}
|
|
1235
|
+
function normalizeBlock(node) {
|
|
1236
|
+
if (node.type === "text") return node;
|
|
1237
|
+
const cloned = cloneNode(node);
|
|
1238
|
+
if (!cloned.content || cloned.content.length === 0) {
|
|
1239
|
+
if (isListType(node.type)) return {
|
|
1240
|
+
type: "paragraph",
|
|
1241
|
+
id: nextId("p"),
|
|
1242
|
+
content: [createText("")]
|
|
1243
|
+
};
|
|
1244
|
+
if (isTextBlock(node.type)) cloned.content = [createText("")];
|
|
1245
|
+
return cloned;
|
|
1246
|
+
}
|
|
1247
|
+
if (isTextBlock(node.type)) {
|
|
1248
|
+
cloned.content = coalesceText(cloned.content.map(normalizeBlock));
|
|
1249
|
+
if (cloned.content.length === 0) cloned.content = [createText("")];
|
|
1250
|
+
} else {
|
|
1251
|
+
cloned.content = cloned.content.map(normalizeBlock);
|
|
1252
|
+
if (isListType(node.type)) {
|
|
1253
|
+
cloned.content = cloned.content.filter((c) => {
|
|
1254
|
+
if (c.type !== "listItem") return true;
|
|
1255
|
+
return (c.content?.length ?? 0) > 0;
|
|
1256
|
+
});
|
|
1257
|
+
if (cloned.content.length === 0) return {
|
|
1258
|
+
type: "paragraph",
|
|
1259
|
+
id: nextId("p"),
|
|
1260
|
+
content: [createText("")]
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
return cloned;
|
|
1265
|
+
}
|
|
1266
|
+
function coalesceText(nodes) {
|
|
1267
|
+
const out = [];
|
|
1268
|
+
for (const n of nodes) {
|
|
1269
|
+
if (n.type !== "text") {
|
|
1270
|
+
out.push(n);
|
|
1271
|
+
continue;
|
|
1272
|
+
}
|
|
1273
|
+
const prev = out.at(-1);
|
|
1274
|
+
if (prev?.type === "text" && JSON.stringify(prev.marks ?? []) === JSON.stringify(n.marks ?? [])) out[out.length - 1] = {
|
|
1275
|
+
...prev,
|
|
1276
|
+
text: (prev.text ?? "") + (n.text ?? "")
|
|
1277
|
+
};
|
|
1278
|
+
else out.push(n);
|
|
1279
|
+
}
|
|
1280
|
+
return out;
|
|
1281
|
+
}
|
|
1282
|
+
function isNormalizeIdempotent(doc) {
|
|
1283
|
+
const a = normalize(doc), b = normalize(a);
|
|
1284
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
1285
|
+
}
|
|
1286
|
+
//#endregion
|
|
1287
|
+
//#region src/transaction.ts
|
|
1288
|
+
function createState(doc = createDoc(), selection) {
|
|
1289
|
+
const normalized = normalize(doc);
|
|
1290
|
+
return {
|
|
1291
|
+
doc: normalized,
|
|
1292
|
+
selection: clampSelection(normalized, selection ?? collapsedAt([0], 0))
|
|
1293
|
+
};
|
|
1294
|
+
}
|
|
1295
|
+
function applyTransaction(state, tr, schema) {
|
|
1296
|
+
const { doc, selection, inverses } = applyOps(state.doc, tr.ops, state.selection, schema);
|
|
1297
|
+
return {
|
|
1298
|
+
inverses,
|
|
1299
|
+
state: {
|
|
1300
|
+
doc: normalize(doc),
|
|
1301
|
+
selection
|
|
1302
|
+
}
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
function transaction(...ops) {
|
|
1306
|
+
return { ops };
|
|
1307
|
+
}
|
|
1308
|
+
//#endregion
|
|
1309
|
+
//#region src/history.ts
|
|
1310
|
+
function createHistory(options = {}) {
|
|
1311
|
+
const maxDepth = options.maxDepth ?? 100, mergeWindowMs = options.mergeWindowMs ?? 500, maxEntryChars = options.maxEntryChars ?? 1e4, schema = options.schema, undoStack = [], redoStack = [];
|
|
1312
|
+
function push(entry) {
|
|
1313
|
+
const last = undoStack.at(-1);
|
|
1314
|
+
if (last && entry.at - last.at <= mergeWindowMs && isTyping(entry.redo) && isTyping(last.redo) && last.chars + entry.chars <= maxEntryChars) {
|
|
1315
|
+
last.redo = [...last.redo, ...entry.redo];
|
|
1316
|
+
last.inverses = [...entry.inverses, ...last.inverses];
|
|
1317
|
+
last.selection = entry.selection;
|
|
1318
|
+
last.at = entry.at;
|
|
1319
|
+
last.chars += entry.chars;
|
|
1320
|
+
} else undoStack.push(entry);
|
|
1321
|
+
while (undoStack.length > maxDepth) undoStack.shift();
|
|
1322
|
+
redoStack.length = 0;
|
|
1323
|
+
}
|
|
1324
|
+
return {
|
|
1325
|
+
apply(state, tr, now = Date.now()) {
|
|
1326
|
+
const { state: next, inverses } = applyTransaction(state, tr, schema);
|
|
1327
|
+
if (tr.ops.some((op) => op.type !== "set_selection")) {
|
|
1328
|
+
const redo = tr.ops.filter((o) => o.type !== "set_selection");
|
|
1329
|
+
push({
|
|
1330
|
+
inverses,
|
|
1331
|
+
redo,
|
|
1332
|
+
selection: next.selection,
|
|
1333
|
+
at: now,
|
|
1334
|
+
chars: entryCharCount(redo)
|
|
1335
|
+
});
|
|
1336
|
+
}
|
|
1337
|
+
return next;
|
|
1338
|
+
},
|
|
1339
|
+
canRedo: () => redoStack.length > 0,
|
|
1340
|
+
canUndo: () => undoStack.length > 0,
|
|
1341
|
+
clear() {
|
|
1342
|
+
undoStack.length = 0;
|
|
1343
|
+
redoStack.length = 0;
|
|
1344
|
+
},
|
|
1345
|
+
depth: () => undoStack.length,
|
|
1346
|
+
redo(state) {
|
|
1347
|
+
const entry = redoStack.pop();
|
|
1348
|
+
if (!entry) return state;
|
|
1349
|
+
const { state: next, inverses } = applyTransaction(state, transaction(...entry.redo), schema);
|
|
1350
|
+
undoStack.push({
|
|
1351
|
+
inverses,
|
|
1352
|
+
redo: entry.redo,
|
|
1353
|
+
selection: entry.selection,
|
|
1354
|
+
at: Date.now(),
|
|
1355
|
+
chars: entry.chars
|
|
1356
|
+
});
|
|
1357
|
+
return {
|
|
1358
|
+
...next,
|
|
1359
|
+
selection: entry.selection
|
|
1360
|
+
};
|
|
1361
|
+
},
|
|
1362
|
+
undo(state) {
|
|
1363
|
+
const entry = undoStack.pop();
|
|
1364
|
+
if (!entry) return state;
|
|
1365
|
+
const { state: next } = applyTransaction(state, transaction(...entry.inverses), schema);
|
|
1366
|
+
redoStack.push(entry);
|
|
1367
|
+
return next;
|
|
1368
|
+
}
|
|
1369
|
+
};
|
|
1370
|
+
}
|
|
1371
|
+
function isTyping(ops) {
|
|
1372
|
+
return ops.length > 0 && ops.every((o) => o.type === "insert_text" || o.type === "delete_text");
|
|
1373
|
+
}
|
|
1374
|
+
function entryCharCount(ops) {
|
|
1375
|
+
let n = 0;
|
|
1376
|
+
for (const op of ops) if (op.type === "insert_text") n += op.text.length;
|
|
1377
|
+
else if (op.type === "delete_text") n += op.length;
|
|
1378
|
+
return n;
|
|
1379
|
+
}
|
|
1380
|
+
function emptyState() {
|
|
1381
|
+
return createState();
|
|
1382
|
+
}
|
|
1383
|
+
//#endregion
|
|
1384
|
+
//#region src/commands.ts
|
|
1385
|
+
function textPath(state) {
|
|
1386
|
+
return resolveTextPath(state.doc, state.selection.anchor.path);
|
|
1387
|
+
}
|
|
1388
|
+
function samePath(a, b) {
|
|
1389
|
+
return a.length === b.length && a.every((v, i) => v === b[i]);
|
|
1390
|
+
}
|
|
1391
|
+
function isLeafTextBlock(node) {
|
|
1392
|
+
return isTextBlock(node.type) && !(node.content ?? []).some((c) => c.type !== "text" && (isTextBlock(c.type) || isListType(c.type) || c.type === "table" || c.type === "tableRow" || c.type === "tableCell"));
|
|
1393
|
+
}
|
|
1394
|
+
/**
|
|
1395
|
+
* Ops to delete the current selection — same-path text or cross-block (siblings / root span).
|
|
1396
|
+
* Keeps selectAll → deleteBackward/insertText working.
|
|
1397
|
+
*/
|
|
1398
|
+
function deleteSelectionRangeOps(doc, sel) {
|
|
1399
|
+
const { from, to } = orderedRange(sel);
|
|
1400
|
+
if (samePath(from.path, to.path)) {
|
|
1401
|
+
if (from.offset === to.offset) return null;
|
|
1402
|
+
return [{
|
|
1403
|
+
type: "delete_text",
|
|
1404
|
+
path: resolveTextPath(doc, from.path),
|
|
1405
|
+
offset: from.offset,
|
|
1406
|
+
length: to.offset - from.offset
|
|
1407
|
+
}];
|
|
1408
|
+
}
|
|
1409
|
+
if (from.path.length === to.path.length && from.path.length > 0 && samePath(from.path.slice(0, -1), to.path.slice(0, -1))) return deleteSiblingSpan(doc, from, to);
|
|
1410
|
+
return deleteRootSpan(doc, from, to);
|
|
1411
|
+
}
|
|
1412
|
+
function deleteSiblingSpan(doc, from, to) {
|
|
1413
|
+
return deleteSpanUnderParent(doc, from, to, from.path.slice(0, -1), from.path.at(-1) ?? 0, to.path.at(-1) ?? 0);
|
|
1414
|
+
}
|
|
1415
|
+
function deleteSpanUnderParent(doc, from, to, parent, fromIdx, toIdx) {
|
|
1416
|
+
const fromPath = resolveTextPath(doc, from.path);
|
|
1417
|
+
const toPath = resolveTextPath(doc, to.path);
|
|
1418
|
+
const ops = [];
|
|
1419
|
+
const fromLen = textLength(getNodeAt(doc, fromPath));
|
|
1420
|
+
if (from.offset < fromLen) ops.push({
|
|
1421
|
+
type: "delete_text",
|
|
1422
|
+
path: fromPath,
|
|
1423
|
+
offset: from.offset,
|
|
1424
|
+
length: fromLen - from.offset
|
|
1425
|
+
});
|
|
1426
|
+
const fromBlock = getNodeAt(doc, [...parent, fromIdx]);
|
|
1427
|
+
const toBlock = getNodeAt(doc, [...parent, toIdx]);
|
|
1428
|
+
if (isLeafTextBlock(fromBlock) && isLeafTextBlock(toBlock) && toIdx > fromIdx) {
|
|
1429
|
+
if (to.offset > 0) {
|
|
1430
|
+
const remappedTo = [...toPath];
|
|
1431
|
+
if (parent.length < remappedTo.length) remappedTo[parent.length] = toIdx;
|
|
1432
|
+
ops.push({
|
|
1433
|
+
type: "delete_text",
|
|
1434
|
+
path: remappedTo,
|
|
1435
|
+
offset: 0,
|
|
1436
|
+
length: to.offset
|
|
1437
|
+
});
|
|
1438
|
+
}
|
|
1439
|
+
for (let i = toIdx - 1; i > fromIdx; i--) ops.push({
|
|
1440
|
+
type: "remove_node",
|
|
1441
|
+
path: parent,
|
|
1442
|
+
index: i
|
|
1443
|
+
});
|
|
1444
|
+
ops.push({
|
|
1445
|
+
type: "merge_paragraph",
|
|
1446
|
+
path: [...parent, fromIdx + 1]
|
|
1447
|
+
});
|
|
1448
|
+
} else return deleteLeafSpan(doc, from, to);
|
|
1449
|
+
ops.push({
|
|
1450
|
+
type: "set_selection",
|
|
1451
|
+
selection: collapsedAt(fromPath, from.offset)
|
|
1452
|
+
});
|
|
1453
|
+
return ops;
|
|
1454
|
+
}
|
|
1455
|
+
function collectTextLeaves(node, path, out) {
|
|
1456
|
+
if (isLeafTextBlock(node)) {
|
|
1457
|
+
out.push([...path]);
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
(node.content ?? []).forEach((child, i) => {
|
|
1461
|
+
collectTextLeaves(child, [...path, i], out);
|
|
1462
|
+
});
|
|
1463
|
+
}
|
|
1464
|
+
function removableAncestor(doc, leafPath) {
|
|
1465
|
+
if (leafPath.length >= 2) {
|
|
1466
|
+
const itemPath = leafPath.slice(0, -1);
|
|
1467
|
+
try {
|
|
1468
|
+
const item = getNodeAt(doc, itemPath);
|
|
1469
|
+
if (item.type === "listItem" || item.type === "tableCell") return {
|
|
1470
|
+
parent: itemPath.slice(0, -1),
|
|
1471
|
+
index: itemPath.at(-1) ?? 0
|
|
1472
|
+
};
|
|
1473
|
+
} catch {}
|
|
1474
|
+
}
|
|
1475
|
+
return {
|
|
1476
|
+
parent: leafPath.slice(0, -1),
|
|
1477
|
+
index: leafPath.at(-1) ?? 0
|
|
1478
|
+
};
|
|
1479
|
+
}
|
|
1480
|
+
function deleteLeafSpan(doc, from, to) {
|
|
1481
|
+
const fromPath = resolveTextPath(doc, from.path);
|
|
1482
|
+
const toPath = resolveTextPath(doc, to.path);
|
|
1483
|
+
let d = 0;
|
|
1484
|
+
while (d < fromPath.length && d < toPath.length && fromPath[d] === toPath[d]) d++;
|
|
1485
|
+
const rootPath = fromPath.slice(0, d);
|
|
1486
|
+
const rootNode = rootPath.length === 0 ? doc : getNodeAt(doc, rootPath);
|
|
1487
|
+
const leaves = [];
|
|
1488
|
+
collectTextLeaves(rootNode, rootPath, leaves);
|
|
1489
|
+
const i0 = leaves.findIndex((p) => samePath(p, fromPath));
|
|
1490
|
+
const i1 = leaves.findIndex((p) => samePath(p, toPath));
|
|
1491
|
+
if (i0 === -1 || i1 === -1) return [];
|
|
1492
|
+
const start = Math.min(i0, i1);
|
|
1493
|
+
const end = Math.max(i0, i1);
|
|
1494
|
+
const ops = [];
|
|
1495
|
+
const first = leaves[start];
|
|
1496
|
+
if (first === void 0) return [];
|
|
1497
|
+
const firstLen = textLength(getNodeAt(doc, first));
|
|
1498
|
+
const firstFrom = samePath(first, fromPath) ? from.offset : 0;
|
|
1499
|
+
if (firstFrom < firstLen) ops.push({
|
|
1500
|
+
type: "delete_text",
|
|
1501
|
+
path: first,
|
|
1502
|
+
offset: firstFrom,
|
|
1503
|
+
length: firstLen - firstFrom
|
|
1504
|
+
});
|
|
1505
|
+
const removals = [];
|
|
1506
|
+
const startFullyCovered = firstFrom === 0 && (end > start || to.offset >= firstLen);
|
|
1507
|
+
for (let i = end; i >= start; i--) {
|
|
1508
|
+
if (i === start && !startFullyCovered) continue;
|
|
1509
|
+
const leaf = leaves[i];
|
|
1510
|
+
if (leaf === void 0) continue;
|
|
1511
|
+
if (!(i === start ? startFullyCovered : i < end || to.offset >= textLength(getNodeAt(doc, leaf)))) {
|
|
1512
|
+
if (i === end && to.offset > 0) ops.push({
|
|
1513
|
+
type: "delete_text",
|
|
1514
|
+
path: leaf,
|
|
1515
|
+
offset: 0,
|
|
1516
|
+
length: to.offset
|
|
1517
|
+
});
|
|
1518
|
+
continue;
|
|
1519
|
+
}
|
|
1520
|
+
const rem = removableAncestor(doc, leaf);
|
|
1521
|
+
const key = `${rem.parent.join(".")}:${rem.index}`;
|
|
1522
|
+
if (!removals.some((r) => r.key === key)) removals.push({
|
|
1523
|
+
...rem,
|
|
1524
|
+
key
|
|
1525
|
+
});
|
|
1526
|
+
}
|
|
1527
|
+
removals.sort((a, b) => {
|
|
1528
|
+
if (a.parent.length !== b.parent.length) return b.parent.length - a.parent.length;
|
|
1529
|
+
for (let i = 0; i < a.parent.length; i++) if (a.parent[i] !== b.parent[i]) return (b.parent[i] ?? 0) - (a.parent[i] ?? 0);
|
|
1530
|
+
return b.index - a.index;
|
|
1531
|
+
});
|
|
1532
|
+
for (const rem of removals) ops.push({
|
|
1533
|
+
type: "remove_node",
|
|
1534
|
+
path: rem.parent,
|
|
1535
|
+
index: rem.index
|
|
1536
|
+
});
|
|
1537
|
+
ops.push({
|
|
1538
|
+
type: "set_selection",
|
|
1539
|
+
selection: collapsedAt(fromPath, from.offset)
|
|
1540
|
+
});
|
|
1541
|
+
return ops;
|
|
1542
|
+
}
|
|
1543
|
+
function deleteRootSpan(doc, from, to) {
|
|
1544
|
+
const fromRoot = from.path[0] ?? 0;
|
|
1545
|
+
const toRoot = to.path[0] ?? 0;
|
|
1546
|
+
const fromPath = resolveTextPath(doc, from.path);
|
|
1547
|
+
const ops = [];
|
|
1548
|
+
if (fromRoot === toRoot) {
|
|
1549
|
+
let d = 0;
|
|
1550
|
+
while (d < from.path.length && d < to.path.length && from.path[d] === to.path[d]) d++;
|
|
1551
|
+
if (d < from.path.length && d < to.path.length && from.path.length === to.path.length) return deleteSpanUnderParent(doc, from, to, from.path.slice(0, d), from.path[d] ?? 0, to.path[d] ?? 0);
|
|
1552
|
+
return deleteLeafSpan(doc, from, to);
|
|
1553
|
+
}
|
|
1554
|
+
const fromLen = textLength(getNodeAt(doc, fromPath));
|
|
1555
|
+
if (from.offset < fromLen) ops.push({
|
|
1556
|
+
type: "delete_text",
|
|
1557
|
+
path: fromPath,
|
|
1558
|
+
offset: from.offset,
|
|
1559
|
+
length: fromLen - from.offset
|
|
1560
|
+
});
|
|
1561
|
+
const fromBlock = getNodeAt(doc, [fromRoot]);
|
|
1562
|
+
const toBlock = getNodeAt(doc, [toRoot]);
|
|
1563
|
+
const toPath = resolveTextPath(doc, to.path);
|
|
1564
|
+
if (from.path.length === 1 && to.path.length === 1 && isLeafTextBlock(fromBlock) && isLeafTextBlock(toBlock)) {
|
|
1565
|
+
if (to.offset > 0) ops.push({
|
|
1566
|
+
type: "delete_text",
|
|
1567
|
+
path: toPath,
|
|
1568
|
+
offset: 0,
|
|
1569
|
+
length: to.offset
|
|
1570
|
+
});
|
|
1571
|
+
for (let i = toRoot - 1; i > fromRoot; i--) ops.push({
|
|
1572
|
+
type: "remove_node",
|
|
1573
|
+
path: [],
|
|
1574
|
+
index: i
|
|
1575
|
+
});
|
|
1576
|
+
ops.push({
|
|
1577
|
+
type: "merge_paragraph",
|
|
1578
|
+
path: [fromRoot + 1]
|
|
1579
|
+
});
|
|
1580
|
+
} else return deleteLeafSpan(doc, from, to);
|
|
1581
|
+
ops.push({
|
|
1582
|
+
type: "set_selection",
|
|
1583
|
+
selection: collapsedAt(fromPath, from.offset)
|
|
1584
|
+
});
|
|
1585
|
+
return ops;
|
|
1586
|
+
}
|
|
1587
|
+
function insertText(text, marks) {
|
|
1588
|
+
return (state) => {
|
|
1589
|
+
const range = selectionTextRange(state.selection);
|
|
1590
|
+
if (!range) {
|
|
1591
|
+
const cross = deleteSelectionRangeOps(state.doc, state.selection);
|
|
1592
|
+
if (cross && !isCollapsedSelection(state.selection)) {
|
|
1593
|
+
const path = resolveTextPath(state.doc, orderedRange(state.selection).from.path);
|
|
1594
|
+
const offset = orderedRange(state.selection).from.offset;
|
|
1595
|
+
const ops = cross.filter((op) => op.type !== "set_selection");
|
|
1596
|
+
ops.push(marks && marks.length > 0 ? {
|
|
1597
|
+
type: "insert_text",
|
|
1598
|
+
path,
|
|
1599
|
+
offset,
|
|
1600
|
+
text,
|
|
1601
|
+
marks
|
|
1602
|
+
} : {
|
|
1603
|
+
type: "insert_text",
|
|
1604
|
+
path,
|
|
1605
|
+
offset,
|
|
1606
|
+
text
|
|
1607
|
+
}, {
|
|
1608
|
+
type: "set_selection",
|
|
1609
|
+
selection: collapsedAt(path, offset + text.length)
|
|
1610
|
+
});
|
|
1611
|
+
return ops;
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
const path = resolveTextPath(state.doc, range?.path ?? state.selection.anchor.path);
|
|
1615
|
+
const offset = range ? range.from : state.selection.anchor.offset;
|
|
1616
|
+
const ops = [];
|
|
1617
|
+
if (range && range.from !== range.to) ops.push({
|
|
1618
|
+
type: "delete_text",
|
|
1619
|
+
path,
|
|
1620
|
+
offset: range.from,
|
|
1621
|
+
length: range.to - range.from
|
|
1622
|
+
});
|
|
1623
|
+
ops.push(marks && marks.length > 0 ? {
|
|
1624
|
+
type: "insert_text",
|
|
1625
|
+
path,
|
|
1626
|
+
offset,
|
|
1627
|
+
text,
|
|
1628
|
+
marks
|
|
1629
|
+
} : {
|
|
1630
|
+
type: "insert_text",
|
|
1631
|
+
path,
|
|
1632
|
+
offset,
|
|
1633
|
+
text
|
|
1634
|
+
});
|
|
1635
|
+
return ops;
|
|
1636
|
+
};
|
|
1637
|
+
}
|
|
1638
|
+
function isCollapsedSelection(sel) {
|
|
1639
|
+
return samePath(sel.anchor.path, sel.focus.path) && sel.anchor.offset === sel.focus.offset;
|
|
1640
|
+
}
|
|
1641
|
+
/**
|
|
1642
|
+
* Track-changes style backspace: mark the previous char as deleted instead of
|
|
1643
|
+
* removing it. Hard-deletes own `insertion` marks and already-marked deletions.
|
|
1644
|
+
*/
|
|
1645
|
+
function softDeleteBackward(deletionMark) {
|
|
1646
|
+
return (state) => {
|
|
1647
|
+
if (!isCollapsedSelection(state.selection)) return deleteBackward(state);
|
|
1648
|
+
const path = textPath(state);
|
|
1649
|
+
const offset = state.selection.anchor.offset;
|
|
1650
|
+
if (offset <= 0) return deleteBackward(state);
|
|
1651
|
+
const { text, runs } = flattenTextForMarks(getNodeAt(state.doc, path));
|
|
1652
|
+
if (text.length === 0) return deleteBackward(state);
|
|
1653
|
+
const from = offset - 1;
|
|
1654
|
+
if (marksAtOffset(runs, from).some((m) => m.type === "insertion" || m.type === deletionMark.type)) return [{
|
|
1655
|
+
type: "delete_text",
|
|
1656
|
+
path,
|
|
1657
|
+
offset: from,
|
|
1658
|
+
length: 1
|
|
1659
|
+
}];
|
|
1660
|
+
return [{
|
|
1661
|
+
type: "set_mark",
|
|
1662
|
+
path,
|
|
1663
|
+
from,
|
|
1664
|
+
to: offset,
|
|
1665
|
+
mark: deletionMark
|
|
1666
|
+
}, {
|
|
1667
|
+
type: "set_selection",
|
|
1668
|
+
selection: collapsedAt(path, from)
|
|
1669
|
+
}];
|
|
1670
|
+
};
|
|
1671
|
+
}
|
|
1672
|
+
function flattenTextForMarks(paragraph) {
|
|
1673
|
+
let text = "";
|
|
1674
|
+
const runs = [];
|
|
1675
|
+
for (const child of paragraph.content ?? []) {
|
|
1676
|
+
if (child.type !== "text") continue;
|
|
1677
|
+
const start = text.length;
|
|
1678
|
+
const chunk = child.text ?? "";
|
|
1679
|
+
text += chunk;
|
|
1680
|
+
runs.push({
|
|
1681
|
+
start,
|
|
1682
|
+
end: text.length,
|
|
1683
|
+
marks: (child.marks ?? []).map((m) => ({ ...m }))
|
|
1684
|
+
});
|
|
1685
|
+
}
|
|
1686
|
+
return {
|
|
1687
|
+
text,
|
|
1688
|
+
runs
|
|
1689
|
+
};
|
|
1690
|
+
}
|
|
1691
|
+
function marksAtOffset(runs, index) {
|
|
1692
|
+
for (const run of runs) if (index >= run.start && index < run.end) return run.marks.map((m) => ({ ...m }));
|
|
1693
|
+
return [];
|
|
1694
|
+
}
|
|
1695
|
+
/** Resolve list-item path when caret is on the item or its first nested text block. */
|
|
1696
|
+
function listItemContext(doc, path) {
|
|
1697
|
+
let node;
|
|
1698
|
+
try {
|
|
1699
|
+
node = getNodeAt(doc, path);
|
|
1700
|
+
} catch {
|
|
1701
|
+
return null;
|
|
1702
|
+
}
|
|
1703
|
+
if (node.type === "listItem") {
|
|
1704
|
+
const itemIndex = path.at(-1) ?? 0;
|
|
1705
|
+
return {
|
|
1706
|
+
listItemPath: path,
|
|
1707
|
+
listPath: path.slice(0, -1),
|
|
1708
|
+
itemIndex
|
|
1709
|
+
};
|
|
1710
|
+
}
|
|
1711
|
+
if (path.length < 2) return null;
|
|
1712
|
+
const parentPath = path.slice(0, -1);
|
|
1713
|
+
try {
|
|
1714
|
+
if (getNodeAt(doc, parentPath).type === "listItem" && (path.at(-1) ?? 0) === 0) {
|
|
1715
|
+
const itemIndex = parentPath.at(-1) ?? 0;
|
|
1716
|
+
return {
|
|
1717
|
+
listItemPath: parentPath,
|
|
1718
|
+
listPath: parentPath.slice(0, -1),
|
|
1719
|
+
itemIndex
|
|
1720
|
+
};
|
|
1721
|
+
}
|
|
1722
|
+
} catch {}
|
|
1723
|
+
return null;
|
|
1724
|
+
}
|
|
1725
|
+
var deleteBackward = (state) => {
|
|
1726
|
+
const crossOrSame = deleteSelectionRangeOps(state.doc, state.selection);
|
|
1727
|
+
if (crossOrSame) {
|
|
1728
|
+
const range = selectionTextRange(state.selection);
|
|
1729
|
+
if (!range || range.from !== range.to) return crossOrSame;
|
|
1730
|
+
}
|
|
1731
|
+
const path = textPath(state);
|
|
1732
|
+
const offset = state.selection.anchor.offset;
|
|
1733
|
+
if (offset > 0) return [{
|
|
1734
|
+
type: "delete_text",
|
|
1735
|
+
path,
|
|
1736
|
+
offset: offset - 1,
|
|
1737
|
+
length: 1
|
|
1738
|
+
}];
|
|
1739
|
+
const listCtx = listItemContext(state.doc, path);
|
|
1740
|
+
if (listCtx) {
|
|
1741
|
+
const { listItemPath, listPath, itemIndex } = listCtx;
|
|
1742
|
+
if (itemIndex > 0) {
|
|
1743
|
+
const prevItemPath = [...listPath, itemIndex - 1];
|
|
1744
|
+
const leftItem = getNodeAt(state.doc, prevItemPath);
|
|
1745
|
+
const rightItem = getNodeAt(state.doc, listItemPath);
|
|
1746
|
+
if (isLeafTextBlock(leftItem) && isLeafTextBlock(rightItem)) return [{
|
|
1747
|
+
type: "merge_paragraph",
|
|
1748
|
+
path: listItemPath
|
|
1749
|
+
}];
|
|
1750
|
+
const ops = [];
|
|
1751
|
+
const leftCount = leftItem.content?.length ?? 0;
|
|
1752
|
+
const rightContent = rightItem.content ?? [];
|
|
1753
|
+
for (let i = 0; i < rightContent.length; i++) {
|
|
1754
|
+
const node = rightContent[i];
|
|
1755
|
+
if (node === void 0) continue;
|
|
1756
|
+
ops.push({
|
|
1757
|
+
type: "insert_node",
|
|
1758
|
+
path: prevItemPath,
|
|
1759
|
+
index: leftCount + i,
|
|
1760
|
+
node
|
|
1761
|
+
});
|
|
1762
|
+
}
|
|
1763
|
+
ops.push({
|
|
1764
|
+
type: "remove_node",
|
|
1765
|
+
path: listPath,
|
|
1766
|
+
index: itemIndex
|
|
1767
|
+
});
|
|
1768
|
+
const leftLast = leftCount > 0 ? leftItem.content?.[leftCount - 1] : void 0;
|
|
1769
|
+
const rightFirst = rightContent[0];
|
|
1770
|
+
if (leftLast !== void 0 && rightFirst !== void 0 && isLeafTextBlock(leftLast) && isLeafTextBlock(rightFirst)) {
|
|
1771
|
+
const joinAt = textLength(leftLast);
|
|
1772
|
+
ops.push({
|
|
1773
|
+
type: "merge_paragraph",
|
|
1774
|
+
path: [...prevItemPath, leftCount]
|
|
1775
|
+
}, {
|
|
1776
|
+
type: "set_selection",
|
|
1777
|
+
selection: collapsedAt([...prevItemPath, leftCount - 1], joinAt)
|
|
1778
|
+
});
|
|
1779
|
+
} else {
|
|
1780
|
+
const selPath = resolveTextPath(state.doc, prevItemPath);
|
|
1781
|
+
ops.push({
|
|
1782
|
+
type: "set_selection",
|
|
1783
|
+
selection: collapsedAt(selPath, textLength(getNodeAt(state.doc, selPath)))
|
|
1784
|
+
});
|
|
1785
|
+
}
|
|
1786
|
+
return ops;
|
|
1787
|
+
}
|
|
1788
|
+
const parentPath = listPath.slice(0, -1);
|
|
1789
|
+
const { ops, selectionPath, liftedIndex } = liftIntoParent(state.doc, listPath, itemIndex);
|
|
1790
|
+
if (liftedIndex > 0) try {
|
|
1791
|
+
const prev = getNodeAt(state.doc, [...parentPath, liftedIndex - 1]);
|
|
1792
|
+
if (isTextBlock(prev.type)) {
|
|
1793
|
+
const joinAt = textLength(prev);
|
|
1794
|
+
return [
|
|
1795
|
+
...ops,
|
|
1796
|
+
{
|
|
1797
|
+
type: "merge_paragraph",
|
|
1798
|
+
path: [...parentPath, liftedIndex]
|
|
1799
|
+
},
|
|
1800
|
+
{
|
|
1801
|
+
type: "set_selection",
|
|
1802
|
+
selection: collapsedAt([...parentPath, liftedIndex - 1], joinAt)
|
|
1803
|
+
}
|
|
1804
|
+
];
|
|
1805
|
+
}
|
|
1806
|
+
} catch {}
|
|
1807
|
+
return [...ops, {
|
|
1808
|
+
type: "set_selection",
|
|
1809
|
+
selection: collapsedAt(selectionPath, 0)
|
|
1810
|
+
}];
|
|
1811
|
+
}
|
|
1812
|
+
if (path.length >= 4) {
|
|
1813
|
+
const cellPath = path.slice(0, 3);
|
|
1814
|
+
try {
|
|
1815
|
+
if (getNodeAt(state.doc, cellPath).type === "tableCell" && (path.at(-1) ?? 0) === 0) return null;
|
|
1816
|
+
} catch {}
|
|
1817
|
+
}
|
|
1818
|
+
if (path.length === 1) {
|
|
1819
|
+
if (path[0] <= 0) return null;
|
|
1820
|
+
return [{
|
|
1821
|
+
type: "merge_paragraph",
|
|
1822
|
+
path
|
|
1823
|
+
}];
|
|
1824
|
+
}
|
|
1825
|
+
const index = path.at(-1);
|
|
1826
|
+
if (index === void 0 || index <= 0) return null;
|
|
1827
|
+
return [{
|
|
1828
|
+
type: "merge_paragraph",
|
|
1829
|
+
path
|
|
1830
|
+
}];
|
|
1831
|
+
};
|
|
1832
|
+
/** Delete one character forward of the caret (range → delete selection, incl. cross-block). */
|
|
1833
|
+
var deleteForward = (state) => {
|
|
1834
|
+
const crossOrSame = deleteSelectionRangeOps(state.doc, state.selection);
|
|
1835
|
+
if (crossOrSame) {
|
|
1836
|
+
const range = selectionTextRange(state.selection);
|
|
1837
|
+
if (!range || range.from !== range.to) return crossOrSame;
|
|
1838
|
+
}
|
|
1839
|
+
const path = textPath(state);
|
|
1840
|
+
const offset = state.selection.anchor.offset;
|
|
1841
|
+
let node;
|
|
1842
|
+
try {
|
|
1843
|
+
node = getNodeAt(state.doc, path);
|
|
1844
|
+
} catch {
|
|
1845
|
+
return null;
|
|
1846
|
+
}
|
|
1847
|
+
if (offset < textLength(node)) return [{
|
|
1848
|
+
type: "delete_text",
|
|
1849
|
+
path,
|
|
1850
|
+
offset,
|
|
1851
|
+
length: 1
|
|
1852
|
+
}];
|
|
1853
|
+
return null;
|
|
1854
|
+
};
|
|
1855
|
+
var splitBlock = (state) => {
|
|
1856
|
+
const path = textPath(state);
|
|
1857
|
+
const offset = state.selection.anchor.offset;
|
|
1858
|
+
let node;
|
|
1859
|
+
try {
|
|
1860
|
+
node = getNodeAt(state.doc, path);
|
|
1861
|
+
} catch {
|
|
1862
|
+
return null;
|
|
1863
|
+
}
|
|
1864
|
+
if (isListType(node.type) || node.type === "table" || node.type === "tableRow" || node.type === "tableCell") return null;
|
|
1865
|
+
const parentPath = path.slice(0, -1);
|
|
1866
|
+
let parentIsList = false;
|
|
1867
|
+
if (path.length >= 2) try {
|
|
1868
|
+
parentIsList = isListType(getNodeAt(state.doc, parentPath).type);
|
|
1869
|
+
} catch {
|
|
1870
|
+
parentIsList = false;
|
|
1871
|
+
}
|
|
1872
|
+
if (node.type === "listItem" || parentIsList && isTextBlock(node.type)) {
|
|
1873
|
+
if (plainText(node).length === 0) {
|
|
1874
|
+
const listPath = parentIsList ? parentPath : path.slice(0, -1);
|
|
1875
|
+
const itemIndex = path.at(-1) ?? 0;
|
|
1876
|
+
const { ops, selectionPath } = exitListItemOps(state.doc, listPath, itemIndex);
|
|
1877
|
+
return [...ops, {
|
|
1878
|
+
type: "set_selection",
|
|
1879
|
+
selection: collapsedAt(selectionPath, 0)
|
|
1880
|
+
}];
|
|
1881
|
+
}
|
|
1882
|
+
return [{
|
|
1883
|
+
type: "split_paragraph",
|
|
1884
|
+
path,
|
|
1885
|
+
offset
|
|
1886
|
+
}];
|
|
1887
|
+
}
|
|
1888
|
+
if (node.type === "heading" && offset >= plainText(node).length) {
|
|
1889
|
+
const headingParentPath = path.slice(0, -1);
|
|
1890
|
+
const index = (path.at(-1) ?? 0) + 1;
|
|
1891
|
+
return [{
|
|
1892
|
+
type: "insert_node",
|
|
1893
|
+
path: headingParentPath,
|
|
1894
|
+
index,
|
|
1895
|
+
node: createParagraph([createText("")])
|
|
1896
|
+
}, {
|
|
1897
|
+
type: "set_selection",
|
|
1898
|
+
selection: collapsedAt([...headingParentPath, index], 0)
|
|
1899
|
+
}];
|
|
1900
|
+
}
|
|
1901
|
+
if (isTextBlock(node.type)) return [{
|
|
1902
|
+
type: "split_paragraph",
|
|
1903
|
+
path,
|
|
1904
|
+
offset
|
|
1905
|
+
}];
|
|
1906
|
+
return null;
|
|
1907
|
+
};
|
|
1908
|
+
function toggleMark(markType) {
|
|
1909
|
+
return (state) => {
|
|
1910
|
+
const range = selectionTextRange(state.selection);
|
|
1911
|
+
if (!range || range.from === range.to) return null;
|
|
1912
|
+
if (rangeHasMark(getNodeAt(state.doc, range.path), range.from, range.to, markType)) return [{
|
|
1913
|
+
type: "remove_mark",
|
|
1914
|
+
path: range.path,
|
|
1915
|
+
from: range.from,
|
|
1916
|
+
to: range.to,
|
|
1917
|
+
markType
|
|
1918
|
+
}];
|
|
1919
|
+
return [{
|
|
1920
|
+
type: "set_mark",
|
|
1921
|
+
path: range.path,
|
|
1922
|
+
from: range.from,
|
|
1923
|
+
to: range.to,
|
|
1924
|
+
mark: { type: markType }
|
|
1925
|
+
}];
|
|
1926
|
+
};
|
|
1927
|
+
}
|
|
1928
|
+
/** Whether the current selection (or caret) carries `markType`. */
|
|
1929
|
+
function selectionHasMark(markType) {
|
|
1930
|
+
return (state) => {
|
|
1931
|
+
const range = selectionTextRange(state.selection);
|
|
1932
|
+
if (!range) return false;
|
|
1933
|
+
const para = getNodeAt(state.doc, range.path);
|
|
1934
|
+
if (range.from === range.to) return offsetHasMark(para, range.from > 0 ? range.from - 1 : range.from, markType);
|
|
1935
|
+
return rangeHasMark(para, range.from, range.to, markType);
|
|
1936
|
+
};
|
|
1937
|
+
}
|
|
1938
|
+
var selectAll = (state) => {
|
|
1939
|
+
const leaves = [];
|
|
1940
|
+
collectTextLeaves(state.doc, [], leaves);
|
|
1941
|
+
if (leaves.length === 0) return null;
|
|
1942
|
+
const first = leaves[0];
|
|
1943
|
+
const last = leaves.at(-1);
|
|
1944
|
+
if (first === void 0 || last === void 0) return null;
|
|
1945
|
+
const len = plainText(getNodeAt(state.doc, last)).length;
|
|
1946
|
+
return [{
|
|
1947
|
+
type: "set_selection",
|
|
1948
|
+
selection: {
|
|
1949
|
+
anchor: {
|
|
1950
|
+
path: first,
|
|
1951
|
+
offset: 0
|
|
1952
|
+
},
|
|
1953
|
+
focus: {
|
|
1954
|
+
path: last,
|
|
1955
|
+
offset: len
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
}];
|
|
1959
|
+
};
|
|
1960
|
+
function runCommand(state, command) {
|
|
1961
|
+
const ops = command(state);
|
|
1962
|
+
if (!ops) return null;
|
|
1963
|
+
return transaction(...ops);
|
|
1964
|
+
}
|
|
1965
|
+
//#endregion
|
|
1966
|
+
export { JSON_DOC_VERSION, MAX_INSERT_CHARS, MAX_SLICE_KEEPS, MAX_TREE_DEPTH, applyOp, applyOps, applyTransaction, assertMark, assertMaxTreeDepth, assertNodeType, clampSelection, cloneNode, collapsedAt, comparePoints, copyAttrs, createDoc, createHistory, createList, createListItem, createParagraph, createSchema, createSelection, createState, createText, deepCloneNode, deleteBackward, deleteForward, deleteSelectionRangeOps, docFromJSON, docToJSON, emptyState, ensureNodeIds, exitListItemOps, getNodeAt, insertText, isCollapsed, isListType, isNormalizeIdempotent, isTextBlock, liftIntoParent, mergeMarksByType, nextId, normalize, offsetHasMark, orderedRange, plainText, rangeHasMark, rebaseOps, registerMark, registerNode, replaceAt, resetIdCounter, resolveTextPath, runCommand, sealSchema, selectAll, selectionHasMark, selectionTextRange, softDeleteBackward, splitBlock, textBlockLength, textLength, toggleMark, transaction, transformOp, transformOps };
|
|
1967
|
+
|
|
1968
|
+
//# sourceMappingURL=index.mjs.map
|