@k2b/editor 0.0.0 → 0.1.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.
Files changed (83) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +32 -1
  3. package/package.json +116 -4
  4. package/src/BubbleMenu.tsx +126 -0
  5. package/src/Editor.tsx +32 -0
  6. package/src/SlashMenu.tsx +13 -0
  7. package/src/Toolbar.tsx +205 -0
  8. package/src/actions.ts +102 -0
  9. package/src/chart-schema.ts +274 -0
  10. package/src/charts.ts +710 -0
  11. package/src/collab/history.ts +39 -0
  12. package/src/collab/index.ts +32 -0
  13. package/src/collab/relative-cursor.ts +18 -0
  14. package/src/collab/remote-cursors.ts +358 -0
  15. package/src/editor-state.ts +199 -0
  16. package/src/formula.ts +1283 -0
  17. package/src/index.ts +16 -0
  18. package/src/markdown/_lib/cursor-zone-field.ts +338 -0
  19. package/src/markdown/_lib/inline-syntax.ts +9 -0
  20. package/src/markdown/_lib/link-opener.ts +50 -0
  21. package/src/markdown/_lib/list-item-line.ts +34 -0
  22. package/src/markdown/_lib/list-structure.ts +227 -0
  23. package/src/markdown/_lib/math-syntax.ts +3 -0
  24. package/src/markdown/_lib/table-cell.ts +74 -0
  25. package/src/markdown/basic.ts +37 -0
  26. package/src/markdown/block-previews.tsx +453 -0
  27. package/src/markdown/chart-block-messages.ts +5 -0
  28. package/src/markdown/chart-copy.ts +20 -0
  29. package/src/markdown/code-fence-snippets.ts +167 -0
  30. package/src/markdown/code-font.ts +54 -0
  31. package/src/markdown/completion-icon.ts +9 -0
  32. package/src/markdown/data-block-template.ts +11 -0
  33. package/src/markdown/data-blocks.ts +214 -0
  34. package/src/markdown/editor-scope.ts +92 -0
  35. package/src/markdown/fade-scroll-margins.browser-harness.ts +78 -0
  36. package/src/markdown/fade-scroll-margins.ts +25 -0
  37. package/src/markdown/image-syntax.ts +26 -0
  38. package/src/markdown/images.ts +192 -0
  39. package/src/markdown/index.ts +54 -0
  40. package/src/markdown/info-block-snippets.ts +159 -0
  41. package/src/markdown/info-blocks.ts +156 -0
  42. package/src/markdown/katex.ts +303 -0
  43. package/src/markdown/lib/chart-blocks.ts +511 -0
  44. package/src/markdown/lib/heading-anchors.ts +40 -0
  45. package/src/markdown/lib/ligatures.ts +107 -0
  46. package/src/markdown/lib/markdown-context.ts +72 -0
  47. package/src/markdown/lib/markdown-fences.ts +22 -0
  48. package/src/markdown/lib/markdown-image.fixtures.ts +20 -0
  49. package/src/markdown/lib/markdown-image.ts +36 -0
  50. package/src/markdown/lib/named-blocks.ts +403 -0
  51. package/src/markdown/lib/query-blocks.ts +145 -0
  52. package/src/markdown/ligatures.ts +223 -0
  53. package/src/markdown/links.ts +158 -0
  54. package/src/markdown/list-commands.ts +1124 -0
  55. package/src/markdown/lists.ts +290 -0
  56. package/src/markdown/mark.ts +103 -0
  57. package/src/markdown/markdown.ts +78 -0
  58. package/src/markdown/markup.ts +385 -0
  59. package/src/markdown/mermaid-export.ts +78 -0
  60. package/src/markdown/mermaid-preview.tsx +76 -0
  61. package/src/markdown/mermaid-render.ts +22 -0
  62. package/src/markdown/mermaid-viewport.tsx +78 -0
  63. package/src/markdown/mermaid.ts +242 -0
  64. package/src/markdown/messages.ts +17 -0
  65. package/src/markdown/named-blocks.ts +84 -0
  66. package/src/markdown/note-scroller.browser-harness.ts +84 -0
  67. package/src/markdown/note-scroller.ts +57 -0
  68. package/src/markdown/notebook-charts.tsx +142 -0
  69. package/src/markdown/pretty-table-messages.ts +33 -0
  70. package/src/markdown/pretty-table.ts +220 -0
  71. package/src/markdown/search-theme.ts +98 -0
  72. package/src/markdown/shared-tables.ts +286 -0
  73. package/src/markdown/sub-sup.ts +115 -0
  74. package/src/markdown/surface-press.ts +17 -0
  75. package/src/markdown/tab-indent.ts +104 -0
  76. package/src/markdown/table-columns.ts +188 -0
  77. package/src/markdown/table-formulas.ts +178 -0
  78. package/src/markdown/tables.ts +703 -0
  79. package/src/markdown/theme.ts +243 -0
  80. package/src/markdown/urls.ts +29 -0
  81. package/src/messages.ts +45 -0
  82. package/src/slash-menu.ts +220 -0
  83. package/src/styles.css +376 -0
