@texuli/core 0.1.0-next.0 → 0.1.0-next.1

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.
Files changed (46) hide show
  1. package/README.md +9 -1
  2. package/dist/editor/document.d.ts +65 -0
  3. package/dist/editor/document.js +394 -0
  4. package/dist/editor/edit.d.ts +7 -0
  5. package/dist/editor/edit.js +7 -0
  6. package/dist/editor/graphemes.d.ts +17 -0
  7. package/dist/editor/graphemes.js +71 -0
  8. package/dist/editor/history.d.ts +57 -0
  9. package/dist/editor/history.js +77 -0
  10. package/dist/editor/inline.d.ts +56 -0
  11. package/dist/editor/inline.js +166 -0
  12. package/dist/editor/input-rules.d.ts +17 -0
  13. package/dist/editor/input-rules.js +151 -0
  14. package/dist/editor/keymap.d.ts +30 -0
  15. package/dist/editor/keymap.js +69 -0
  16. package/dist/editor/load.d.ts +44 -0
  17. package/dist/editor/load.js +248 -0
  18. package/dist/editor/markdown.d.ts +33 -0
  19. package/dist/editor/markdown.js +705 -0
  20. package/dist/editor/model.d.ts +13 -0
  21. package/dist/editor/model.js +13 -0
  22. package/dist/editor/selection.d.ts +27 -0
  23. package/dist/editor/selection.js +85 -0
  24. package/dist/editor/transforms.d.ts +93 -0
  25. package/dist/editor/transforms.js +847 -0
  26. package/dist/editor/tree.d.ts +35 -0
  27. package/dist/editor/tree.js +103 -0
  28. package/dist/editor/types.d.ts +147 -0
  29. package/dist/editor/types.js +39 -0
  30. package/dist/index.d.ts +1 -0
  31. package/dist/index.js +1 -0
  32. package/dist/url/safe-href.d.ts +1 -20
  33. package/dist/url/safe-href.js +24 -1
  34. package/dist/url-policy/index.d.ts +1 -0
  35. package/dist/url-policy/index.js +1 -0
  36. package/dist/url-policy/safe-image-src.d.ts +1 -1
  37. package/dist/url-policy/safe-image-src.js +4 -4
  38. package/dist/url-policy/safe-image-srcset.d.ts +1 -1
  39. package/dist/url-policy/safe-image-srcset.js +3 -1
  40. package/dist/url-policy/safe-media-src.d.ts +1 -1
  41. package/dist/url-policy/safe-media-src.js +4 -4
  42. package/dist/url-policy/shared.d.ts +0 -2
  43. package/dist/url-policy/shared.js +0 -3
  44. package/dist/url-policy/trust.d.ts +8 -0
  45. package/dist/url-policy/trust.js +9 -0
  46. package/package.json +22 -6
