@k2b/editor 0.0.0-stage → 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 +31 -2
  3. package/package.json +118 -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
package/src/index.ts ADDED
@@ -0,0 +1,16 @@
1
+ export { ariaShortcut, isApple, markdownActions, shortcutLabel } from "./actions";
2
+ export { BubbleMenu, type BubbleMenuProps } from "./BubbleMenu";
3
+ export { Editor, type EditorProps } from "./Editor";
4
+ export { asUndoStep, createEditor, type EditorAction, type EditorHandle } from "./editor-state";
5
+ export { editorMessages } from "./messages";
6
+ export { SlashMenu, type SlashMenuProps } from "./SlashMenu";
7
+ export { type SlashAction, type SlashMenuOptions, slashCompletionSource, slashMenu } from "./slash-menu";
8
+ export {
9
+ Toolbar,
10
+ ToolbarButton,
11
+ type ToolbarCustom,
12
+ type ToolbarGroup,
13
+ type ToolbarItem,
14
+ type ToolbarMenu,
15
+ type ToolbarProps,
16
+ } from "./Toolbar";
@@ -0,0 +1,338 @@
1
+ /**
2
+ * Cursor-zone StateField — shared by editor decoration pipelines
3
+ * that rebuild on doc changes OR when the cursor crosses a "zone"
4
+ * boundary (entered, left, or moved between zones).
5
+ *
6
+ * # Two modes
7
+ *
8
+ * The field supports two operating modes:
9
+ *
10
+ * - **Default mode** (no `incremental` option): rebuild on every
11
+ * doc change and every newly delivered syntax tree, rebuild on
12
+ * selection changes that cross a range boundary. Used by
13
+ * image/link extensions whose `build` already walks the syntax
14
+ * tree incrementally — a full call is cheap.
15
+ *
16
+ * - **Incremental mode** (with `incremental.changesMightAffectSyntax`):
17
+ * on doc change, skip the rebuild when the doc has no relevant
18
+ * marker syntax AND the change doesn't introduce any, OR when
19
+ * the existing ranges are unaffected by the change. A newly
20
+ * delivered syntax tree always rebuilds, also when it arrives
21
+ * with a doc change that carries it past its old end. Used by
22
+ * extensions whose `build` does a full `doc.toString() +
23
+ * regex.matchAll()` pass (katex, tag-pill) — those
24
+ * rebuilds are expensive enough to be worth gating.
25
+ *
26
+ * Each caller (images, links, tag-pill, info-blocks, katex) tracks
27
+ * the source-byte ranges where editing should reveal raw markdown
28
+ * source instead of the rendered widget. Without the cursor-zone
29
+ * gate, every plain cursor move through prose would re-walk the
30
+ * syntax tree / re-scan the doc text — visibly laggy on long notes.
31
+ *
32
+ * # State shape
33
+ *
34
+ * Callers return `{ decorations, ranges, hasSyntax? }` from `build`.
35
+ * `hasSyntax` only matters in incremental mode; default mode
36
+ * ignores it.
37
+ *
38
+ * NOTE: `tables.ts` does NOT use this helper — its state carries
39
+ * an extra cached `blockWidgetDecorations` field that doesn't fit
40
+ * the standard shape.
41
+ */
42
+ import { forceParsing, syntaxTree } from "@codemirror/language";
43
+ import type { EditorState, Extension, SelectionRange, Transaction } from "@codemirror/state";
44
+ import { Prec, StateEffect, StateField } from "@codemirror/state";
45
+ import type { DecorationSet } from "@codemirror/view";
46
+ import { Decoration, EditorView, keymap, ViewPlugin } from "@codemirror/view";
47
+
48
+ export type CursorZoneRange = { from: number; to: number };
49
+
50
+ export type CursorZoneState = {
51
+ decorations: DecorationSet;
52
+ /** Optional subset of replacement decorations that should behave
53
+ * atomically for cursor movement. Use this for block widgets that
54
+ * hide source text; it prevents CM from resolving mouse/vertical
55
+ * navigation into the replaced source range. */
56
+ atomicDecorations?: DecorationSet;
57
+ /** Source-byte ranges where the cursor "activates" the source
58
+ * (i.e. hides the widget so the user can edit raw markdown). */
59
+ ranges: CursorZoneRange[];
60
+ /** Incremental mode only: `true` if the doc currently contains
61
+ * any relevant marker syntax. Lets the field skip the rebuild
62
+ * when both this AND the per-change predicate return falsy. */
63
+ hasSyntax?: boolean;
64
+ };
65
+
66
+ export type IncrementalOptions = {
67
+ /** Cheap predicate run on every doc-change transaction. Returns
68
+ * `true` if the inserted text OR a small window around the
69
+ * change might introduce / remove relevant marker syntax.
70
+ *
71
+ * Bias: when in doubt, return `true` — a false positive only
72
+ * costs a rebuild (back to baseline). A false negative leaves
73
+ * decorations stale, so the predicate MUST be conservative. */
74
+ changesMightAffectSyntax: (tr: Transaction) => boolean;
75
+ };
76
+
77
+ export const selectionIntersectsRange = (selection: SelectionRange, from: number, to: number): boolean => {
78
+ if (selection.empty) return selection.from >= from && selection.to <= to;
79
+ return selection.from < to && selection.to > from;
80
+ };
81
+
82
+ export const isPointerSelectionTransaction = (tr: Transaction): boolean =>
83
+ !tr.docChanged && !!tr.selection && tr.isUserEvent("select.pointer");
84
+
85
+ /** True when a transaction changed the document or delivered a new syntax tree.
86
+ * CodeMirror parses a long note in steps and hands each further part of the tree
87
+ * over in a transaction without a document change, so a decoration field that
88
+ * reads the tree must rebuild on both. Selection and focus transactions keep the
89
+ * same tree and stay cheap. */
90
+ export const treeOrDocChanged = (tr: Transaction): boolean => tr.docChanged || syntaxTree(tr.startState) !== syntaxTree(tr.state);
91
+
92
+ export const refreshMarkdownDecorationsEffect = StateEffect.define<void>();
93
+
94
+ export const pointerSelectionHoldEffect = StateEffect.define<boolean>();
95
+
96
+ /** True while a mouse or pen button is down in the editor. Revealing Markdown then would move the text under the
97
+ * pointer mid-drag, so selection-driven decorations wait for the refresh after release. */
98
+ export const pointerSelectionHeld = StateField.define<boolean>({
99
+ create: () => false,
100
+ update(value, tr) {
101
+ for (const effect of tr.effects) {
102
+ if (effect.is(refreshMarkdownDecorationsEffect)) return false;
103
+ if (effect.is(pointerSelectionHoldEffect)) value = effect.value;
104
+ }
105
+ return value;
106
+ },
107
+ });
108
+
109
+ export const holdsDecorations = (tr: Transaction): boolean =>
110
+ tr.startState.field(pointerSelectionHeld, false) === true &&
111
+ tr.state.field(pointerSelectionHeld, false) === true &&
112
+ isPointerSelectionTransaction(tr);
113
+
114
+ export const initialMarkdownDecorationRefreshExtension = (): Extension =>
115
+ ViewPlugin.fromClass(
116
+ class {
117
+ private raf: number | null = null;
118
+ private attempts = 0;
119
+
120
+ constructor(private view: EditorView) {
121
+ this.schedule();
122
+ }
123
+
124
+ private schedule() {
125
+ this.raf = window.requestAnimationFrame(() => {
126
+ this.raf = null;
127
+ this.attempts += 1;
128
+ // Markdown parsing is viewport/background-driven. Force the parse
129
+ // to the end after mount so syntax-tree based widgets don't stay raw
130
+ // until the parser reaches them. forceParsing hands a newer tree over
131
+ // in a transaction, and every tree-based field rebuilds on it.
132
+ const parsed = forceParsing(this.view, this.view.state.doc.length, 50);
133
+ if (!parsed && this.attempts < 4) this.schedule();
134
+ });
135
+ }
136
+
137
+ destroy() {
138
+ if (this.raf !== null) window.cancelAnimationFrame(this.raf);
139
+ }
140
+ },
141
+ );
142
+
143
+ export const pointerSelectionMarkdownRefreshExtension = (): Extension => [
144
+ pointerSelectionHeld,
145
+ ViewPlugin.fromClass(
146
+ class {
147
+ private timeout: number | null = null;
148
+
149
+ constructor(private view: EditorView) {
150
+ view.contentDOM.addEventListener("pointerdown", this.onPointerDown);
151
+ document.addEventListener("pointerup", this.onPointerDone);
152
+ document.addEventListener("pointercancel", this.onPointerDone);
153
+ }
154
+
155
+ private onPointerDown = (event: PointerEvent) => {
156
+ // Native touch selection must keep working on phones, without a hold.
157
+ if (event.pointerType === "touch" || event.button !== 0) return;
158
+ if (this.timeout !== null) window.clearTimeout(this.timeout);
159
+ this.timeout = null;
160
+ this.view.dispatch({ effects: pointerSelectionHoldEffect.of(true) });
161
+ };
162
+
163
+ private onPointerDone = () => {
164
+ if (!this.view.state.field(pointerSelectionHeld) || this.timeout !== null) return;
165
+ this.timeout = window.setTimeout(() => {
166
+ this.timeout = null;
167
+ this.view.dispatch({ effects: refreshMarkdownDecorationsEffect.of() });
168
+ }, 0);
169
+ };
170
+
171
+ destroy() {
172
+ this.view.contentDOM.removeEventListener("pointerdown", this.onPointerDown);
173
+ document.removeEventListener("pointerup", this.onPointerDone);
174
+ document.removeEventListener("pointercancel", this.onPointerDone);
175
+ if (this.timeout !== null) window.clearTimeout(this.timeout);
176
+ }
177
+ },
178
+ ),
179
+ ];
180
+
181
+ export const blockWidgetLineNavigationExtension = <T>(
182
+ field: StateField<T>,
183
+ getBlockDecorations: (value: T) => DecorationSet | undefined,
184
+ ): Extension => {
185
+ const findWidgetRange = (set: DecorationSet | undefined, from: number, to: number): CursorZoneRange | null => {
186
+ let found: CursorZoneRange | null = null;
187
+ set?.between(from, to, (decoFrom, decoTo) => {
188
+ found = { from: decoFrom, to: decoTo };
189
+ return false;
190
+ });
191
+ return found;
192
+ };
193
+
194
+ const navigateLine = (view: EditorView, dir: -1 | 1, extend: boolean): boolean => {
195
+ const value = view.state.field(field, false);
196
+ if (!value) return false;
197
+ const blockDecorations = getBlockDecorations(value);
198
+ if (!blockDecorations || blockDecorations === Decoration.none) return false;
199
+
200
+ const sel = view.state.selection.main;
201
+ const caretLine = view.state.doc.lineAt(sel.head);
202
+ const caretCol = sel.head - caretLine.from;
203
+ let targetLineNumber = caretLine.number + dir;
204
+ let skippedWidget = false;
205
+
206
+ while (targetLineNumber >= 1 && targetLineNumber <= view.state.doc.lines) {
207
+ const line = view.state.doc.line(targetLineNumber);
208
+ const widget = findWidgetRange(blockDecorations, line.from, line.to);
209
+ if (!widget) break;
210
+ skippedWidget = true;
211
+ const widgetStartLine = view.state.doc.lineAt(widget.from).number;
212
+ const widgetEndLine = view.state.doc.lineAt(widget.to).number;
213
+ targetLineNumber = dir < 0 ? widgetStartLine - 1 : widgetEndLine + 1;
214
+ }
215
+ if (!skippedWidget) return false;
216
+
217
+ const target =
218
+ targetLineNumber < 1
219
+ ? 0
220
+ : targetLineNumber > view.state.doc.lines
221
+ ? view.state.doc.length
222
+ : view.state.doc.line(targetLineNumber).from + Math.min(caretCol, view.state.doc.line(targetLineNumber).length);
223
+
224
+ view.dispatch({
225
+ selection: extend ? { anchor: sel.anchor, head: target } : { anchor: target },
226
+ scrollIntoView: true,
227
+ userEvent: "select",
228
+ });
229
+ return true;
230
+ };
231
+
232
+ return Prec.highest(
233
+ keymap.of([
234
+ { key: "ArrowUp", run: (view) => navigateLine(view, -1, false) },
235
+ { key: "Shift-ArrowUp", run: (view) => navigateLine(view, -1, true) },
236
+ { key: "ArrowDown", run: (view) => navigateLine(view, 1, false) },
237
+ { key: "Shift-ArrowDown", run: (view) => navigateLine(view, 1, true) },
238
+ ]),
239
+ );
240
+ };
241
+
242
+ /** Identity of the range the cursor currently sits inside, or
243
+ * `null` if it sits outside all ranges. A cursor-only transaction
244
+ * needs a rebuild only when this answer changes. */
245
+ const cursorKey = (state: EditorState, ranges: CursorZoneRange[]): string | null => {
246
+ if (ranges.length === 0) return null;
247
+ const cursor = state.selection.main;
248
+ const hits: number[] = [];
249
+ for (const r of ranges) {
250
+ if (selectionIntersectsRange(cursor, r.from, r.to)) hits.push(r.from);
251
+ }
252
+ return hits.length > 0 ? hits.sort((a, b) => a - b).join(",") : null;
253
+ };
254
+
255
+ const intersectsAnyRange = (ranges: CursorZoneRange[], from: number, to: number): boolean =>
256
+ ranges.some((r) => (from >= r.from && from < r.to) || (to > r.from && to <= r.to) || (from <= r.from && to >= r.to));
257
+
258
+ /** True if any of the transaction's changed regions intersects (or
259
+ * touches the boundary of) one of `ranges`. Pre-change positions
260
+ * are used because `ranges` haven't been mapped through `tr` yet. */
261
+ const changesIntersectRanges = (tr: Transaction, ranges: CursorZoneRange[]): boolean => {
262
+ if (ranges.length === 0) return false;
263
+ let intersects = false;
264
+ tr.changes.iterChangedRanges((fromA, toA) => {
265
+ if (intersects) return;
266
+ intersects = intersectsAnyRange(ranges, fromA, toA) || ranges.some((r) => fromA === r.from || fromA === r.to);
267
+ });
268
+ return intersects;
269
+ };
270
+
271
+ /** Map pre-change ranges through a transaction's changes so they
272
+ * reflect the post-change document. `from` biases right, `to`
273
+ * biases left — keeps the range tight around the original span. */
274
+ const mapRanges = (tr: Transaction, ranges: CursorZoneRange[]): CursorZoneRange[] =>
275
+ ranges
276
+ .map((r) => ({
277
+ from: tr.changes.mapPos(r.from, 1),
278
+ to: tr.changes.mapPos(r.to, -1),
279
+ }))
280
+ .filter((r) => r.from < r.to);
281
+
282
+ /** True when a doc change also carried the syntax tree past the end of the previous,
283
+ * partial tree. CodeMirror parses up to the viewport while it applies an edit, so an
284
+ * edit after a jump into the unparsed end of a long note can bring new code or math
285
+ * nodes that the incremental skip checks can't see. */
286
+ const treeGrewPastOldEnd = (tr: Transaction): boolean =>
287
+ syntaxTree(tr.state).length > tr.changes.mapPos(syntaxTree(tr.startState).length, 1);
288
+
289
+ export const cursorZoneStateField = (
290
+ build: (state: EditorState) => CursorZoneState,
291
+ incremental?: IncrementalOptions,
292
+ ): StateField<CursorZoneState> => {
293
+ return StateField.define<CursorZoneState>({
294
+ create: build,
295
+ update(value, tr) {
296
+ if (tr.effects.some((effect) => effect.is(refreshMarkdownDecorationsEffect))) {
297
+ return build(tr.state);
298
+ }
299
+ if (tr.docChanged) {
300
+ if (incremental && !treeGrewPastOldEnd(tr)) {
301
+ const decorations = value.decorations.map(tr.changes);
302
+ const atomicDecorations = value.atomicDecorations?.map(tr.changes);
303
+ const mightAffect = incremental.changesMightAffectSyntax(tr);
304
+ // No syntax anywhere AND change doesn't introduce any →
305
+ // nothing to scan, just keep an empty state.
306
+ if (!value.hasSyntax && !mightAffect) {
307
+ return { decorations, atomicDecorations, ranges: [], hasSyntax: false };
308
+ }
309
+ // Have syntax, change misses the ranges AND introduces
310
+ // no new markers → keep current decorations, just remap
311
+ // range positions through the change.
312
+ if (value.hasSyntax && !changesIntersectRanges(tr, value.ranges) && !mightAffect) {
313
+ return {
314
+ decorations,
315
+ atomicDecorations,
316
+ ranges: mapRanges(tr, value.ranges),
317
+ hasSyntax: true,
318
+ };
319
+ }
320
+ }
321
+ return build(tr.state);
322
+ }
323
+ if (treeOrDocChanged(tr)) return build(tr.state);
324
+ if (holdsDecorations(tr)) return value;
325
+ if (!tr.selection) return value;
326
+ const oldKey = cursorKey(tr.startState, value.ranges);
327
+ const newKey = cursorKey(tr.state, value.ranges);
328
+ if (oldKey === newKey) return value;
329
+ return build(tr.state);
330
+ },
331
+ provide(field) {
332
+ return [
333
+ EditorView.decorations.from(field, (v) => v.decorations),
334
+ EditorView.atomicRanges.of((view) => view.state.field(field).atomicDecorations ?? Decoration.none),
335
+ ];
336
+ },
337
+ });
338
+ };
@@ -0,0 +1,9 @@
1
+ // Text syntax shared by editor decorations and the server preview.
2
+ export const TAG_REGEX = /(^|\s)#([a-zA-Z][\w-]*(?:\/[\w-]+)*)/g;
3
+ export const MARK_REGEX = /==(?!=)([^\s=][^=]*?[^\s=]|[^\s=])==(?!=)/g;
4
+ export const SUB_REGEX = /(?<!~)~(?!~)([^~\s]+)~(?!~)/g;
5
+ export const SUP_REGEX = /\^([^\^\s]+)\^/g;
6
+
7
+ // CodeMirror highlightSpecialChars defaults (tabs keep their native tab-size).
8
+ export const SPECIAL_CHAR_REGEX =
9
+ /[\u0000-\u0008\u000a-\u001f\u007f-\u009f\u00ad\u061c\u200b\u200e\u200f\u2028\u2029\u202d\u202e\u2066\u2067\u2069\ufeff\ufff9-\ufffc]/gu;
@@ -0,0 +1,50 @@
1
+ import type { EditorSelection } from "@codemirror/state";
2
+ import type { EditorView } from "@codemirror/view";
3
+
4
+ /** A pointer that moved further than this between press and click dragged a selection. */
5
+ const CLICK_SLOP_PX = 4;
6
+
7
+ /**
8
+ * The element opens its link on click and, where the editor is read-only, from the keyboard as a link.
9
+ *
10
+ * A mouse or pen press reaches CodeMirror, so a drag that starts on the element selects text. The pointer hold keeps
11
+ * the element rendered until release, so a click still lands on it; the click then restores the selection from before
12
+ * the press and opens the link. A touch press keeps the native tap: it moves no caret, and the tap opens the link.
13
+ * The widget passes `mousedown` to CodeMirror and ignores every other event.
14
+ */
15
+ export const makeOpener = (el: HTMLElement, view: EditorView, open: () => void) => {
16
+ el.classList.add("cm-link-open");
17
+ el.setAttribute("role", "link");
18
+ if (view.state.readOnly) el.tabIndex = 0;
19
+ let touch = false;
20
+ let press: { x: number; y: number; selection: EditorSelection } | null = null;
21
+ el.onpointerdown = (e) => {
22
+ touch = e.pointerType === "touch";
23
+ };
24
+ el.onmousedown = (e) => {
25
+ press = null;
26
+ if (touch) {
27
+ e.preventDefault();
28
+ e.stopPropagation();
29
+ return;
30
+ }
31
+ press = { x: e.clientX, y: e.clientY, selection: view.state.selection };
32
+ };
33
+ el.onclick = (e) => {
34
+ e.preventDefault();
35
+ e.stopPropagation();
36
+ const start = press;
37
+ press = null;
38
+ if (start) {
39
+ if (Math.hypot(e.clientX - start.x, e.clientY - start.y) > CLICK_SLOP_PX) return;
40
+ view.dispatch({ selection: start.selection });
41
+ }
42
+ open();
43
+ };
44
+ el.onkeydown = (e) => {
45
+ if (e.key !== "Enter") return;
46
+ e.preventDefault();
47
+ e.stopPropagation();
48
+ open();
49
+ };
50
+ };
@@ -0,0 +1,34 @@
1
+ import type { Line } from "@codemirror/state";
2
+ import type { SyntaxNode, Tree } from "@lezer/common";
3
+ import type { ExcludedSpan } from "./list-structure";
4
+
5
+ export const LIST_ITEM = /^( *)([-*+]|\d{1,9}[.)])( +|$)/;
6
+ const CODE = new Set(["FencedCode", "CodeBlock"]);
7
+
8
+ export type ListItemLine = { indent: number; content: number; marker: string };
9
+
10
+ export const leadingWhitespace = (text: string) => text.length - text.trimStart().length;
11
+
12
+ export const inCode = (tree: Tree, pos: number): boolean => {
13
+ for (let node: SyntaxNode | null = tree.resolveInner(pos, 1); node; node = node.parent) {
14
+ if (CODE.has(node.name)) return true;
15
+ }
16
+ return false;
17
+ };
18
+
19
+ /**
20
+ * A line that starts with a list marker outside code, data and directives. The line text decides, not the parse:
21
+ * an empty item right after Tab reads as paragraph text until someone types, and Shift+Tab
22
+ * must still lift it.
23
+ */
24
+ export const listItemLine = (tree: Tree, line: Line, excluded: () => readonly ExcludedSpan[]): ListItemLine | null => {
25
+ const match = LIST_ITEM.exec(line.text);
26
+ if (!match || /^([-*_])(?: *\1){2,} *$/.test(line.text.trimStart())) return null;
27
+ const indent = match[1]!.length;
28
+ if (inCode(tree, line.from + indent) || excluded().some((span) => line.from + indent >= span.from && line.from + indent < span.to))
29
+ return null;
30
+ const marker = match[2]!.length;
31
+ const spaces = match[3]!.length;
32
+ const rest = line.text.length > match[0].length;
33
+ return { indent, marker: match[2]!, content: indent + marker + (rest && spaces >= 1 && spaces <= 4 ? spaces : 1) };
34
+ };
@@ -0,0 +1,227 @@
1
+ /**
2
+ * The list items of a note as Markdown parses them: real nesting depth, the
3
+ * number Book shows, and the source ranges of markers and content. Editor
4
+ * display, renumbering, moving items and list-aware paste share this model.
5
+ */
6
+ import { syntaxTree } from "@codemirror/language";
7
+ import { type ChangeSet, type ChangeSpec, type EditorState, Facet } from "@codemirror/state";
8
+ import type { Tree } from "@lezer/common";
9
+
10
+ export type ListItemKind = "bullet" | "ordered" | "task";
11
+ export type ListItem = {
12
+ /** 1-based marker line and its start position. */
13
+ line: number;
14
+ from: number;
15
+ /** End of the ListItem node, including its descendants. */
16
+ to: number;
17
+ depth: number;
18
+ orderedDepth: number;
19
+ kind: ListItemKind;
20
+ ordered: boolean;
21
+ start: number | null;
22
+ ordinal: number | null;
23
+ checked: boolean;
24
+ markerFrom: number;
25
+ markerTo: number;
26
+ taskFrom: number | null;
27
+ taskTo: number | null;
28
+ contentFrom: number;
29
+ parent: number;
30
+ /** Source identity of the containing list, including separate lists of the same kind. */
31
+ listFrom: number;
32
+ /** Index one past the last descendant. */
33
+ subtreeEnd: number;
34
+ };
35
+ export type ListStructure = { items: readonly ListItem[] };
36
+ export type ExcludedSpan = { from: number; to: number };
37
+
38
+ export type MarkdownRangeOptions = {
39
+ /** Source ranges that must keep literal text, such as data or query directives. */
40
+ excludedRanges?: (markdown: string) => readonly ExcludedSpan[];
41
+ };
42
+
43
+ /** Shared by list display and commands, including commands used without rich list display. */
44
+ export const listExcludedRanges = Facet.define<NonNullable<MarkdownRangeOptions["excludedRanges"]>>({
45
+ combine: (values) => [...new Set(values)],
46
+ });
47
+
48
+ /** Full source scan; callers cache these spans independently of parser progress. */
49
+ export const excludedSpans = (state: EditorState): ExcludedSpan[] =>
50
+ state.facet(listExcludedRanges).flatMap((ranges) => ranges(state.doc.toString()).map(({ from, to }) => ({ from, to })));
51
+
52
+ /** Items outside quotes, data blocks and directives. Optional ranges cover whole top-level list blocks; exclusions are read only when a list exists. */
53
+ export const listStructure = (
54
+ state: EditorState,
55
+ excludedSpans: () => readonly ExcludedSpan[],
56
+ range?: ExcludedSpan,
57
+ tree: Tree = syntaxTree(state),
58
+ ): ListStructure => {
59
+ const items: ListItem[] = [];
60
+ const parents: Array<{ from: number; index: number }> = [];
61
+ const lists: Array<{ ordered: boolean; start: number | null; index: number }> = [];
62
+ tree.iterate({
63
+ from: range?.from,
64
+ to: range?.to,
65
+ enter(ref) {
66
+ if (ref.name === "Blockquote") return false;
67
+ const node = ref.node;
68
+ if (ref.name === "BulletList" || ref.name === "OrderedList") {
69
+ if (range && (ref.to <= range.from || ref.from >= range.to)) return false;
70
+ const ordered = ref.name === "OrderedList";
71
+ const mark = node.getChild("ListItem")?.getChild("ListMark");
72
+ lists.push({ ordered, start: ordered && mark ? Number.parseInt(state.sliceDoc(mark.from, mark.to), 10) : null, index: 0 });
73
+ }
74
+ if (ref.name !== "ListItem" || !["BulletList", "OrderedList"].includes(node.parent?.name ?? "")) return;
75
+ const list = lists.at(-1);
76
+ const mark = node.getChild("ListMark");
77
+ if (!list || !mark) return false;
78
+ const ordinal = list.start === null ? null : list.start + list.index;
79
+ list.index++;
80
+ const line = state.doc.lineAt(mark.from);
81
+ if (items.at(-1)?.line === line.number || excludedSpans().some((span) => mark.from >= span.from && mark.from < span.to)) return false;
82
+ const task = node.getChild("Task")?.getChild("TaskMarker");
83
+ let contentFrom = task?.to ?? mark.to;
84
+ while (contentFrom < line.to && /[ \t]/.test(state.sliceDoc(contentFrom, contentFrom + 1))) contentFrom++;
85
+ const parent = parents.at(-1)?.index ?? -1;
86
+ const ancestor = items[parent];
87
+ const index = items.length;
88
+ items.push({
89
+ line: line.number,
90
+ from: line.from,
91
+ to: node.to,
92
+ depth: parents.length,
93
+ orderedDepth: ancestor ? ancestor.orderedDepth + Number(ancestor.ordered) : 0,
94
+ kind: task ? "task" : list.ordered ? "ordered" : "bullet",
95
+ ordered: list.ordered,
96
+ start: list.start,
97
+ ordinal,
98
+ checked: task ? state.sliceDoc(task.from, task.to) !== "[ ]" : false,
99
+ markerFrom: mark.from,
100
+ markerTo: mark.to,
101
+ taskFrom: task?.from ?? null,
102
+ taskTo: task?.to ?? null,
103
+ contentFrom,
104
+ parent,
105
+ listFrom: node.parent!.from,
106
+ subtreeEnd: index + 1,
107
+ });
108
+ parents.push({ from: node.from, index });
109
+ },
110
+ leave(ref) {
111
+ if (ref.name === "BulletList" || ref.name === "OrderedList") lists.pop();
112
+ if (ref.name === "ListItem" && parents.at(-1)?.from === ref.from) {
113
+ const parent = parents.pop();
114
+ if (parent) items[parent.index]!.subtreeEnd = items.length;
115
+ }
116
+ },
117
+ });
118
+ return { items };
119
+ };
120
+
121
+ /** Rewrite markers and shift continuation text and children by the width delta. Source-only items also cover empty, not-yet-parsed items. */
122
+ export const markerChanges = (
123
+ state: EditorState,
124
+ structure: { items: readonly Pick<ListItem, "line" | "to" | "markerFrom" | "markerTo">[] },
125
+ markers: ReadonlyMap<number, string>,
126
+ ): ChangeSet => {
127
+ const changes: ChangeSpec[] = [];
128
+ const deltas = new Map<number, number>();
129
+ for (const [index, marker] of markers) {
130
+ const item = structure.items[index]!;
131
+ const old = state.sliceDoc(item.markerFrom, item.markerTo);
132
+ if (old === marker) continue;
133
+ let prefix = 0;
134
+ while (prefix < old.length && prefix < marker.length && old[prefix] === marker[prefix]) prefix++;
135
+ let suffix = 0;
136
+ while (
137
+ suffix < old.length - prefix &&
138
+ suffix < marker.length - prefix &&
139
+ old[old.length - suffix - 1] === marker[marker.length - suffix - 1]
140
+ )
141
+ suffix++;
142
+ changes.push({ from: item.markerFrom + prefix, to: item.markerTo - suffix, insert: marker.slice(prefix, marker.length - suffix) });
143
+ const delta = marker.length - (item.markerTo - item.markerFrom);
144
+ if (!delta) continue;
145
+ for (let number = item.line + 1; number <= state.doc.lineAt(item.to).number; number++) {
146
+ const line = state.doc.line(number);
147
+ if (line.text.trim()) deltas.set(number, (deltas.get(number) ?? 0) + delta);
148
+ }
149
+ }
150
+ for (const [number, delta] of deltas) {
151
+ const line = state.doc.line(number);
152
+ if (delta > 0) changes.push({ from: line.from, insert: " ".repeat(delta) });
153
+ else if (delta < 0) changes.push({ from: line.from, to: line.from + Math.min(-delta, /^ */.exec(line.text)![0].length) });
154
+ }
155
+ return state.changes(changes);
156
+ };
157
+
158
+ /** Repair only the specified ordered lists, using their supplied starts and preserving delimiters. */
159
+ export const renumberChanges = (state: EditorState, structure: ListStructure, starts: ReadonlyMap<number, number>): ChangeSet => {
160
+ const markers = new Map<number, string>();
161
+ structure.items.forEach((item, index) => {
162
+ const start = starts.get(item.listFrom);
163
+ if (!item.ordered || start === undefined || item.ordinal === null || item.start === null) return;
164
+ const written = state.sliceDoc(item.markerFrom, item.markerTo);
165
+ const marker = `${item.ordinal - item.start + start}${written.at(-1)}`;
166
+ if (written !== marker) markers.set(index, marker);
167
+ });
168
+ return markerChanges(state, structure, markers);
169
+ };
170
+
171
+ export const siblingIndex = (structure: ListStructure, index: number, direction: 1 | -1): number => {
172
+ const item = structure.items[index]!;
173
+ for (let next = direction > 0 ? item.subtreeEnd : index - 1; next >= 0 && next < structure.items.length; next += direction) {
174
+ const candidate = structure.items[next]!;
175
+ if (candidate.depth < item.depth) break;
176
+ if (candidate.depth === item.depth) return candidate.parent === item.parent && candidate.listFrom === item.listFrom ? next : -1;
177
+ }
178
+ return -1;
179
+ };
180
+
181
+ /** Binary search for the item whose marker is on this line. */
182
+ export const listItemAtLine = (structure: ListStructure, line: number): number => {
183
+ let low = 0;
184
+ let high = structure.items.length - 1;
185
+ while (low <= high) {
186
+ const middle = (low + high) >>> 1;
187
+ const candidate = structure.items[middle]!;
188
+ if (candidate.line === line) return middle;
189
+ if (candidate.line < line) low = middle + 1;
190
+ else high = middle - 1;
191
+ }
192
+ return -1;
193
+ };
194
+
195
+ const romanDigits: readonly (readonly [number, string])[] = [
196
+ [1000, "m"],
197
+ [900, "cm"],
198
+ [500, "d"],
199
+ [400, "cd"],
200
+ [100, "c"],
201
+ [90, "xc"],
202
+ [50, "l"],
203
+ [40, "xl"],
204
+ [10, "x"],
205
+ [9, "ix"],
206
+ [5, "v"],
207
+ [4, "iv"],
208
+ [1, "i"],
209
+ ];
210
+
211
+ /** Labels cycle through 1., a., i. for six ordered levels; deeper levels keep roman like Book and the PDF, with CSS's decimal fallback. */
212
+ export const orderedLabel = (ordinal: number, orderedDepth: number): string => {
213
+ const style = Math.min(orderedDepth, 5) % 3;
214
+ let label = "";
215
+ if (style === 1 && ordinal >= 1) {
216
+ for (let n = ordinal; n > 0; n = Math.floor((n - 1) / 26)) label = String.fromCharCode(97 + ((n - 1) % 26)) + label;
217
+ } else if (style === 2 && ordinal >= 1 && ordinal <= 3999) {
218
+ let n = ordinal;
219
+ for (const [value, digit] of romanDigits) {
220
+ while (n >= value) {
221
+ label += digit;
222
+ n -= value;
223
+ }
224
+ }
225
+ } else label = String(ordinal);
226
+ return `${label}.`;
227
+ };
@@ -0,0 +1,3 @@
1
+ /** KaTeX source syntax, shared by the math widgets and every decoration that must leave math alone. */
2
+ export const blockMathPattern = (): RegExp => /\$\$([^$]+)\$\$|\\\[(.*?)\\\]/gs;
3
+ export const inlineMathPattern = (): RegExp => /(?<!\$)\$(?!\$)([^\n$]+)\$(?!\$)|\\\(([^)\n]*?)\\\)/g;