@@ -0,0 +1,1124 @@
1
+ /** List typing, kinds, indentation, subtree moves and local number repair, shared by keys and the toolbar. */
2
+ import { completionStatus } from "@codemirror/autocomplete";
3
+ import { isolateHistory } from "@codemirror/commands";
4
+ import { ensureSyntaxTree, language, syntaxTreeAvailable } from "@codemirror/language";
5
+ import {
6
+ Annotation,
7
+ ChangeSet,
8
+ type ChangeSpec,
9
+ EditorSelection,
10
+ EditorState,
11
+ type Extension,
12
+ type Line,
13
+ MapMode,
14
+ Prec,
15
+ type Text,
16
+ Transaction,
17
+ } from "@codemirror/state";
18
+ import { type Command, EditorView, keymap } from "@codemirror/view";
19
+ import type { Tree } from "@lezer/common";
20
+ import { asUndoStep } from "../editor-state";
21
+ import { inCode, LIST_ITEM, type ListItemLine, leadingWhitespace, listItemLine } from "./_lib/list-item-line";
22
+ import {
23
+ type ExcludedSpan,
24
+ excludedSpans,
25
+ listExcludedRanges,
26
+ listStructure,
27
+ type MarkdownRangeOptions,
28
+ markerChanges,
29
+ renumberChanges,
30
+ siblingIndex,
31
+ } from "./_lib/list-structure";
32
+ import { listStateField } from "./lists";
33
+
34
+ type Direction = 1 | -1;
35
+ const mac = typeof navigator !== "undefined" && /Mac/.test(navigator.platform);
36
+
37
+ /**
38
+ * Marks a command that already restarted the numbers after an ordered item that left its list. The renumber filter
39
+ * skips it: while the new paragraph line is still empty, Markdown reads both parts as one list and would count on.
40
+ */
41
+ const orderedSuffixRestart = Annotation.define<boolean>();
42
+
43
+ const rawExcludedSpans = new WeakMap<Text, Map<readonly NonNullable<MarkdownRangeOptions["excludedRanges"]>[], readonly ExcludedSpan[]>>();
44
+ const excludedLines = (state: EditorState): (() => readonly ExcludedSpan[]) => {
45
+ const listState = state.field(listStateField, false);
46
+ const sources = state.facet(listExcludedRanges);
47
+ let spans = listState ? listState.excluded : rawExcludedSpans.get(state.doc)?.get(sources);
48
+ return () => {
49
+ if (!spans) {
50
+ spans = excludedSpans(state);
51
+ if (!listState) {
52
+ let cached = rawExcludedSpans.get(state.doc);
53
+ if (!cached) {
54
+ cached = new Map();
55
+ rawExcludedSpans.set(state.doc, cached);
56
+ }
57
+ cached.set(sources, spans);
58
+ }
59
+ }
60
+ return spans;
61
+ };
62
+ };
63
+
64
+ const sameKind = (a: string, b: string) => a.at(-1) === b.at(-1);
65
+
66
+ /**
67
+ * The nearest item above in the source, which also finds empty, not-yet-parsed items: `sibling` is the previous item
68
+ * of the same list, `previous` any item at the same level (Tab nests under it, whatever its kind), `parent` the item
69
+ * one level up.
70
+ */
71
+ const itemAbove = (
72
+ state: EditorState,
73
+ line: Line,
74
+ item: ListItemLine,
75
+ relation: "sibling" | "previous" | "parent",
76
+ excluded: () => readonly ExcludedSpan[],
77
+ tree: Tree,
78
+ ): Line | null => {
79
+ for (let number = line.number - 1; number >= 1; number--) {
80
+ const above = state.doc.line(number);
81
+ if (!above.text.trim() || leadingWhitespace(above.text) > item.indent) continue;
82
+ if (relation === "parent" && leadingWhitespace(above.text) === item.indent) continue;
83
+ const candidate = listItemLine(tree, above, excluded);
84
+ if (!candidate || relation === "parent") return candidate ? above : null;
85
+ return candidate.indent === item.indent && (relation === "previous" || sameKind(candidate.marker, item.marker)) ? above : null;
86
+ }
87
+ return null;
88
+ };
89
+
90
+ const ordinalAt = (state: EditorState, line: Line, excluded: () => readonly ExcludedSpan[], tree: Tree): number => {
91
+ let item = listItemLine(tree, line, excluded)!;
92
+ let count = 0;
93
+ for (
94
+ let previous = itemAbove(state, line, item, "sibling", excluded, tree);
95
+ previous;
96
+ previous = itemAbove(state, line, item, "sibling", excluded, tree)
97
+ ) {
98
+ line = previous;
99
+ item = listItemLine(tree, line, excluded)!;
100
+ count++;
101
+ }
102
+ return Number.parseInt(item.marker, 10) + count;
103
+ };
104
+
105
+ const adoptedMarker = (state: EditorState, target: Line, offset: number, excluded: () => readonly ExcludedSpan[], tree: Tree): string => {
106
+ const marker = listItemLine(tree, target, excluded)!.marker;
107
+ return /^\d/.test(marker) ? `${ordinalAt(state, target, excluded, tree) + offset}${marker.at(-1)}` : marker;
108
+ };
109
+
110
+ /** Selection lines, excluding an unselected line at the end of a range like CodeMirror's line commands. */
111
+ const selectedLines = (state: EditorState): Line[] => {
112
+ const lines: Line[] = [];
113
+ let last = 0;
114
+ for (const range of state.selection.ranges) {
115
+ for (let pos = range.from; pos <= range.to; ) {
116
+ const line = state.doc.lineAt(pos);
117
+ if (line.number > last && (range.empty || range.to > line.from)) {
118
+ lines.push(line);
119
+ last = line.number;
120
+ }
121
+ pos = line.to + 1;
122
+ }
123
+ }
124
+ return lines;
125
+ };
126
+
127
+ const nestedEnd = (state: EditorState, line: Line, indent: number): number => {
128
+ let end = line.number;
129
+ for (let number = line.number + 1; number <= state.doc.lines; number++) {
130
+ const next = state.doc.line(number);
131
+ if (next.text.trim() && leadingWhitespace(next.text) <= indent) break;
132
+ end = number;
133
+ }
134
+ return end;
135
+ };
136
+
137
+ const shiftChanges = (state: EditorState, direction: Direction): ChangeSet | null => {
138
+ const headLine = state.doc.lineAt(state.selection.main.head);
139
+ if (!LIST_ITEM.test(headLine.text)) return null;
140
+ // Keep the returned tree: parser progress need not have reached the state field yet.
141
+ let tree = ensureSyntaxTree(state, headLine.to, 50);
142
+ if (!tree) return state.changes([]);
143
+ const excluded = excludedLines(state);
144
+ const headItem = listItemLine(tree, headLine, excluded);
145
+ if (!headItem) return null;
146
+ if (state.readOnly) return state.changes([]);
147
+ const above = itemAbove(state, headLine, headItem, direction > 0 ? "previous" : "parent", excluded, tree);
148
+ const width = above
149
+ ? direction > 0
150
+ ? listItemLine(tree, above, excluded)!.content - headItem.indent
151
+ : headItem.indent - listItemLine(tree, above, excluded)!.indent
152
+ : direction < 0
153
+ ? Math.min(headItem.indent, 2)
154
+ : 0;
155
+ if (!width && direction > 0) return state.changes([]);
156
+
157
+ const lines = selectedLines(state);
158
+ const upto = Math.max(headLine.to, lines.at(-1)?.to ?? 0);
159
+ tree = ensureSyntaxTree(state, upto, 50);
160
+ if (!tree) return state.changes([]);
161
+ const deltas = new Map<number, number>();
162
+ const markerItems: Array<{ line: number; to: number; markerFrom: number; markerTo: number }> = [];
163
+ const markers = new Map<number, string>();
164
+ const rewrite = (line: Line, item: ListItemLine, end: number, marker: string) => {
165
+ markers.set(markerItems.length, marker);
166
+ markerItems.push({
167
+ line: line.number,
168
+ to: state.doc.line(end).to,
169
+ markerFrom: line.from + item.indent,
170
+ markerTo: line.from + item.indent + item.marker.length,
171
+ });
172
+ };
173
+ let last = 0;
174
+ let previousTarget: Line | null = null;
175
+ let targetOffset = 0;
176
+ const lifted: Array<{ line: Line; item: ListItemLine; end: number; marker: string; width: number }> = [];
177
+ for (const line of lines) {
178
+ if (line.number <= last) continue;
179
+ const item = listItemLine(tree, line, excluded);
180
+ const end = item ? nestedEnd(state, line, item.indent) : line.number;
181
+ let itemWidth = width;
182
+ if (item) {
183
+ // Following siblings may need a new marker when this item is lifted.
184
+ const upto = state.doc.line(Math.min(state.doc.lines, end + 1)).to;
185
+ tree = ensureSyntaxTree(state, upto, 50);
186
+ if (!tree) return state.changes([]);
187
+ const target = itemAbove(state, line, item, direction > 0 ? "previous" : "parent", excluded, tree);
188
+ // A selection beginning with the first sibling cannot fabricate a nesting level either.
189
+ if (direction > 0 && !target) return state.changes([]);
190
+ itemWidth = target
191
+ ? direction > 0
192
+ ? (deltas.get(target.number) ?? listItemLine(tree, target, excluded)!.content - item.indent)
193
+ : item.indent - listItemLine(tree, target, excluded)!.indent
194
+ : Math.min(item.indent, 2);
195
+ let marker = item.marker;
196
+ if (target) {
197
+ if (direction > 0) {
198
+ const parent = listItemLine(tree, target, excluded)!;
199
+ let child: Line | null = null;
200
+ for (let number = target.number + 1; number < line.number; number++) {
201
+ if (deltas.has(number)) continue;
202
+ const candidate = state.doc.line(number);
203
+ const parsed = listItemLine(tree, candidate, excluded);
204
+ if (parsed && parsed.indent >= parent.content && (!child || parsed.indent <= listItemLine(tree, child, excluded)!.indent))
205
+ child = candidate;
206
+ }
207
+ // Selected siblings all join the first selected item's destination list.
208
+ const destination: Line | null = deltas.has(target.number) ? previousTarget : child;
209
+ if (!deltas.has(target.number)) {
210
+ previousTarget = destination;
211
+ targetOffset = 0;
212
+ }
213
+ marker = destination
214
+ ? adoptedMarker(state, destination, ++targetOffset, excluded, tree)
215
+ : /^\d/.test(item.marker)
216
+ ? `${++targetOffset}${item.marker.at(-1)}`
217
+ : item.marker;
218
+ } else {
219
+ if (previousTarget?.number !== target.number) targetOffset = 0;
220
+ previousTarget = target;
221
+ marker = adoptedMarker(state, target, ++targetOffset, excluded, tree);
222
+ lifted.push({ line, item, end, marker, width: itemWidth });
223
+ }
224
+ }
225
+ rewrite(line, item, end, marker);
226
+ }
227
+ for (let number = line.number; number <= end; number++) deltas.set(number, direction * itemWidth);
228
+ last = end;
229
+ }
230
+ for (const current of lifted) {
231
+ const { item, end, marker, width } = current;
232
+ // All remaining parent content becomes content of the lifted item, regardless of its kind.
233
+ const delta = item.content - width + marker.length - item.marker.length - item.indent;
234
+ const parent = itemAbove(state, current.line, item, "parent", excluded, tree);
235
+ let followingEnd = parent ? nestedEnd(state, parent, listItemLine(tree, parent, excluded)!.indent) : state.doc.lines;
236
+ const nextLifted = lifted.find((other) => other.line.number > end);
237
+ if (nextLifted) followingEnd = Math.min(followingEnd, nextLifted.line.number - 1);
238
+ for (let number = end + 1; number <= followingEnd; number++) if (!deltas.has(number)) deltas.set(number, delta);
239
+ const next = end < followingEnd ? state.doc.line(end + 1) : null;
240
+ const sibling = next && listItemLine(tree, next, excluded);
241
+ if (next && sibling && sibling.indent === item.indent && sameKind(sibling.marker, item.marker) && /^\d/.test(sibling.marker))
242
+ rewrite(next, sibling, nestedEnd(state, next, sibling.indent), `1${sibling.marker.at(-1)}`);
243
+ }
244
+ const specs: ChangeSpec[] = [];
245
+ for (const [number, delta] of deltas) {
246
+ const line = state.doc.line(number);
247
+ if (!line.text.trim()) continue;
248
+ if (delta > 0) specs.push({ from: line.from, insert: " ".repeat(delta) });
249
+ else if (delta < 0) specs.push({ from: line.from, to: line.from + Math.min(-delta, /^ */.exec(line.text)![0].length) });
250
+ }
251
+ const indentation = state.changes(specs);
252
+ // Compute width deltas on the shifted text: mapping two deletions of the same
253
+ // leading spaces would otherwise collapse the second deletion.
254
+ const shifted = EditorState.create({ doc: indentation.apply(state.doc) });
255
+ const items = markerItems.map((item) => ({
256
+ ...item,
257
+ markerFrom: indentation.mapPos(item.markerFrom, 1),
258
+ markerTo: indentation.mapPos(item.markerTo, 1),
259
+ to: indentation.mapPos(item.to, 1),
260
+ }));
261
+ return indentation.compose(markerChanges(shifted, { items }, markers));
262
+ };
263
+
264
+ const shiftListItems =
265
+ (direction: Direction): Command =>
266
+ (view) => {
267
+ const changes = shiftChanges(view.state, direction);
268
+ if (!changes) return false;
269
+ if (!changes.empty)
270
+ view.dispatch({
271
+ changes,
272
+ selection: view.state.selection.map(changes, 1),
273
+ scrollIntoView: true,
274
+ userEvent: direction > 0 ? "input.indent" : "delete.dedent",
275
+ });
276
+ return true;
277
+ };
278
+
279
+ export const indentListItems: Command = shiftListItems(1);
280
+ export const outdentListItems: Command = shiftListItems(-1);
281
+ export const listIndentAvailability = (state: EditorState): { indent: boolean; outdent: boolean } => {
282
+ const disabled = { indent: false, outdent: false };
283
+ const line = state.doc.lineAt(state.selection.main.head);
284
+ if (state.readOnly || !LIST_ITEM.test(line.text)) return disabled;
285
+ const tree = ensureSyntaxTree(state, line.to, 50);
286
+ if (!tree) return disabled;
287
+ const excluded = excludedLines(state);
288
+ const item = listItemLine(tree, line, excluded);
289
+ if (!item) return disabled;
290
+ return {
291
+ indent: !!itemAbove(state, line, item, "previous", excluded, tree),
292
+ outdent: item.indent > 0,
293
+ };
294
+ };
295
+
296
+ const taskBox = (line: Line, item: ListItemLine) => /^[ \t]*\[[ xX]\](?:[ \t]+|$)/.exec(line.text.slice(item.content));
297
+ const textStart = (line: Line, item: ListItemLine) => {
298
+ let start = Math.min(line.text.length, item.content + (taskBox(line, item)?.[0].length ?? 0));
299
+ while (start < line.text.length && /[ \t]/.test(line.text[start]!)) start++;
300
+ return line.from + start;
301
+ };
302
+ const itemKind = (line: Line, item: ListItemLine) => (taskBox(line, item) ? "task" : /^\d/.test(item.marker) ? "ordered" : "bullet");
303
+ /** The kind shared by every selected line; its toggle only turns top-level items without children back into text. */
304
+ export const listKindAt = (state: EditorState) => {
305
+ const lines = selectedLines(state);
306
+ const last = lines.at(-1);
307
+ if (!last) return null;
308
+ const tree = ensureSyntaxTree(state, last.to, 50);
309
+ if (!tree) return null;
310
+ const excluded = excludedLines(state);
311
+ const kinds = lines.map((line) => {
312
+ const item = listItemLine(tree, line, excluded);
313
+ return item ? itemKind(line, item) : null;
314
+ });
315
+ const kind = kinds[0];
316
+ return kind && kinds.every((value) => value === kind) ? kind : null;
317
+ };
318
+
319
+ const emptyPrefix = (line: Line, item: ListItemLine) =>
320
+ `${line.text.slice(0, item.indent)}${item.marker} ${taskBox(line, item) ? "[ ] " : ""}`;
321
+
322
+ const firstChild = (state: EditorState, line: Line, item: ListItemLine, tree: Tree, excluded: () => readonly ExcludedSpan[]) => {
323
+ const end = nestedEnd(state, line, item.indent);
324
+ for (let number = line.number + 1; number <= end; number++) {
325
+ const child = state.doc.line(number);
326
+ const parsed = listItemLine(tree, child, excluded);
327
+ if (parsed && parsed.indent > item.indent) return { line: child, item: parsed };
328
+ }
329
+ return null;
330
+ };
331
+
332
+ /** Restart the old ordered suffix before conversion can make its markers lazy paragraph text. */
333
+ const followingOrderedMarkers = (state: EditorState, removed: readonly Line[], tree: Tree, excluded: () => readonly ExcludedSpan[]) => {
334
+ const converted = new Set(removed.map((line) => line.number));
335
+ const markers: Array<{ line: number; to: number; markerFrom: number; markerTo: number; marker: string }> = [];
336
+ for (const line of removed) {
337
+ const item = listItemLine(tree, line, excluded);
338
+ if (!item || !/^\d/.test(item.marker)) continue;
339
+ let ordinal = 1;
340
+ for (let number = nestedEnd(state, line, item.indent) + 1; number <= state.doc.lines; ) {
341
+ const next = state.doc.line(number);
342
+ if (!next.text.trim()) {
343
+ number++;
344
+ continue;
345
+ }
346
+ // Another converted sibling owns the restart after itself.
347
+ if (converted.has(number)) break;
348
+ const end = nestedEnd(state, next, item.indent);
349
+ const parsed = ensureSyntaxTree(state, state.doc.line(end).to, 50);
350
+ if (!parsed) break;
351
+ tree = parsed;
352
+ const sibling = listItemLine(tree, next, excluded);
353
+ if (!sibling || sibling.indent !== item.indent || !/^\d/.test(sibling.marker) || !sameKind(sibling.marker, item.marker)) break;
354
+ markers.push({
355
+ line: number,
356
+ to: state.doc.line(end).to,
357
+ markerFrom: next.from + sibling.indent,
358
+ markerTo: next.from + sibling.indent + sibling.marker.length,
359
+ marker: `${ordinal++}${sibling.marker.at(-1)}`,
360
+ });
361
+ number = end + 1;
362
+ }
363
+ }
364
+ return markers;
365
+ };
366
+
367
+ /** Neighbours retain their old structure, except lines converted to paragraphs in this command. */
368
+ const separateLines = (state: EditorState, changes: ChangeSet, paragraphs: readonly number[], emptyItems: readonly number[] = []) => {
369
+ if (!paragraphs.length && !emptyItems.length) return changes;
370
+ const last = Math.max(
371
+ ...paragraphs.map((from) => {
372
+ const line = state.doc.lineAt(from);
373
+ return nestedEnd(state, line, leadingWhitespace(line.text));
374
+ }),
375
+ ...emptyItems.map((from) => state.doc.lineAt(from).number),
376
+ );
377
+ const tree = ensureSyntaxTree(state, state.doc.line(Math.min(state.doc.lines, last + 1)).to, 50);
378
+ if (!tree) return changes;
379
+ const excluded = excludedLines(state);
380
+ const converted = new Set(paragraphs.map((from) => state.doc.lineAt(from).number));
381
+ const blanks = new Set<number>();
382
+ const listContent = (line: Line) => {
383
+ if (converted.has(line.number)) return false;
384
+ if (listItemLine(tree, line, excluded)) return true;
385
+ const pos = line.from + leadingWhitespace(line.text);
386
+ if (excluded().some((span) => pos >= span.from && pos < span.to)) return false;
387
+ let inside = false;
388
+ for (let node = tree.resolveInner(pos, 1); node; ) {
389
+ if (node.name === "Blockquote") return false;
390
+ if (node.name === "ListItem") inside = true;
391
+ if (!node.parent) break;
392
+ node = node.parent;
393
+ }
394
+ return inside;
395
+ };
396
+ for (const number of converted) {
397
+ const line = state.doc.line(number);
398
+ const above = number > 1 ? state.doc.line(number - 1) : null;
399
+ const end = nestedEnd(state, line, leadingWhitespace(line.text));
400
+ const below = end < state.doc.lines ? state.doc.line(end + 1) : null;
401
+ if (above?.text.trim() && !converted.has(above.number) && listContent(above)) blanks.add(line.from);
402
+ if (below && !converted.has(below.number) && listItemLine(tree, below, excluded)) blanks.add(below.from);
403
+ }
404
+ for (const from of emptyItems) {
405
+ const line = state.doc.lineAt(from);
406
+ const above = line.number > 1 ? state.doc.line(line.number - 1) : null;
407
+ if (above?.text.trim() && !listContent(above)) blanks.add(line.from);
408
+ }
409
+ return changes.compose(
410
+ ChangeSet.of(
411
+ [...blanks].sort((a, b) => a - b).map((from) => ({ from: changes.mapPos(from, -1), insert: "\n" })),
412
+ changes.newLength,
413
+ ),
414
+ );
415
+ };
416
+
417
+ const liftAtCaret = (view: EditorView, line: Line, item: ListItemLine, event: string, annotation?: Annotation<unknown>) => {
418
+ const changes = shiftChanges(view.state, -1)!;
419
+ if (!changes.empty) {
420
+ view.dispatch({
421
+ changes,
422
+ selection: { anchor: changes.mapPos(textStart(line, item), 1) },
423
+ scrollIntoView: true,
424
+ userEvent: event,
425
+ annotations: annotation,
426
+ });
427
+ }
428
+ return true;
429
+ };
430
+
431
+ const paragraphAtCaret = (
432
+ view: EditorView,
433
+ line: Line,
434
+ item: ListItemLine,
435
+ event: string,
436
+ tree: Tree,
437
+ excluded: () => readonly ExcludedSpan[],
438
+ annotation?: Annotation<unknown>,
439
+ ) => {
440
+ const { state } = view;
441
+ const from = textStart(line, item);
442
+ const following = followingOrderedMarkers(state, [line], tree, excluded);
443
+ const rewritten = markerChanges(state, { items: following }, new Map(following.map((item, index) => [index, item.marker])));
444
+ const specs: ChangeSpec[] = [{ from: rewritten.mapPos(line.from), to: rewritten.mapPos(from) }];
445
+ const end = nestedEnd(state, line, item.indent);
446
+ for (let number = line.number + 1; number <= end; number++) {
447
+ const continuation = state.doc.line(number);
448
+ if (!continuation.text.trim()) continue;
449
+ specs.push({
450
+ from: rewritten.mapPos(continuation.from),
451
+ to: rewritten.mapPos(continuation.from + Math.min(leadingWhitespace(continuation.text), item.content)),
452
+ });
453
+ }
454
+ const paragraph = ChangeSet.of(specs, rewritten.newLength);
455
+ const changes = separateLines(state, rewritten.compose(paragraph), [line.from]);
456
+ view.dispatch({
457
+ changes,
458
+ selection: { anchor: changes.mapPos(from, 1) },
459
+ scrollIntoView: true,
460
+ userEvent: event,
461
+ annotations: [orderedSuffixRestart.of(following.length > 0), ...(annotation ? [annotation] : [])],
462
+ });
463
+ return true;
464
+ };
465
+
466
+ const singleCaret = (state: EditorState) => state.selection.ranges.length === 1 && state.selection.main.empty;
467
+
468
+ const enterListItem: Command = (view) => {
469
+ const { state } = view;
470
+ if (state.readOnly || !singleCaret(state) || completionStatus(state) === "active") return false;
471
+ const head = state.selection.main.head;
472
+ const line = state.doc.lineAt(head);
473
+ const tree = ensureSyntaxTree(state, state.doc.line(nestedEnd(state, line, leadingWhitespace(line.text))).to, 50);
474
+ if (!tree) return false;
475
+ const excluded = excludedLines(state);
476
+ const item = listItemLine(tree, line, excluded);
477
+ if (!item) return false;
478
+ const start = textStart(line, item);
479
+ const child = firstChild(state, line, item, tree, excluded);
480
+ if (!line.text.slice(start - line.from).trim()) {
481
+ if (item.indent) return liftAtCaret(view, line, item, "input", isolateHistory.of("full"));
482
+ return child ? true : paragraphAtCaret(view, line, item, "input", tree, excluded, isolateHistory.of("full"));
483
+ }
484
+ if (head <= start) {
485
+ const prefix = emptyPrefix(line, item);
486
+ const inserted = state.changes({ from: line.from, insert: prefix + "\n" });
487
+ const separated = separateLines(state, inserted, [], [line.from]);
488
+ view.dispatch({
489
+ changes: separated,
490
+ selection: { anchor: separated.mapPos(start, 1) },
491
+ scrollIntoView: true,
492
+ userEvent: "input",
493
+ annotations: isolateHistory.of("full"),
494
+ });
495
+ return true;
496
+ }
497
+ if (!child) return false;
498
+ let from = head;
499
+ while (from > start && /\s/.test(state.sliceDoc(from - 1, from))) from--;
500
+ const prefix = emptyPrefix(child.line, child.item);
501
+ view.dispatch({
502
+ changes: { from, to: head, insert: "\n" + prefix },
503
+ selection: { anchor: from + 1 + prefix.length },
504
+ scrollIntoView: true,
505
+ userEvent: "input",
506
+ annotations: isolateHistory.of("full"),
507
+ });
508
+ return true;
509
+ };
510
+
511
+ const backspaceListItem: Command = (view) => {
512
+ const { state } = view;
513
+ if (state.readOnly || !singleCaret(state)) return false;
514
+ const head = state.selection.main.head;
515
+ const line = state.doc.lineAt(head);
516
+ const tree = ensureSyntaxTree(state, state.doc.line(nestedEnd(state, line, leadingWhitespace(line.text))).to, 50);
517
+ if (!tree) return false;
518
+ const excluded = excludedLines(state);
519
+ const item = listItemLine(tree, line, excluded);
520
+ if (item && head <= textStart(line, item)) {
521
+ if (item.indent) return liftAtCaret(view, line, item, "delete.dedent");
522
+ return firstChild(state, line, item, tree, excluded) ? true : paragraphAtCaret(view, line, item, "delete", tree, excluded);
523
+ }
524
+ if (excluded().some((span) => head >= span.from && head < span.to)) return false;
525
+ for (let node = tree.resolveInner(head, -1); node; ) {
526
+ if (/^ATXHeading[1-6]$/.test(node.name) && /^(Document|Blockquote|ListItem)$/.test(node.parent?.name ?? "")) {
527
+ const mark = node.getChild("HeaderMark");
528
+ if (!mark) return false;
529
+ let start = mark.to;
530
+ while (start < line.to && /[ \t]/.test(state.sliceDoc(start, start + 1))) start++;
531
+ if (head !== start) return false;
532
+ const from = node.parent?.name === "Document" ? line.from : mark.from;
533
+ view.dispatch({
534
+ changes: { from, to: start },
535
+ selection: { anchor: from },
536
+ scrollIntoView: true,
537
+ userEvent: "delete",
538
+ });
539
+ return true;
540
+ }
541
+ if (!node.parent) break;
542
+ node = node.parent;
543
+ }
544
+ return false;
545
+ };
546
+
547
+ export const setListKind =
548
+ (kind: "bullet" | "ordered" | "task", count = 1): Command =>
549
+ (view) => {
550
+ const { state } = view;
551
+ if (state.readOnly) return false;
552
+ const lines = count > 1 ? [state.doc.lineAt(state.selection.main.head)] : selectedLines(state);
553
+ const last = lines.at(-1);
554
+ if (!last) return true;
555
+ const tree = ensureSyntaxTree(state, state.doc.line(nestedEnd(state, last, leadingWhitespace(last.text))).to, 50);
556
+ if (!tree) return false;
557
+ const excluded = excludedLines(state);
558
+ const parsed = lines.map((line) => ({ line, item: listItemLine(tree, line, excluded) }));
559
+ const toggle = count === 1 && parsed.every(({ line, item }) => item && itemKind(line, item) === kind);
560
+ const specs: ChangeSpec[] = [];
561
+ const markers = new Map<number, string>();
562
+ const markerItems: Array<{ line: number; to: number; markerFrom: number; markerTo: number }> = [];
563
+ const paragraphs: number[] = [];
564
+ const emptyItems: number[] = [];
565
+ const removed: Line[] = [];
566
+ for (const { line, item } of parsed) {
567
+ if (toggle) {
568
+ if (item && !item.indent && !firstChild(state, line, item, tree, excluded)) {
569
+ specs.push({ from: line.from, to: textStart(line, item) });
570
+ const end = nestedEnd(state, line, item.indent);
571
+ for (let number = line.number + 1; number <= end; number++) {
572
+ const continuation = state.doc.line(number);
573
+ if (!continuation.text.trim()) continue;
574
+ specs.push({
575
+ from: continuation.from,
576
+ to: continuation.from + Math.min(leadingWhitespace(continuation.text), item.content),
577
+ });
578
+ }
579
+ paragraphs.push(line.from);
580
+ removed.push(line);
581
+ }
582
+ continue;
583
+ }
584
+ if (item && itemKind(line, item) === kind) continue;
585
+ if (!item) {
586
+ if (
587
+ (!line.text.trim() && count === 1 && !singleCaret(state)) ||
588
+ inCode(tree, line.from + leadingWhitespace(line.text)) ||
589
+ excluded().some((span) => line.from >= span.from && line.from < span.to)
590
+ )
591
+ continue;
592
+ const from = line.from + leadingWhitespace(line.text);
593
+ specs.push({ from, insert: kind === "ordered" ? "1. " : kind === "task" ? "- [ ] " : "- " });
594
+ if (!line.text.trim()) emptyItems.push(line.from);
595
+ continue;
596
+ }
597
+ let marker = "-";
598
+ if (kind === "ordered") {
599
+ const above = itemAbove(state, line, item, "previous", excluded, tree);
600
+ const below = state.doc.line(Math.min(state.doc.lines, nestedEnd(state, line, item.indent) + 1));
601
+ const adjacent = [above, below]
602
+ .map((candidate) => candidate && listItemLine(tree, candidate, excluded))
603
+ .find((candidate) => candidate?.indent === item.indent && /^\d/.test(candidate.marker));
604
+ marker = `1${adjacent?.marker.at(-1) ?? "."}`;
605
+ }
606
+ if (/^\d/.test(item.marker) && !/^\d/.test(marker)) removed.push(line);
607
+ markers.set(markerItems.length, marker);
608
+ markerItems.push({
609
+ line: line.number,
610
+ to: state.doc.line(nestedEnd(state, line, item.indent)).to,
611
+ markerFrom: line.from + item.indent,
612
+ markerTo: line.from + item.indent + item.marker.length,
613
+ });
614
+ const box = taskBox(line, item);
615
+ if (box) specs.push({ from: line.from + item.content, to: textStart(line, item) });
616
+ if (kind === "task")
617
+ specs.push({
618
+ from: line.from + Math.min(item.content, line.text.length),
619
+ insert: line.text.length < item.content ? " [ ] " : "[ ] ",
620
+ });
621
+ }
622
+ const following = followingOrderedMarkers(state, removed, tree, excluded);
623
+ for (const item of following) {
624
+ markers.set(markerItems.length, item.marker);
625
+ markerItems.push(item);
626
+ }
627
+ const rewritten = markerChanges(state, { items: markerItems }, markers);
628
+ const contentChanges: ChangeSpec[] = [];
629
+ state
630
+ .changes(specs)
631
+ .iterChanges((from, to, _fromB, _toB, insert) =>
632
+ contentChanges.push({ from: rewritten.mapPos(from, 1), to: rewritten.mapPos(to, 1), insert }),
633
+ );
634
+ let changes = rewritten.compose(ChangeSet.of(contentChanges, rewritten.newLength));
635
+ changes = separateLines(state, changes, paragraphs, emptyItems);
636
+ let firstHead: number | null = null;
637
+ if (count > 1) {
638
+ const { line, item } = parsed[0]!;
639
+ if (!item && !specs.length) return false;
640
+ const indent = item?.indent ?? leadingWhitespace(line.text);
641
+ const marker = markers.get(0) ?? item?.marker ?? (kind === "ordered" ? "1." : "-");
642
+ const siblingMarker = /^\d/.test(marker) ? `1${marker.at(-1)}` : marker;
643
+ const prefix = `${line.text.slice(0, indent)}${siblingMarker} ${kind === "task" ? "[ ] " : ""}`;
644
+ const end = state.doc.line(nestedEnd(state, line, indent)).to;
645
+ const siblings = ChangeSet.of({ from: changes.mapPos(end, 1), insert: ("\n" + prefix).repeat(count - 1) }, changes.newLength);
646
+ firstHead = siblings.mapPos(changes.mapPos(item ? textStart(line, item) : line.from + indent, 1), -1);
647
+ changes = changes.compose(siblings);
648
+ }
649
+ if (!changes.empty) {
650
+ const selection = firstHead === null ? state.selection.map(changes, 1) : EditorSelection.single(firstHead);
651
+ view.dispatch({
652
+ changes,
653
+ selection,
654
+ scrollIntoView: true,
655
+ userEvent: "input",
656
+ annotations: orderedSuffixRestart.of(following.length > 0),
657
+ });
658
+ }
659
+ return true;
660
+ };
661
+
662
+ export const toggleTaskAtCaret: Command = (view) => {
663
+ const { state } = view;
664
+ if (state.readOnly) return false;
665
+ const head = state.selection.main.head;
666
+ let line = state.doc.lineAt(head);
667
+ const tree = ensureSyntaxTree(state, line.to, 50);
668
+ if (!tree) return false;
669
+ const excluded = excludedLines(state);
670
+ let item = listItemLine(tree, line, excluded);
671
+ if (!item) {
672
+ for (let node = tree.resolveInner(head, -1); node; ) {
673
+ if (node.name === "ListItem") {
674
+ line = state.doc.lineAt(node.from);
675
+ item = listItemLine(tree, line, excluded);
676
+ break;
677
+ }
678
+ if (!node.parent) break;
679
+ node = node.parent;
680
+ }
681
+ }
682
+ const box = item && taskBox(line, item);
683
+ if (!item || !box) return false;
684
+ const from = line.from + item.content + box[0].indexOf("[") + 1;
685
+ view.dispatch({ changes: { from, to: from + 1, insert: state.sliceDoc(from, from + 1) === " " ? "x" : " " }, userEvent: "input" });
686
+ return true;
687
+ };
688
+
689
+ const android = typeof navigator === "object" && /Android\b/.test(navigator.userAgent);
690
+ const typedTask = EditorView.inputHandler.of((view, from, to, text, insert) => {
691
+ const { state } = view;
692
+ if (
693
+ (android ? view.composing : view.compositionStarted) ||
694
+ state.readOnly ||
695
+ text !== " " ||
696
+ from !== to ||
697
+ !singleCaret(state) ||
698
+ from !== state.selection.main.head
699
+ )
700
+ return false;
701
+ const line = state.doc.lineAt(from);
702
+ const prefix = /^( *)(?:(?:[-*+]|\d{1,9}[.)]) +)?\[(?: )?\]$/.exec(state.sliceDoc(line.from, from));
703
+ const marker = prefix && LIST_ITEM.exec(prefix[0]);
704
+ // `- [ ] ` is already a task; converting it again would only add an empty undo step.
705
+ if (!prefix || (marker && prefix[0].endsWith("[ ]"))) return false;
706
+ const tree = ensureSyntaxTree(state, line.to, 50);
707
+ if (!tree || inCode(tree, line.from + prefix[1]!.length) || excludedLines(state)().some((span) => from >= span.from && from < span.to))
708
+ return false;
709
+ // The typed space is ordinary input; undoing the separate conversion keeps it.
710
+ view.dispatch(insert());
711
+ const replacement = marker ? `${marker[0]}[ ] ` : `${prefix[1]}- [ ] `;
712
+ const empty = !line.text.slice(from - line.from).trim();
713
+ const changes = separateLines(
714
+ view.state,
715
+ view.state.changes({ from: line.from, to: from + 1, insert: replacement }),
716
+ [],
717
+ empty ? [line.from] : [],
718
+ );
719
+ view.dispatch({
720
+ changes,
721
+ selection: { anchor: changes.mapPos(from + 1, 1) },
722
+ scrollIntoView: true,
723
+ userEvent: "input",
724
+ annotations: isolateHistory.of("before"),
725
+ });
726
+ return true;
727
+ });
728
+
729
+ /** Top-level list blocks intersecting a source window, without descending into quotes or individual items. */
730
+ const listBlocks = (tree: Tree, range: ExcludedSpan): ExcludedSpan[] => {
731
+ const blocks: ExcludedSpan[] = [];
732
+ tree.iterate({
733
+ ...range,
734
+ enter(ref) {
735
+ if (ref.name === "Blockquote") return false;
736
+ if (ref.name !== "OrderedList" && ref.name !== "BulletList") return;
737
+ if (ref.from < range.to && ref.to > range.from) blocks.push({ from: ref.from, to: ref.to });
738
+ return false;
739
+ },
740
+ });
741
+ return blocks;
742
+ };
743
+
744
+ /** A partial list grows as parsing advances. Re-find touched blocks until their closing boundaries are parsed. */
745
+ const parsedListBlocks = (
746
+ state: EditorState,
747
+ windows: readonly ExcludedSpan[],
748
+ deadline = performance.now() + 50,
749
+ ): { blocks: ExcludedSpan[]; tree: Tree } | null => {
750
+ let upto = Math.min(state.doc.length, Math.max(...windows.map((window) => window.to)));
751
+ for (;;) {
752
+ const remaining = deadline - performance.now();
753
+ if (remaining <= 0) return null;
754
+ const tree = ensureSyntaxTree(state, upto, remaining);
755
+ if (!tree) return null;
756
+ const blocks = new Map<number, ExcludedSpan>();
757
+ for (const window of windows) for (const block of listBlocks(tree, window)) blocks.set(block.from, block);
758
+ const end = Math.min(state.doc.length, Math.max(upto, ...[...blocks.values()].map((block) => block.to + 1)));
759
+ if (syntaxTreeAvailable(state, end)) return { blocks: [...blocks.values()], tree };
760
+ // Markdown reparses a partial list. Grow the lookahead to avoid restarting it for every sibling.
761
+ upto = Math.min(state.doc.length, Math.max(end, upto * 2));
762
+ }
763
+ };
764
+
765
+ const moveListItem =
766
+ (direction: Direction): Command =>
767
+ (view) => {
768
+ const { state } = view;
769
+ const head = state.selection.main.head;
770
+ const line = state.doc.lineAt(head);
771
+ const tree = ensureSyntaxTree(state, line.to, 50);
772
+ if (!tree) return LIST_ITEM.test(line.text) || leadingWhitespace(line.text) > 0;
773
+ const excluded = excludedLines(state);
774
+ if (LIST_ITEM.test(line.text) && !listItemLine(tree, line, excluded)) return false;
775
+ const parsed = parsedListBlocks(state, [{ from: line.from, to: line.to + 1 }]);
776
+ if (!parsed) return LIST_ITEM.test(line.text) || leadingWhitespace(line.text) > 0;
777
+ const block = parsed.blocks[0];
778
+ if (!block) return !!listItemLine(parsed.tree, line, excluded);
779
+ if (excluded().some((span) => head >= span.from && head < span.to)) return false;
780
+ const structure = listStructure(state, excluded, block, parsed.tree);
781
+ const index = structure.items.findLastIndex((item) => item.from <= head && head <= state.doc.lineAt(item.to).to);
782
+ if (index < 0) return false;
783
+ const item = structure.items[index]!;
784
+ const end = state.doc.lineAt(item.to).to;
785
+ if (state.readOnly) return true;
786
+ if (state.selection.ranges.some((range) => range.from < item.from || range.to > end)) return false;
787
+ const sibling = siblingIndex(structure, index, direction);
788
+ if (sibling < 0) return true;
789
+ const upperIndex = direction < 0 ? sibling : index;
790
+ const lowerIndex = direction < 0 ? index : sibling;
791
+ const upper = structure.items[upperIndex]!;
792
+ const lower = structure.items[lowerIndex]!;
793
+ const upperEnd = state.doc.lineAt(upper.to).to;
794
+ const lowerEnd = state.doc.lineAt(lower.to).to;
795
+ const gap = state.sliceDoc(upperEnd, lower.from);
796
+ // Give the block entering the upper slot that slot's ordinal before reparsing; this preserves the list start.
797
+ const repair = upper.ordered
798
+ ? markerChanges(
799
+ state,
800
+ structure,
801
+ new Map([[lowerIndex, `${upper.ordinal}${state.sliceDoc(lower.markerFrom, lower.markerTo).at(-1)}`]]),
802
+ )
803
+ : state.changes([]);
804
+ const repairedDoc = repair.apply(state.doc);
805
+ const upperFrom = repair.mapPos(upper.from);
806
+ const repairedUpperEnd = repair.mapPos(upperEnd, 1);
807
+ const lowerFrom = repair.mapPos(lower.from);
808
+ const repairedLowerEnd = repair.mapPos(lowerEnd, 1);
809
+ const relocation =
810
+ direction < 0
811
+ ? [
812
+ { from: upperFrom, to: lowerFrom },
813
+ { from: repairedLowerEnd, insert: gap + repairedDoc.sliceString(upperFrom, repairedUpperEnd) },
814
+ ]
815
+ : [
816
+ { from: upperFrom, insert: repairedDoc.sliceString(lowerFrom, repairedLowerEnd) + gap },
817
+ { from: repairedUpperEnd, to: repairedLowerEnd },
818
+ ];
819
+ const changes = repair.compose(ChangeSet.of(relocation, repairedDoc.length));
820
+ const selection = state.selection.map(changes, direction);
821
+ view.dispatch({ changes, selection, scrollIntoView: true, userEvent: "move.line" });
822
+ return true;
823
+ };
824
+
825
+ const prefixTouched = (state: EditorState, from: number, to: number, deadline: number): boolean => {
826
+ const excluded = excludedLines(state);
827
+ for (let number = state.doc.lineAt(from).number; number <= state.doc.lineAt(to).number; number++) {
828
+ const line = state.doc.line(number);
829
+ const match = LIST_ITEM.exec(line.text);
830
+ if (!match || from >= line.from + match[0].length || to < line.from) continue;
831
+ const remaining = deadline - performance.now();
832
+ if (remaining <= 0) continue;
833
+ const tree = ensureSyntaxTree(state, line.to, remaining);
834
+ if (!tree) continue;
835
+ if (listItemLine(tree, line, excluded)) return true;
836
+ }
837
+ return false;
838
+ };
839
+
840
+ /** Empty ordered children can still parse as continuation text until someone types. Repair their source list too. */
841
+ const unparsedNumbers = (
842
+ state: EditorState,
843
+ blocks: readonly ExcludedSpan[],
844
+ ranges: readonly ExcludedSpan[],
845
+ tree: Tree,
846
+ deadline: number,
847
+ ): ChangeSet => {
848
+ const excluded = excludedLines(state);
849
+ const items: Array<{ line: number; to: number; markerFrom: number; markerTo: number }> = [];
850
+ const markers = new Map<number, string>();
851
+ for (const block of blocks) {
852
+ const parsedLines = new Set(listStructure(state, excluded, block, tree).items.map((item) => item.line));
853
+ const emptySourceOnly = (line: Line, item: ListItemLine) =>
854
+ !parsedLines.has(line.number) && !line.text.slice(textStart(line, item) - line.from).trim();
855
+ const source = [];
856
+ for (let number = state.doc.lineAt(block.from).number; number <= state.doc.lineAt(block.to).number; number++) {
857
+ const line = state.doc.line(number);
858
+ const item = listItemLine(tree, line, excluded);
859
+ if (item && /^\d/.test(item.marker)) source.push({ line, item });
860
+ }
861
+ if (!source.some(({ line, item }) => emptySourceOnly(line, item))) continue;
862
+ for (const { line, item } of source) {
863
+ if (performance.now() >= deadline) return state.changes([]);
864
+ const number = line.number;
865
+ let first = line;
866
+ let empty = emptySourceOnly(line, item);
867
+ for (
868
+ let above = itemAbove(state, first, item, "sibling", excluded, tree);
869
+ above;
870
+ above = itemAbove(state, first, item, "sibling", excluded, tree)
871
+ ) {
872
+ first = above;
873
+ if (emptySourceOnly(first, listItemLine(tree, first, excluded)!)) empty = true;
874
+ }
875
+ let end = nestedEnd(state, line, item.indent);
876
+ for (let next = end + 1; next <= state.doc.lines; ) {
877
+ const sibling = state.doc.line(next);
878
+ const parsed = listItemLine(tree, sibling, excluded);
879
+ if (!parsed || parsed.indent !== item.indent || !sameKind(parsed.marker, item.marker)) break;
880
+ if (emptySourceOnly(sibling, parsed)) empty = true;
881
+ end = nestedEnd(state, sibling, parsed.indent);
882
+ next = end + 1;
883
+ }
884
+ if (!empty) continue;
885
+ if (!ranges.some((range) => first.from <= range.to && state.doc.line(end).to >= range.from)) continue;
886
+ const marker = `${ordinalAt(state, line, excluded, tree)}${item.marker.at(-1)}`;
887
+ if (marker === item.marker) continue;
888
+ markers.set(items.length, marker);
889
+ items.push({
890
+ line: number,
891
+ to: state.doc.line(nestedEnd(state, line, item.indent)).to,
892
+ markerFrom: line.from + item.indent,
893
+ markerTo: line.from + item.indent + item.marker.length,
894
+ });
895
+ }
896
+ }
897
+ return markerChanges(state, { items }, markers);
898
+ };
899
+
900
+ /** lang-markdown's Enter already increments sibling numbers, but leaves their children at the old width. */
901
+ const continuedListColumns = (tr: Transaction, deadline: number): ChangeSet => {
902
+ const empty = tr.state.changes([]);
903
+ if (tr.annotation(Transaction.userEvent) !== "input") return empty;
904
+ const numbers = new Map<number, { to: number; marker: string }>();
905
+ let newline = false;
906
+ tr.changes.iterChanges((fromA, toA, _fromB, _toB, insert) => {
907
+ if (insert.toString().includes("\n")) newline = true;
908
+ if (/^\d{1,9}$/.test(tr.startState.sliceDoc(fromA, toA)) && /^\d{1,9}$/.test(insert.toString()) && toA - fromA !== insert.length) {
909
+ numbers.set(fromA, { to: toA, marker: insert.toString() });
910
+ }
911
+ });
912
+ if (!newline || !numbers.size) return empty;
913
+ const old = tr.startState;
914
+ const parsed = parsedListBlocks(
915
+ old,
916
+ [...numbers].map(([from, { to }]) => ({ from, to })),
917
+ );
918
+ if (!parsed) return empty;
919
+ const excluded = excludedLines(old);
920
+ const specs: ChangeSpec[] = [];
921
+ for (const block of parsed.blocks) {
922
+ const structure = listStructure(old, excluded, block, parsed.tree);
923
+ const markers = new Map<number, string>();
924
+ structure.items.forEach((item, index) => {
925
+ const number = numbers.get(item.markerFrom);
926
+ if (item.ordered && number?.to === item.markerTo - 1)
927
+ markers.set(index, number.marker + old.sliceDoc(item.markerTo - 1, item.markerTo));
928
+ });
929
+ markerChanges(old, structure, markers).iterChanges((from, to, _fromB, _toB, insert) => {
930
+ if (![...numbers].some(([numberFrom, number]) => from >= numberFrom && to <= number.to)) specs.push({ from, to, insert });
931
+ });
932
+ }
933
+ return old.changes(specs).map(tr.changes);
934
+ };
935
+
936
+ const renumber = (options: ListCommandsOptions) =>
937
+ EditorState.transactionFilter.of((tr: Transaction) => {
938
+ if (
939
+ !tr.docChanged ||
940
+ tr.annotation(orderedSuffixRestart) ||
941
+ tr.state.readOnly ||
942
+ options.isRemoteTransaction?.(tr) ||
943
+ tr.isUserEvent("undo") ||
944
+ tr.isUserEvent("redo")
945
+ )
946
+ return tr;
947
+ const deadline = performance.now() + 50;
948
+ const windows: ExcludedSpan[] = [];
949
+ const oldRanges: ExcludedSpan[] = [];
950
+ const changedRanges: ExcludedSpan[] = [];
951
+ let structural = false;
952
+ tr.changes.iterChangedRanges((fromA, toA, fromB, toB) => {
953
+ oldRanges.push({ from: fromA, to: toA });
954
+ changedRanges.push({ from: fromB, to: toB });
955
+ const oldSlice = tr.startState.sliceDoc(fromA, toA);
956
+ const newSlice = tr.state.sliceDoc(fromB, toB);
957
+ const withinBlankLines =
958
+ !oldSlice.trim() &&
959
+ !newSlice.trim() &&
960
+ !tr.startState.doc.lineAt(fromA).text.trim() &&
961
+ !tr.startState.doc.lineAt(toA).text.trim() &&
962
+ !tr.newDoc.lineAt(fromB).text.trim() &&
963
+ !tr.newDoc.lineAt(toB).text.trim();
964
+ if (
965
+ !withinBlankLines &&
966
+ (oldSlice.includes("\n") ||
967
+ newSlice.includes("\n") ||
968
+ prefixTouched(tr.startState, fromA, toA, deadline) ||
969
+ prefixTouched(tr.state, fromB, toB, deadline))
970
+ )
971
+ structural = true;
972
+ const first = Math.max(1, tr.newDoc.lineAt(fromB).number - 1);
973
+ const last = Math.min(tr.newDoc.lines, tr.newDoc.lineAt(toB).number + 1);
974
+ windows.push({ from: tr.newDoc.line(first).from, to: tr.newDoc.line(last).to + 1 });
975
+ });
976
+ if (!structural) return tr;
977
+ // When the first item of an ordered list goes, the first remaining item takes over the list's start before the
978
+ // reparse. This also keeps a nested list that would otherwise read as paragraph text (it must start at 1) a list.
979
+ const survivors: Array<{ markerFrom: number; markerTo: number; to: number; start: number }> = [];
980
+ const columns = continuedListColumns(tr, deadline);
981
+ const oldParsed = parsedListBlocks(tr.startState, oldRanges, deadline);
982
+ if (oldParsed) {
983
+ const excluded = excludedLines(tr.startState);
984
+ for (const block of oldParsed.blocks) {
985
+ const lost = new Map<number, number | null>();
986
+ for (const item of listStructure(tr.startState, excluded, block, oldParsed.tree).items) {
987
+ if (!item.ordered || item.start === null || lost.get(item.listFrom) === null) continue;
988
+ const deleted =
989
+ tr.changes.mapPos(item.markerFrom, 1, MapMode.TrackAfter) === null &&
990
+ tr.changes.mapPos(item.markerTo, -1, MapMode.TrackBefore) === null;
991
+ if (deleted) {
992
+ if (!lost.has(item.listFrom)) lost.set(item.listFrom, item.start);
993
+ continue;
994
+ }
995
+ const start = lost.get(item.listFrom);
996
+ lost.set(item.listFrom, null);
997
+ if (start === undefined || start === null) continue;
998
+ survivors.push({
999
+ markerFrom: columns.mapPos(tr.changes.mapPos(item.markerFrom, 1), 1),
1000
+ markerTo: columns.mapPos(tr.changes.mapPos(item.markerTo, -1), -1),
1001
+ to: columns.mapPos(tr.changes.mapPos(item.to, -1), -1),
1002
+ start,
1003
+ });
1004
+ }
1005
+ }
1006
+ }
1007
+ const shifted = columns.empty ? tr.newDoc : columns.apply(tr.newDoc);
1008
+ const restoring = EditorState.create({ doc: shifted });
1009
+ const restorable = survivors
1010
+ .filter((item) => /^\d{1,9}[.)]$/.test(restoring.sliceDoc(item.markerFrom, item.markerTo)))
1011
+ .map((item) => ({ ...item, line: shifted.lineAt(item.markerFrom).number }));
1012
+ const restored = markerChanges(
1013
+ restoring,
1014
+ { items: restorable },
1015
+ new Map(restorable.map((item, index) => [index, `${item.start}${restoring.sliceDoc(item.markerTo - 1, item.markerTo)}`])),
1016
+ );
1017
+ let prepared = columns.compose(restored);
1018
+ let state = prepared.empty
1019
+ ? tr.state
1020
+ : EditorState.create({
1021
+ doc: prepared.apply(tr.newDoc),
1022
+ extensions: [
1023
+ tr.state.facet(language)?.extension ?? [],
1024
+ ...tr.state.facet(listExcludedRanges).map((ranges) => listExcludedRanges.of(ranges)),
1025
+ ],
1026
+ });
1027
+ for (const range of [...changedRanges, ...windows]) {
1028
+ range.from = prepared.mapPos(range.from);
1029
+ range.to = prepared.mapPos(Math.min(range.to, tr.newDoc.length), 1);
1030
+ }
1031
+ let parsed = parsedListBlocks(state, windows, deadline);
1032
+ if (!parsed) return tr;
1033
+ const sourceRepair = unparsedNumbers(state, parsed.blocks, changedRanges, parsed.tree, deadline);
1034
+ if (!sourceRepair.empty) {
1035
+ prepared = prepared.compose(sourceRepair);
1036
+ state = EditorState.create({
1037
+ doc: sourceRepair.apply(state.doc),
1038
+ extensions: [
1039
+ state.facet(language)?.extension ?? [],
1040
+ ...state.facet(listExcludedRanges).map((ranges) => listExcludedRanges.of(ranges)),
1041
+ ],
1042
+ });
1043
+ for (const range of [...changedRanges, ...windows]) {
1044
+ range.from = sourceRepair.mapPos(range.from);
1045
+ range.to = sourceRepair.mapPos(Math.min(range.to, sourceRepair.length), 1);
1046
+ }
1047
+ parsed = parsedListBlocks(state, windows, deadline);
1048
+ if (!parsed) return [tr, { changes: prepared, sequential: true }];
1049
+ }
1050
+ const excluded = excludedLines(state);
1051
+ const changes: ChangeSpec[] = [];
1052
+ for (const block of parsed.blocks) {
1053
+ const structure = listStructure(state, excluded, block, parsed.tree);
1054
+ const lists = new Map<number, { from: number; to: number; start: number }>();
1055
+ for (const item of structure.items) {
1056
+ if (!item.ordered || item.start === null) continue;
1057
+ const list = lists.get(item.listFrom);
1058
+ if (list) list.to = item.to;
1059
+ else lists.set(item.listFrom, { from: item.from, to: item.to, start: item.start });
1060
+ }
1061
+ const starts = new Map<number, number>();
1062
+ for (const [listFrom, list] of lists) {
1063
+ if (changedRanges.some((range) => list.from <= range.to && list.to >= range.from)) starts.set(listFrom, list.start);
1064
+ }
1065
+ renumberChanges(state, structure, starts).iterChanges((from, to, _fromB, _toB, insert) => changes.push({ from, to, insert }));
1066
+ }
1067
+ const repair = prepared.compose(state.changes(changes));
1068
+ return repair.empty ? tr : [tr, { changes: repair, sequential: true }];
1069
+ });
1070
+
1071
+ const isolatedCommand =
1072
+ (command: Command): Command =>
1073
+ (view) => {
1074
+ let handled = false;
1075
+ void asUndoStep(view, () => {
1076
+ handled = command(view);
1077
+ });
1078
+ return handled;
1079
+ };
1080
+
1081
+ export type ListCommandsOptions = MarkdownRangeOptions & {
1082
+ /** Collaboration-origin transactions must never echo automatic number repairs. */
1083
+ isRemoteTransaction?: (transaction: Transaction) => boolean;
1084
+ };
1085
+
1086
+ export const listCommandsExtension = (options: ListCommandsOptions = {}): Extension => [
1087
+ options.excludedRanges ? listExcludedRanges.of(options.excludedRanges) : [],
1088
+ Prec.highest(
1089
+ EditorView.domEventHandlers({
1090
+ keydown(event, view) {
1091
+ if (
1092
+ !event.shiftKey ||
1093
+ event.altKey ||
1094
+ event.isComposing ||
1095
+ !(mac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey)
1096
+ )
1097
+ return false;
1098
+ const kind = event.code === "Digit7" ? "ordered" : event.code === "Digit8" ? "bullet" : event.code === "Digit9" ? "task" : null;
1099
+ if (!kind || !isolatedCommand(setListKind(kind))(view)) return false;
1100
+ event.preventDefault();
1101
+ return true;
1102
+ },
1103
+ }),
1104
+ ),
1105
+ Prec.highest(
1106
+ keymap.of([
1107
+ { key: "Enter", run: enterListItem },
1108
+ { key: "Backspace", run: backspaceListItem },
1109
+ { key: "Mod-Enter", run: toggleTaskAtCaret },
1110
+ ]),
1111
+ ),
1112
+ typedTask,
1113
+ Prec.high(
1114
+ keymap.of([
1115
+ { key: "Mod-]", run: isolatedCommand(indentListItems) },
1116
+ { key: "Mod-[", run: isolatedCommand(outdentListItems) },
1117
+ { key: "Alt-ArrowUp", run: moveListItem(-1) },
1118
+ { key: "Alt-ArrowDown", run: moveListItem(1) },
1119
+ { win: "Ctrl-Shift-ArrowUp", linux: "Ctrl-Shift-ArrowUp", run: moveListItem(-1) },
1120
+ { win: "Ctrl-Shift-ArrowDown", linux: "Ctrl-Shift-ArrowDown", run: moveListItem(1) },
1121
+ ]),
1122
+ ),
1123
+ renumber(options),
1124
+ ];