@@ -0,0 +1,847 @@
1
+ import { safeHref } from "../url/safe-href.js";
2
+ import { resolveTrust } from "../url-policy/trust.js";
3
+ import { blockLength, blockText, editorBlockCount, editorCharCount, editorLeaves, isTextBlock, locateBlock, nextEditorId, normalizeDocument, paragraph, removeBlocks, replaceBlock, replaceItem, } from "./document.js";
4
+ import { nextGrapheme, nextWord, previousGrapheme, previousWord } from "./graphemes.js";
5
+ import { cleanText, concatInline, hasMark, inheritedMarks, inlineLength, inlineText, mapMarks, markKey, sliceInline, spliceInline, textRuns, textToInline, withMark, withoutMark, } from "./inline.js";
6
+ import { caret, clampSelection, isCollapsed, nodeSelection, selectionRange } from "./selection.js";
7
+ import { EDITOR_LIMITS, } from "./types.js";
8
+ const context = (options = {}) => ({
9
+ newId: options.newId ?? nextEditorId,
10
+ limits: { ...EDITOR_LIMITS, ...options.limits },
11
+ });
12
+ const fail = (code, message) => ({
13
+ ok: false,
14
+ errors: [{ code, message }],
15
+ });
16
+ /** Normalize, clamp the selection and check the caps; refuse when a cap is passed. */
17
+ function done(doc, selection, c, diagnostics = [], storedMarks = null) {
18
+ const next = normalizeDocument(doc, c.newId);
19
+ if (editorCharCount(next) > c.limits.chars) {
20
+ return fail('limit-reached', `The document holds at most ${c.limits.chars} characters.`);
21
+ }
22
+ if (editorBlockCount(next.blocks) > c.limits.blocks) {
23
+ return fail('limit-reached', `The document holds at most ${c.limits.blocks} blocks.`);
24
+ }
25
+ if (maxDepth(next.blocks) > c.limits.depth) {
26
+ return fail('depth-capped', `Lists and quotes nest at most ${c.limits.depth} levels.`);
27
+ }
28
+ const state = { doc: next, selection: clampSelection(next, selection), storedMarks };
29
+ return diagnostics.length ? { ok: true, state, diagnostics } : { ok: true, state };
30
+ }
31
+ /** Container nesting of a block tree (lists and quotes). */
32
+ function maxDepth(blocks) {
33
+ let depth = 0;
34
+ for (const block of blocks) {
35
+ if (block.type === 'quote')
36
+ depth = Math.max(depth, 1 + maxDepth(block.blocks));
37
+ else if (block.type === 'list')
38
+ for (const item of block.items)
39
+ depth = Math.max(depth, 1 + maxDepth(item.blocks));
40
+ }
41
+ return depth;
42
+ }
43
+ const textLeaf = (doc, id) => {
44
+ const block = locateBlock(doc, id)?.block;
45
+ return block && isTextBlock(block) ? block : undefined;
46
+ };
47
+ const isRich = (block) => block.type === 'paragraph' || block.type === 'heading';
48
+ const parentOf = (location) => location.ancestors.at(-1);
49
+ const isFirstInItem = (location) => parentOf(location)?.kind === 'item' && location.index === 0;
50
+ /** Inline content of a text block between two offsets (code text becomes plain runs). */
51
+ const asInline = (block, from, to) => block.type === 'code'
52
+ ? textToInline(block.text.slice(from, to)).content
53
+ : sliceInline(block.content, from, to);
54
+ const asCode = (block, from, to) => block.type === 'code'
55
+ ? block.text.slice(from, to)
56
+ : inlineText(sliceInline(block.content, from, to));
57
+ /** `target` up to `a`, followed by `source` from `b`, in `target`'s type. */
58
+ function joined(target, a, source, b) {
59
+ if (target.type === 'code')
60
+ return { ...target, text: target.text.slice(0, a) + asCode(source, b) };
61
+ return {
62
+ ...target,
63
+ content: concatInline(sliceInline(target.content, 0, a), asInline(source, b)),
64
+ };
65
+ }
66
+ /** Replace the siblings of a location (the document, a quote's or an item's blocks). */
67
+ function withSiblings(doc, location, blocks) {
68
+ const parent = parentOf(location);
69
+ if (!parent)
70
+ return { ...doc, blocks };
71
+ if (parent.kind === 'quote')
72
+ return replaceBlock(doc, parent.quote.id, (quote) => [{ ...quote, blocks }]);
73
+ return replaceItem(doc, parent.item.id, (item) => [{ ...item, blocks }]);
74
+ }
75
+ /** Delete `[from, to)` across blocks; the caret ends at `from`. */
76
+ function deleteRange(state, c) {
77
+ const range = selectionRange(state.doc, state.selection);
78
+ if (!range)
79
+ return null;
80
+ const { from, to } = range;
81
+ const leaves = editorLeaves(state.doc);
82
+ const target = leaves[from.index]?.block;
83
+ const source = leaves[to.index]?.block;
84
+ if (!target || !source || !isTextBlock(target) || !isTextBlock(source))
85
+ return null;
86
+ let doc = replaceBlock(state.doc, target.id, () => [
87
+ joined(target, from.offset, source, to.offset),
88
+ ]);
89
+ if (from.index !== to.index) {
90
+ const removed = new Set(leaves.slice(from.index + 1, to.index + 1).map(({ block }) => block.id));
91
+ doc = removeBlocks(doc, removed, c.newId);
92
+ }
93
+ return { doc, selection: caret(target.id, from.offset) };
94
+ }
95
+ /** Remove a void block; the caret moves to the nearest text block. */
96
+ function removeNode(doc, id, c) {
97
+ const leaves = editorLeaves(doc).map(({ block }) => block);
98
+ const index = leaves.findIndex((block) => block.id === id);
99
+ if (index === -1)
100
+ return fail('invalid-selection', 'The selected block is not in the document.');
101
+ const before = leaves.slice(0, index).findLast(isTextBlock);
102
+ const after = leaves.slice(index + 1).find(isTextBlock);
103
+ let next = removeBlocks(doc, new Set([id]), c.newId);
104
+ let selection;
105
+ if (before)
106
+ selection = caret(before.id, blockLength(before));
107
+ else if (after)
108
+ selection = caret(after.id, 0);
109
+ else {
110
+ const empty = paragraph([], c.newId());
111
+ next = { ...next, blocks: [...next.blocks, empty] };
112
+ selection = caret(empty.id);
113
+ }
114
+ return done(next, selection, c);
115
+ }
116
+ /** Collapse the selection to a caret in a text block (deleting a range, replacing a node with a paragraph). */
117
+ function prepare(state, c) {
118
+ let { doc, selection } = state;
119
+ if (selection.kind === 'node') {
120
+ const empty = paragraph([], c.newId());
121
+ doc = replaceBlock(doc, selection.block, () => [empty]);
122
+ return { doc, block: empty, offset: 0 };
123
+ }
124
+ if (!isCollapsed(selection)) {
125
+ const deleted = deleteRange(state, c);
126
+ if (!deleted)
127
+ return null;
128
+ doc = deleted.doc;
129
+ selection = deleted.selection;
130
+ }
131
+ if (selection.kind !== 'text')
132
+ return null;
133
+ const block = textLeaf(doc, selection.focus.block);
134
+ return block
135
+ ? { doc, block, offset: Math.min(selection.focus.offset, blockLength(block)) }
136
+ : null;
137
+ }
138
+ const badSelection = () => fail('invalid-selection', 'The selection is not inside a text block.');
139
+ /**
140
+ * Type `text` at the selection (replacing a range). Rich blocks give the text
141
+ * the stored marks, else the marks before the caret; `\n` becomes a break.
142
+ * Code keeps newlines and tabs. Unsafe characters are stripped.
143
+ */
144
+ export function insertText(state, text, options) {
145
+ const c = context(options);
146
+ const at = prepare(state, c);
147
+ if (!at)
148
+ return badSelection();
149
+ const { block, offset } = at;
150
+ let next;
151
+ let length;
152
+ let stripped;
153
+ if (block.type === 'code') {
154
+ const clean = cleanText(text, '\n\t');
155
+ next = { ...block, text: block.text.slice(0, offset) + clean.text + block.text.slice(offset) };
156
+ length = clean.text.length;
157
+ stripped = clean.stripped;
158
+ }
159
+ else {
160
+ const marks = state.storedMarks ?? inheritedMarks(block.content, offset);
161
+ const inserted = textToInline(text, marks);
162
+ next = { ...block, content: spliceInline(block.content, offset, offset, inserted.content) };
163
+ length = inlineLength(inserted.content);
164
+ stripped = inserted.stripped;
165
+ }
166
+ const diagnostics = stripped
167
+ ? [
168
+ {
169
+ code: 'characters-stripped',
170
+ message: 'Control and bidirectional override characters were removed.',
171
+ },
172
+ ]
173
+ : [];
174
+ if (length === 0 && isCollapsed(state.selection)) {
175
+ return fail('nothing-to-do', 'Nothing was left to insert.');
176
+ }
177
+ return done(replaceBlock(at.doc, block.id, () => [next]), caret(block.id, offset + length), c, diagnostics);
178
+ }
179
+ /** Shift+Enter: a break in rich blocks, a newline in code. */
180
+ export function insertBreak(state, options) {
181
+ const c = context(options);
182
+ const at = prepare(state, c);
183
+ if (!at)
184
+ return badSelection();
185
+ const { block, offset } = at;
186
+ const next = block.type === 'code'
187
+ ? { ...block, text: `${block.text.slice(0, offset)}\n${block.text.slice(offset)}` }
188
+ : { ...block, content: spliceInline(block.content, offset, offset, [{ type: 'break' }]) };
189
+ return done(replaceBlock(at.doc, block.id, () => [next]), caret(block.id, offset + 1), c);
190
+ }
191
+ /** Delete the selection (a range or a selected void block). */
192
+ export function deleteSelection(state, options) {
193
+ const c = context(options);
194
+ if (state.selection.kind === 'node')
195
+ return removeNode(state.doc, state.selection.block, c);
196
+ if (isCollapsed(state.selection))
197
+ return fail('nothing-to-do', 'The selection is empty.');
198
+ const deleted = deleteRange(state, c);
199
+ return deleted ? done(deleted.doc, deleted.selection, c) : badSelection();
200
+ }
201
+ function deleteBy(state, unit, forward, options) {
202
+ if (state.selection.kind === 'node' || !isCollapsed(state.selection))
203
+ return deleteSelection(state, options);
204
+ const c = context(options);
205
+ const { block: id, offset } = state.selection.focus;
206
+ const block = textLeaf(state.doc, id);
207
+ if (!block)
208
+ return badSelection();
209
+ const text = blockText(block);
210
+ const end = forward ? text.length : 0;
211
+ if (offset === end)
212
+ return forward ? joinForward(state, block, c) : joinBackward(state, block, c);
213
+ let other;
214
+ if (forward) {
215
+ other =
216
+ unit === 'word'
217
+ ? nextWord(text, offset)
218
+ : unit === 'line'
219
+ ? lineEnd(text, offset)
220
+ : nextGrapheme(text, offset);
221
+ if (other === offset)
222
+ other = nextGrapheme(text, offset);
223
+ }
224
+ else {
225
+ other =
226
+ unit === 'word'
227
+ ? previousWord(text, offset)
228
+ : unit === 'line'
229
+ ? text.lastIndexOf('\n', offset - 1) + 1
230
+ : previousGrapheme(text, offset);
231
+ if (other === offset)
232
+ other = previousGrapheme(text, offset);
233
+ }
234
+ const [a, b] = forward ? [offset, other] : [other, offset];
235
+ const kept = block.type === 'code'
236
+ ? { ...block, text: block.text.slice(0, a) + block.text.slice(b) }
237
+ : {
238
+ ...block,
239
+ content: concatInline(sliceInline(block.content, 0, a), sliceInline(block.content, b)),
240
+ };
241
+ return done(replaceBlock(state.doc, id, () => [kept]), caret(id, a), c);
242
+ }
243
+ const lineEnd = (text, offset) => {
244
+ const end = text.indexOf('\n', offset);
245
+ return end === -1 ? text.length : end;
246
+ };
247
+ /** Backspace: delete before the caret, or apply the block-start rules (lift, join, select a void block). */
248
+ export const deleteBackward = (state, unit = 'grapheme', options) => deleteBy(state, unit, false, options);
249
+ /** Delete: delete after the caret, or join the next block. */
250
+ export const deleteForward = (state, unit = 'grapheme', options) => deleteBy(state, unit, true, options);
251
+ function joinBackward(state, block, c) {
252
+ const { doc } = state;
253
+ const location = locateBlock(doc, block.id);
254
+ const parent = parentOf(location);
255
+ const here = caret(block.id, 0);
256
+ if (block.type === 'heading')
257
+ return done(replaceBlock(doc, block.id, () => [paragraph(block.content, block.id)]), here, c);
258
+ if (block.type === 'code')
259
+ return done(replaceBlock(doc, block.id, () => [paragraph(asInline(block, 0), block.id)]), here, c);
260
+ if (parent?.kind === 'item' && location.index === 0) {
261
+ return done(liftItem(doc, parent.list, parent.itemIndex, c), here, c);
262
+ }
263
+ if (parent?.kind === 'quote' && location.index === 0) {
264
+ const rest = parent.quote.blocks.slice(1);
265
+ return done(replaceBlock(doc, parent.quote.id, (quote) => [
266
+ block,
267
+ ...(rest.length ? [{ ...quote, blocks: rest }] : []),
268
+ ]), here, c);
269
+ }
270
+ const leaves = editorLeaves(doc).map((leaf) => leaf.block);
271
+ const previous = leaves[leaves.findIndex((leaf) => leaf.id === block.id) - 1];
272
+ if (!previous)
273
+ return fail('nothing-to-do', 'The caret is at the start of the document.');
274
+ if (!isTextBlock(previous))
275
+ return done(doc, nodeSelection(previous.id), c);
276
+ const length = blockLength(previous);
277
+ const merged = replaceBlock(doc, previous.id, () => [joined(previous, length, block, 0)]);
278
+ return done(removeBlocks(merged, new Set([block.id]), c.newId), caret(previous.id, length), c);
279
+ }
280
+ function joinForward(state, block, c) {
281
+ const { doc } = state;
282
+ const leaves = editorLeaves(doc).map((leaf) => leaf.block);
283
+ const next = leaves[leaves.findIndex((leaf) => leaf.id === block.id) + 1];
284
+ if (!next)
285
+ return fail('nothing-to-do', 'The caret is at the end of the document.');
286
+ if (!isTextBlock(next))
287
+ return done(doc, nodeSelection(next.id), c);
288
+ const length = blockLength(block);
289
+ const merged = replaceBlock(doc, block.id, () => [joined(block, length, next, 0)]);
290
+ return done(removeBlocks(merged, new Set([next.id]), c.newId), caret(block.id, length), c);
291
+ }
292
+ /** Move item `index` of `list` out one level (nested: into the parent list; top level: out of the list). */
293
+ function liftItem(doc, list, index, c) {
294
+ const item = list.items[index];
295
+ const before = list.items.slice(0, index);
296
+ const after = list.items.slice(index + 1);
297
+ const outer = parentOf(locateBlock(doc, list.id));
298
+ if (outer?.kind === 'item') {
299
+ const children = after.length
300
+ ? [{ id: c.newId(), type: 'list', kind: list.kind, items: after }]
301
+ : [];
302
+ const moved = { ...item, blocks: [...item.blocks, ...children] };
303
+ return replaceItem(doc, outer.item.id, (parent) => [
304
+ {
305
+ ...parent,
306
+ blocks: parent.blocks.flatMap((b) => b.id === list.id ? (before.length ? [{ ...list, items: before }] : []) : [b]),
307
+ },
308
+ moved,
309
+ ]);
310
+ }
311
+ const rest = after.length
312
+ ? {
313
+ id: c.newId(),
314
+ type: 'list',
315
+ kind: list.kind,
316
+ items: after,
317
+ ...(list.kind === 'ordered' ? { start: (list.start ?? 1) + index + 1 } : {}),
318
+ }
319
+ : undefined;
320
+ return replaceBlock(doc, list.id, () => [
321
+ ...(before.length ? [{ ...list, items: before }] : []),
322
+ ...item.blocks,
323
+ ...(rest ? [rest] : []),
324
+ ]);
325
+ }
326
+ /** The list item nearest around the caret. */
327
+ function caretItem(state) {
328
+ if (state.selection.kind !== 'text')
329
+ return null;
330
+ const location = locateBlock(state.doc, state.selection.focus.block);
331
+ const ancestor = location?.ancestors.findLast((a) => a.kind === 'item');
332
+ return ancestor?.kind === 'item' ? ancestor : null;
333
+ }
334
+ /** Shift+Tab in a list: move the caret's item out one level. */
335
+ export function liftListItem(state, options) {
336
+ const c = context(options);
337
+ const found = caretItem(state);
338
+ if (!found)
339
+ return fail('not-allowed-here', 'The caret is not in a list.');
340
+ return done(liftItem(state.doc, found.list, found.itemIndex, c), state.selection, c);
341
+ }
342
+ /** Tab in a list: nest the caret's item under the previous item. */
343
+ export function sinkListItem(state, options) {
344
+ const c = context(options);
345
+ const found = caretItem(state);
346
+ if (!found)
347
+ return fail('not-allowed-here', 'The caret is not in a list.');
348
+ const { list, itemIndex, item } = found;
349
+ const previous = list.items[itemIndex - 1];
350
+ if (!previous)
351
+ return fail('nothing-to-do', 'The first item of a list cannot be indented.');
352
+ const last = previous.blocks.at(-1);
353
+ const nested = last?.type === 'list' && last.kind === list.kind
354
+ ? {
355
+ ...previous,
356
+ blocks: [...previous.blocks.slice(0, -1), { ...last, items: [...last.items, item] }],
357
+ }
358
+ : {
359
+ ...previous,
360
+ blocks: [
361
+ ...previous.blocks,
362
+ { id: c.newId(), type: 'list', kind: list.kind, items: [item] },
363
+ ],
364
+ };
365
+ const items = [...list.items.slice(0, itemIndex - 1), nested, ...list.items.slice(itemIndex + 1)];
366
+ return done(replaceBlock(state.doc, list.id, () => [{ ...list, items }]), state.selection, c);
367
+ }
368
+ /** Mod+Enter: check or uncheck the caret's todo item. */
369
+ export function toggleTodo(state, options) {
370
+ const c = context(options);
371
+ const found = caretItem(state);
372
+ if (found?.list.kind !== 'todo')
373
+ return fail('not-allowed-here', 'The caret is not in a todo list.');
374
+ return done(replaceItem(state.doc, found.item.id, (item) => [{ ...item, checked: !item.checked }]), state.selection, c);
375
+ }
376
+ /**
377
+ * Enter. Splits the block; in list items continues the list (an empty item
378
+ * lifts out); an empty last paragraph of a quote leaves it; at the end of a
379
+ * heading the new block is a paragraph; in code it inserts a newline, and an
380
+ * empty last line leaves the code block.
381
+ */
382
+ export function splitBlock(state, options) {
383
+ const c = context(options);
384
+ if (state.selection.kind === 'node') {
385
+ const empty = paragraph([], c.newId());
386
+ return done(replaceBlock(state.doc, state.selection.block, (node) => [node, empty]), caret(empty.id), c);
387
+ }
388
+ const at = prepare(state, c);
389
+ if (!at)
390
+ return badSelection();
391
+ const { doc, block, offset } = at;
392
+ const location = locateBlock(doc, block.id);
393
+ const parent = parentOf(location);
394
+ const length = blockLength(block);
395
+ if (block.type === 'code') {
396
+ if (offset === length && block.text.endsWith('\n')) {
397
+ const empty = paragraph([], c.newId());
398
+ return done(replaceBlock(doc, block.id, () => [{ ...block, text: block.text.slice(0, -1) }, empty]), caret(empty.id), c);
399
+ }
400
+ const text = `${block.text.slice(0, offset)}\n${block.text.slice(offset)}`;
401
+ return done(replaceBlock(doc, block.id, () => [{ ...block, text }]), caret(block.id, offset + 1), c);
402
+ }
403
+ const head = sliceInline(block.content, 0, offset);
404
+ const tail = sliceInline(block.content, offset);
405
+ if (parent?.kind === 'item' && location.index === 0) {
406
+ if (length === 0 && parent.item.blocks.length === 1) {
407
+ return done(liftItem(doc, parent.list, parent.itemIndex, c), caret(block.id), c);
408
+ }
409
+ const first = paragraph(tail, c.newId());
410
+ const created = { id: c.newId(), checked: false, blocks: [first] };
411
+ const next = replaceItem(doc, parent.item.id, (item) => [
412
+ { ...item, blocks: [{ ...block, content: head }, ...item.blocks.slice(1)] },
413
+ created,
414
+ ]);
415
+ return done(next, caret(first.id), c);
416
+ }
417
+ if (parent?.kind === 'quote' && length === 0 && location.index === location.siblings.length - 1) {
418
+ const rest = location.siblings.slice(0, -1);
419
+ return done(replaceBlock(doc, parent.quote.id, (quote) => [
420
+ ...(rest.length ? [{ ...quote, blocks: rest }] : []),
421
+ paragraph([], block.id),
422
+ ]), caret(block.id), c);
423
+ }
424
+ if (block.type === 'heading' && offset === 0 && length > 0) {
425
+ return done(replaceBlock(doc, block.id, () => [paragraph([], c.newId()), block]), caret(block.id), c);
426
+ }
427
+ const second = block.type === 'heading' && offset < length
428
+ ? { ...block, id: c.newId(), content: tail }
429
+ : paragraph(tail, c.newId());
430
+ return done(replaceBlock(doc, block.id, () => [{ ...block, content: head }, second]), caret(second.id), c);
431
+ }
432
+ /** Text blocks the selection touches, in order. */
433
+ function selectedTexts(state) {
434
+ const range = selectionRange(state.doc, state.selection);
435
+ if (!range)
436
+ return [];
437
+ return editorLeaves(state.doc)
438
+ .slice(range.from.index, range.to.index + 1)
439
+ .flatMap(({ block }, i, all) => isTextBlock(block)
440
+ ? [
441
+ {
442
+ block,
443
+ from: i === 0 ? range.from.offset : 0,
444
+ to: i === all.length - 1 ? range.to.offset : blockLength(block),
445
+ },
446
+ ]
447
+ : []);
448
+ }
449
+ /** Make the selected text blocks paragraphs, headings or code. A list item's first block stays a paragraph. */
450
+ export function setBlockType(state, type, level = 1, options) {
451
+ const c = context(options);
452
+ let doc = state.doc;
453
+ let changed = false;
454
+ for (const { block } of selectedTexts(state)) {
455
+ if (type !== 'paragraph' && isFirstInItem(locateBlock(doc, block.id)))
456
+ continue;
457
+ const next = type === 'code'
458
+ ? { id: block.id, type: 'code', text: asCode(block, 0) }
459
+ : type === 'heading'
460
+ ? { id: block.id, type: 'heading', level, content: asInline(block, 0) }
461
+ : paragraph(asInline(block, 0), block.id);
462
+ if (next.type === block.type &&
463
+ (next.type !== 'heading' || block.type !== 'heading' || next.level === block.level))
464
+ continue;
465
+ doc = replaceBlock(doc, block.id, () => [next]);
466
+ changed = true;
467
+ }
468
+ return changed
469
+ ? done(doc, state.selection, c)
470
+ : fail('nothing-to-do', 'No selected block can take that type.');
471
+ }
472
+ /** The run of siblings the selection spans, in the container of its first block. */
473
+ function siblingSpan(state) {
474
+ const range = selectionRange(state.doc, state.selection);
475
+ if (!range)
476
+ return null;
477
+ const location = locateBlock(state.doc, range.from.block);
478
+ const last = locateBlock(state.doc, range.to.block);
479
+ const depth = location.ancestors.length;
480
+ const sameContainer = location.ancestors.every((a, i) => {
481
+ const b = last.ancestors[i];
482
+ return (b !== undefined &&
483
+ (a.kind === 'quote'
484
+ ? b.kind === 'quote' && b.quote.id === a.quote.id
485
+ : b.kind === 'item' && b.item.id === a.item.id));
486
+ });
487
+ let end = location.index;
488
+ if (sameContainer) {
489
+ const top = last.ancestors[depth];
490
+ const id = top === undefined ? last.block.id : top.kind === 'quote' ? top.quote.id : top.list.id;
491
+ end = Math.max(location.index, location.siblings.findIndex((b) => b.id === id));
492
+ }
493
+ return { location, end };
494
+ }
495
+ /** Replace `siblings[index..end]` of a location with `blocks`. */
496
+ const replaceSpan = (doc, location, end, blocks) => withSiblings(doc, location, [
497
+ ...location.siblings.slice(0, location.index),
498
+ ...blocks,
499
+ ...location.siblings.slice(end + 1),
500
+ ]);
501
+ /**
502
+ * Bullet, ordered or todo list. Inside a list of that kind the selected items
503
+ * leave it; inside another kind the list changes kind; elsewhere the selected
504
+ * paragraphs and headings become items.
505
+ */
506
+ export function toggleList(state, kind, options) {
507
+ const c = context(options);
508
+ const found = caretItem({ ...state, selection: startOf(state) });
509
+ if (found) {
510
+ const { list } = found;
511
+ if (list.kind !== kind)
512
+ return done(replaceBlock(state.doc, list.id, () => [{ ...list, kind }]), state.selection, c);
513
+ return done(liftItem(state.doc, list, found.itemIndex, c), state.selection, c);
514
+ }
515
+ const span = siblingSpan(state);
516
+ if (!span)
517
+ return badSelection();
518
+ const blocks = span.location.siblings.slice(span.location.index, span.end + 1);
519
+ if (!blocks.every(isRich))
520
+ return fail('not-allowed-here', 'Only paragraphs and headings become list items.');
521
+ const items = blocks.map((block) => ({
522
+ id: c.newId(),
523
+ ...(kind === 'todo' ? { checked: false } : {}),
524
+ blocks: [paragraph(block.content, block.id)],
525
+ }));
526
+ const list = { id: c.newId(), type: 'list', kind, items };
527
+ return done(replaceSpan(state.doc, span.location, span.end, [list]), state.selection, c);
528
+ }
529
+ const startOf = (state) => {
530
+ const range = selectionRange(state.doc, state.selection);
531
+ return range ? caret(range.from.block, range.from.offset) : state.selection;
532
+ };
533
+ /** Wrap the selected blocks in a quote, or unwrap the quote around the caret. */
534
+ export function toggleQuote(state, options) {
535
+ const c = context(options);
536
+ const span = siblingSpan(state);
537
+ if (!span)
538
+ return badSelection();
539
+ const parent = parentOf(span.location);
540
+ if (parent?.kind === 'quote') {
541
+ return done(replaceBlock(state.doc, parent.quote.id, () => parent.quote.blocks), state.selection, c);
542
+ }
543
+ const blocks = span.location.siblings.slice(span.location.index, span.end + 1);
544
+ if (isFirstInItem(span.location))
545
+ return fail('not-allowed-here', 'A list item starts with a paragraph.');
546
+ return done(replaceSpan(state.doc, span.location, span.end, [{ id: c.newId(), type: 'quote', blocks }]), state.selection, c);
547
+ }
548
+ /**
549
+ * Toggle a mark over the selection (removed when every eligible character has
550
+ * it). At a caret it toggles the stored marks for the next typed text. Code
551
+ * text takes only `link` besides `code`.
552
+ */
553
+ export function toggleMark(state, type, options) {
554
+ const c = context(options);
555
+ if (state.selection.kind === 'node')
556
+ return fail('not-allowed-here', 'A selected block has no text to mark.');
557
+ if (isCollapsed(state.selection)) {
558
+ const block = textLeaf(state.doc, state.selection.focus.block);
559
+ if (!block || block.type === 'code')
560
+ return fail('not-allowed-here', 'Code blocks take no marks.');
561
+ const base = state.storedMarks ?? inheritedMarks(block.content, state.selection.focus.offset);
562
+ const stored = hasMark(base, type) ? withoutMark(base, type) : withMark(base, { type });
563
+ return { ok: true, state: { ...state, storedMarks: stored } };
564
+ }
565
+ const parts = selectedTexts(state).filter((part) => isRich(part.block) && part.to > part.from);
566
+ const eligible = (marks) => type === 'code' || !marks.some((m) => m.type === 'code');
567
+ const runs = parts
568
+ .flatMap(({ block, from, to }) => textRuns(block.content, from, to))
569
+ .filter((run) => eligible(run.marks));
570
+ if (runs.length === 0)
571
+ return fail('not-allowed-here', 'Nothing selected can take that mark.');
572
+ const remove = runs.every((run) => hasMark(run.marks, type));
573
+ let doc = state.doc;
574
+ for (const { block, from, to } of parts) {
575
+ const rich = block;
576
+ const content = mapMarks(rich.content, from, to, (marks) => !eligible(marks) ? marks : remove ? withoutMark(marks, type) : withMark(marks, { type }));
577
+ doc = replaceBlock(doc, block.id, () => [{ ...rich, content }]);
578
+ }
579
+ return done(doc, state.selection, c);
580
+ }
581
+ /** The extent of the link around `offset`, if any. */
582
+ function linkAt(content, offset) {
583
+ let at = 0;
584
+ const spans = [];
585
+ for (const node of content) {
586
+ const length = node.type === 'text' ? node.text.length : 1;
587
+ const link = node.type === 'text' ? node.marks.find((m) => m.type === 'link') : undefined;
588
+ spans.push({ from: at, to: at + length, key: link ? markKey(link) : null });
589
+ at += length;
590
+ }
591
+ const hit = spans.findIndex((s) => s.key !== null && s.from <= offset && offset <= s.to && (offset < s.to || s.from < offset));
592
+ if (hit === -1)
593
+ return null;
594
+ const key = spans[hit]?.key;
595
+ let first = hit;
596
+ let last = hit;
597
+ while (spans[first - 1]?.key === key)
598
+ first -= 1;
599
+ while (spans[last + 1]?.key === key)
600
+ last += 1;
601
+ return { from: spans[first]?.from ?? 0, to: spans[last]?.to ?? 0 };
602
+ }
603
+ /**
604
+ * Link the selection to `href`, which must pass `safeHref` at `trust`
605
+ * (`untrusted` by default) or the edit is refused. At a caret, updates the
606
+ * link around it, or inserts `options.text` as a new link.
607
+ */
608
+ export function setLink(state, href, options = {}) {
609
+ const c = context(options);
610
+ const safe = href.length <= EDITOR_LIMITS.href ? safeHref(href, resolveTrust(options.trust)) : undefined;
611
+ if (!safe)
612
+ return fail('link-rejected', 'The URL policy refused that link.');
613
+ const link = { type: 'link', href: safe };
614
+ if (state.selection.kind === 'node')
615
+ return fail('not-allowed-here', 'A selected block has no text to link.');
616
+ if (isCollapsed(state.selection)) {
617
+ const { block: id, offset } = state.selection.focus;
618
+ const block = textLeaf(state.doc, id);
619
+ if (!block || block.type === 'code')
620
+ return fail('not-allowed-here', 'Code blocks take no links.');
621
+ if (options.text) {
622
+ return insertText({ ...state, storedMarks: withMark(inheritedMarks(block.content, offset), link) }, options.text, options);
623
+ }
624
+ const extent = linkAt(block.content, offset);
625
+ if (!extent)
626
+ return fail('nothing-to-do', 'Select text to link, or pass the link text.');
627
+ const content = mapMarks(block.content, extent.from, extent.to, (marks) => withMark(marks, link));
628
+ return done(replaceBlock(state.doc, id, () => [{ ...block, content }]), state.selection, c);
629
+ }
630
+ let doc = state.doc;
631
+ let changed = false;
632
+ for (const { block, from, to } of selectedTexts(state)) {
633
+ if (!isRich(block) || to <= from)
634
+ continue;
635
+ doc = replaceBlock(doc, block.id, () => [
636
+ { ...block, content: mapMarks(block.content, from, to, (marks) => withMark(marks, link)) },
637
+ ]);
638
+ changed = true;
639
+ }
640
+ return changed
641
+ ? done(doc, state.selection, c)
642
+ : fail('not-allowed-here', 'Nothing selected can take a link.');
643
+ }
644
+ /** Remove links from the selection, or the whole link around the caret. */
645
+ export function removeLink(state, options) {
646
+ const c = context(options);
647
+ if (state.selection.kind === 'node')
648
+ return fail('nothing-to-do', 'A selected block has no links.');
649
+ let parts = selectedTexts(state);
650
+ if (isCollapsed(state.selection)) {
651
+ const block = textLeaf(state.doc, state.selection.focus.block);
652
+ const extent = block && isRich(block) ? linkAt(block.content, state.selection.focus.offset) : null;
653
+ parts = block && extent ? [{ block, ...extent }] : [];
654
+ }
655
+ let doc = state.doc;
656
+ for (const { block, from, to } of parts) {
657
+ if (!isRich(block))
658
+ continue;
659
+ doc = replaceBlock(doc, block.id, () => [
660
+ {
661
+ ...block,
662
+ content: mapMarks(block.content, from, to, (marks) => withoutMark(marks, 'link')),
663
+ },
664
+ ]);
665
+ }
666
+ return doc === state.doc
667
+ ? fail('nothing-to-do', 'There is no link here.')
668
+ : done(doc, state.selection, c);
669
+ }
670
+ /** Insert a divider or a media reference at the caret; the caret moves to the text after it. */
671
+ export function insertBlock(state, insert, options) {
672
+ const c = context(options);
673
+ let node;
674
+ if (insert.type === 'media') {
675
+ const ref = typeof insert.ref === 'string' ? insert.ref.trim() : '';
676
+ if (!ref || ref.length > EDITOR_LIMITS.href)
677
+ return fail('invalid-shape', 'Media needs a ref.');
678
+ node = { id: c.newId(), type: 'media', ref, alt: cleanText(insert.alt ?? '').text };
679
+ }
680
+ else
681
+ node = { id: c.newId(), type: 'divider' };
682
+ const at = prepare(state, c);
683
+ if (!at)
684
+ return badSelection();
685
+ const { doc, block, offset } = at;
686
+ if (block.type === 'code')
687
+ return fail('not-allowed-here', 'Blocks cannot go inside code.');
688
+ const location = locateBlock(doc, block.id);
689
+ const length = blockLength(block);
690
+ if (length === 0 && block.type === 'paragraph' && !isFirstInItem(location)) {
691
+ return done(replaceBlock(doc, block.id, () => [node, block]), caret(block.id), c);
692
+ }
693
+ if (offset === 0 && !isFirstInItem(location))
694
+ return done(replaceBlock(doc, block.id, () => [node, block]), caret(block.id), c);
695
+ const tail = paragraph(sliceInline(block.content, offset), c.newId());
696
+ const head = { ...block, content: sliceInline(block.content, 0, offset) };
697
+ return done(replaceBlock(doc, block.id, () => [head, node, tail]), caret(tail.id), c);
698
+ }
699
+ /** A deep copy of blocks with fresh ids. */
700
+ function reid(blocks, newId) {
701
+ return blocks.map((block) => {
702
+ if (block.type === 'quote')
703
+ return { ...block, id: newId(), blocks: reid(block.blocks, newId) };
704
+ if (block.type === 'list') {
705
+ return {
706
+ ...block,
707
+ id: newId(),
708
+ items: block.items.map((item) => ({
709
+ ...item,
710
+ id: newId(),
711
+ blocks: reid(item.blocks, newId),
712
+ })),
713
+ };
714
+ }
715
+ return { ...block, id: newId() };
716
+ });
717
+ }
718
+ /**
719
+ * Paste: replace the selection with `fragment` (a loaded document or blocks,
720
+ * already sanitized by `loadEditorDocument`, `fromMarkdown` or the paste
721
+ * import). One paragraph pastes inline; several blocks split the caret block
722
+ * around them. Blocks that would nest too deep become plain paragraphs, and
723
+ * blocks past the size caps are cut, each with a diagnostic.
724
+ */
725
+ export function replaceSelection(state, fragment, options) {
726
+ const c = context(options);
727
+ let blocks = reid(Array.isArray(fragment)
728
+ ? fragment
729
+ : fragment.blocks, c.newId);
730
+ const at = prepare(state, c);
731
+ if (!at)
732
+ return badSelection();
733
+ const { doc, block, offset } = at;
734
+ if (blocks.length === 0)
735
+ return done(doc, caret(block.id, offset), c);
736
+ const diagnostics = [];
737
+ if (block.type === 'code') {
738
+ const text = editorLeaves({ version: 1, blocks })
739
+ .map(({ block: leaf }) => (isTextBlock(leaf) ? asCode(leaf, 0) : ''))
740
+ .join('\n');
741
+ return insertText({ ...state, selection: caret(block.id, offset), doc }, text, options);
742
+ }
743
+ const first = blocks[0];
744
+ if (blocks.length === 1 && isRich(first)) {
745
+ const content = spliceInline(block.content, offset, offset, first.content);
746
+ return done(replaceBlock(doc, block.id, () => [{ ...block, content }]), caret(block.id, offset + inlineLength(first.content)), c);
747
+ }
748
+ const location = locateBlock(doc, block.id);
749
+ const room = c.limits.depth - location.ancestors.length;
750
+ if (blocks.some((b) => maxDepth([b]) > room)) {
751
+ diagnostics.push({
752
+ code: 'depth-capped',
753
+ message: 'Pasted lists and quotes nested too deep became paragraphs.',
754
+ });
755
+ blocks = blocks.flatMap((b) => maxDepth([b]) > room
756
+ ? editorLeaves({ version: 1, blocks: [b] }).flatMap(({ block: leaf }) => isTextBlock(leaf) ? [paragraph(asInline(leaf, 0), c.newId())] : [])
757
+ : [b]);
758
+ }
759
+ let head = { ...block, content: sliceInline(block.content, 0, offset) };
760
+ let tail = paragraph(sliceInline(block.content, offset), c.newId());
761
+ let middle = blocks;
762
+ let target = caret(tail.id);
763
+ const firstBlock = middle[0];
764
+ if (firstBlock && isRich(firstBlock)) {
765
+ head = { ...head, content: concatInline(head.content, firstBlock.content) };
766
+ middle = middle.slice(1);
767
+ }
768
+ const lastBlock = middle.at(-1);
769
+ if (lastBlock && isRich(lastBlock)) {
770
+ target = caret(tail.id, inlineLength(lastBlock.content));
771
+ tail = { ...tail, content: concatInline(lastBlock.content, tail.content) };
772
+ middle = middle.slice(0, -1);
773
+ }
774
+ const build = (kept) => replaceBlock(doc, block.id, () => [head, ...kept, tail]);
775
+ let result = done(build(middle), target, c, diagnostics);
776
+ while (!result.ok && middle.length > 0 && result.errors[0]?.code === 'limit-reached') {
777
+ middle = middle.slice(0, -1);
778
+ result = done(build(middle), target, c, [
779
+ ...diagnostics,
780
+ { code: 'limit-reached', message: 'Pasted blocks past the size caps were cut.' },
781
+ ]);
782
+ }
783
+ return result;
784
+ }
785
+ /**
786
+ * Apply what an input method composed: the block's text as the DOM shows it
787
+ * after `compositionend` (breaks as `\n`) is diffed against the model, and the
788
+ * one changed span is replaced as a single edit.
789
+ */
790
+ export function applyComposition(state, blockId, domText, options) {
791
+ const block = textLeaf(state.doc, blockId);
792
+ if (!block)
793
+ return badSelection();
794
+ const before = blockText(block);
795
+ let prefix = 0;
796
+ while (prefix < before.length && prefix < domText.length && before[prefix] === domText[prefix])
797
+ prefix += 1;
798
+ let suffix = 0;
799
+ while (suffix < before.length - prefix &&
800
+ suffix < domText.length - prefix &&
801
+ before[before.length - 1 - suffix] === domText[domText.length - 1 - suffix])
802
+ suffix += 1;
803
+ const inserted = domText.slice(prefix, domText.length - suffix);
804
+ const selection = {
805
+ kind: 'text',
806
+ anchor: { block: blockId, offset: prefix },
807
+ focus: { block: blockId, offset: before.length - suffix },
808
+ };
809
+ const next = { ...state, selection, storedMarks: null };
810
+ if (inserted === '')
811
+ return isCollapsed(selection)
812
+ ? fail('nothing-to-do', 'The composition changed nothing.')
813
+ : deleteSelection(next, options);
814
+ return insertText(next, inserted, options);
815
+ }
816
+ /** Move the caret's block (or its list item) one place up or down among its siblings. */
817
+ export function moveBlock(state, direction, options) {
818
+ const c = context(options);
819
+ if (state.selection.kind !== 'text' && state.selection.kind !== 'node')
820
+ return badSelection();
821
+ const id = state.selection.kind === 'node' ? state.selection.block : state.selection.focus.block;
822
+ const location = locateBlock(state.doc, id);
823
+ if (!location)
824
+ return badSelection();
825
+ const step = direction === 'up' ? -1 : 1;
826
+ const parent = parentOf(location);
827
+ if (parent?.kind === 'item' && location.index === 0) {
828
+ const { list, itemIndex } = parent;
829
+ const other = itemIndex + step;
830
+ if (other < 0 || other >= list.items.length)
831
+ return fail('nothing-to-do', 'The item is already at the edge of its list.');
832
+ const items = [...list.items];
833
+ [items[itemIndex], items[other]] = [
834
+ items[other],
835
+ items[itemIndex],
836
+ ];
837
+ return done(replaceBlock(state.doc, list.id, () => [{ ...list, items }]), state.selection, c);
838
+ }
839
+ const other = location.index + step;
840
+ if (other < 0 || other >= location.siblings.length || (parent?.kind === 'item' && other === 0)) {
841
+ return fail('nothing-to-do', 'The block is already at the edge of its container.');
842
+ }
843
+ const siblings = [...location.siblings];
844
+ [siblings[location.index], siblings[other]] = [siblings[other], location.block];
845
+ return done(withSiblings(state.doc, location, siblings), state.selection, c);
846
+ }
847
+ export { textLeaf as editorTextBlock };