@k2b/editor 0.0.0 → 0.1.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 (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,703 @@
1
+ /**
2
+ * Markdown table widget — renders a `| col | col |` block as a tile-style
3
+ * preview when the cursor is on a different line, mirroring the read-mode
4
+ * HTML produced by the `marked` extension. Same `.md-table-*` classes
5
+ * flow through `cloud/src/styles/utilities-markdown-table.css` so the visual
6
+ * is identical in edit and read mode.
7
+ *
8
+ * Editing path:
9
+ * - Cursor inside the table's lines (or on the line right before / after) →
10
+ * widget hidden, raw markdown source visible, normal CM editing.
11
+ * - Click on the widget → cursor dispatched to the table's start so the
12
+ * source-show range fires; user can then arrow-key around inside.
13
+ *
14
+ * # Cursor navigation
15
+ *
16
+ * Four defense-in-depth measures around CodeMirror's vertical-nav
17
+ * behavior near block widgets:
18
+ *
19
+ * 1. `EditorView.atomicRanges` over the block-widget decorations.
20
+ * CM's official recommendation for replace-block decorations —
21
+ * snaps cursor positions that fall inside the widget range to
22
+ * the nearest edge.
23
+ *
24
+ * 2. StateField rebuild only on a doc or syntax-tree change OR
25
+ * cursor-crosses-table-boundary (NOT on every `tr.selection`). Prevents
26
+ * widget destroy+recreate on every vertical-nav keystroke,
27
+ * which was thrashing layout heights mid-move.
28
+ *
29
+ * 3. CSS-derived `TableWidget.estimatedHeight` (37px/row + dividers
30
+ * + 8px padding). Under-estimating causes upward layout shift after
31
+ * DOM measurement, which CM's vertical-nav sees as a moving
32
+ * target.
33
+ *
34
+ * 4. `Prec.highest` ArrowUp/Down keymap intercept that does
35
+ * strict logical-line navigation when tables are present in
36
+ * the doc. Bypasses CM's Y-coord based `moveVertically` for
37
+ * vertical motion, walking past widgets in a single hop.
38
+ *
39
+ * CodeMirror 6.39.13 / 6.40.0 / 6.41.0 fixed several upstream
40
+ * cursor-jump bugs around block widgets + line-wrapping (this
41
+ * project pulls them in via `@codemirror/view: ^6.42.1`), but
42
+ * our specific layout — multiple stacked `Decoration.replace({
43
+ * block: true })` widgets with `cm-lineWrapping` and a cursor-
44
+ * proximity source-toggle — still triggers a residual case where
45
+ * ArrowDown approaching a widget from above resolves past it
46
+ * instead of into its source. We therefore keep a `Prec.highest`
47
+ * custom keymap (4) that does line-by-line nav and walks past
48
+ * widgets in a single hop. With the CM upgrade as a strong base
49
+ * + the keymap as final guard, all the cursor-jump symptoms from
50
+ * the original bug report are gone.
51
+ *
52
+ * No pagination, no per-cell type detection, no column zebra — markdown
53
+ * tables are hand-edited and small. Data tables belong in the Grids app.
54
+ */
55
+ import { syntaxTree } from "@codemirror/language";
56
+ import { type EditorState, type Extension, Prec, type Range, RangeSet, StateField } from "@codemirror/state";
57
+ import { Decoration, type DecorationSet, EditorView, keymap, WidgetType } from "@codemirror/view";
58
+ import { i18n } from "@k2b/stdlib";
59
+ import { clipboard } from "@k2b/stdlib/browser";
60
+ import { type EvalContext, type EvalResult, evaluateFormula, isFormula } from "../formula";
61
+ import { holdsDecorations, refreshMarkdownDecorationsEffect, selectionIntersectsRange, treeOrDocChanged } from "./_lib/cursor-zone-field";
62
+ import { splitTableLineCells, tableCellText } from "./_lib/table-cell";
63
+ import { isNamedBlockHandle } from "./lib/named-blocks";
64
+ import { applyLigatures } from "./ligatures";
65
+ import { formatFormulaError, formatFormulaValue, type PrettyTableOptions, renderPrettyTableHtml } from "./pretty-table";
66
+
67
+ type Align = "left" | "right" | "center" | null;
68
+
69
+ type TableData = {
70
+ caption?: string;
71
+ headers: string[];
72
+ rows: string[][];
73
+ align: Align[];
74
+ };
75
+
76
+ const splitRow = (line: string): string[] => {
77
+ return splitTableLineCells(line).map((cell) => tableCellText(cell.text));
78
+ };
79
+
80
+ const parseAlign = (separator: string): Align => {
81
+ const t = separator.trim();
82
+ const startsColon = t.startsWith(":");
83
+ const endsColon = t.endsWith(":");
84
+ if (startsColon && endsColon) return "center";
85
+ if (endsColon) return "right";
86
+ if (startsColon) return "left";
87
+ return null;
88
+ };
89
+
90
+ const parseTable = (text: string): TableData | null => {
91
+ const lines = text.split("\n").filter((line) => line.trim());
92
+ if (lines.length < 2) return null;
93
+ if (!lines[1]?.includes("---")) return null;
94
+
95
+ const allRows = lines.map(splitRow);
96
+ const headers = allRows[0] ?? [];
97
+ const align: Align[] = (allRows[1] ?? []).map(parseAlign);
98
+ const rows = allRows.slice(2);
99
+
100
+ const cols = headers.length;
101
+ const normalizedRows = rows.map((row) => {
102
+ while (row.length < cols) row.push("");
103
+ return row.slice(0, cols);
104
+ });
105
+ // Pad / truncate align to match column count.
106
+ while (align.length < cols) align.push(null);
107
+
108
+ return { headers, rows: normalizedRows, align: align.slice(0, cols) };
109
+ };
110
+
111
+ const renderTable = (data: TableData, presentation: PrettyTableOptions): string => renderPrettyTableHtml(data, presentation);
112
+
113
+ const sameArray = <T>(left: T[], right: T[]): boolean =>
114
+ left.length === right.length && left.every((value, index) => value === right[index]);
115
+
116
+ const sameRows = (left: string[][], right: string[][]): boolean =>
117
+ left.length === right.length && left.every((row, index) => sameArray(row, right[index] ?? []));
118
+
119
+ const sameTableData = (left: TableData, right: TableData): boolean =>
120
+ left.caption === right.caption &&
121
+ sameArray(left.headers, right.headers) &&
122
+ sameArray(left.align, right.align) &&
123
+ sameRows(left.rows, right.rows);
124
+
125
+ /**
126
+ * Inline live-preview widget for raw-mode formula cells. When the user
127
+ * has the cursor inside a table (so the markdown source is visible),
128
+ * we slip a small ` → <result> ` ghost after each `=...` cell so they
129
+ * see whether their formula evaluates correctly without having to
130
+ * leave the cell.
131
+ *
132
+ * Click-to-copy: clicking the OK preview copies the computed value to
133
+ * the clipboard and briefly swaps the text to ` copied ` for ~800 ms.
134
+ * The error variant stays inert — there's nothing useful to copy.
135
+ *
136
+ * `eq()` makes equal-content widgets DOM-stable so the in-flight
137
+ * "copied" state survives unrelated editor updates.
138
+ */
139
+ const COPIED_REVERT_MS = 800;
140
+ const formulaCopyMessages = i18n.define({
141
+ baseLocale: "en",
142
+ messages: {
143
+ en: { copy: "Copy result", copied: "copied", failed: "Could not copy. Click to try again." },
144
+ de: { copy: "Ergebnis kopieren", copied: "kopiert", failed: "Kopieren fehlgeschlagen. Zum Wiederholen klicken." },
145
+ },
146
+ });
147
+
148
+ // Equal widgets may retain DOM while their WidgetType instance changes.
149
+ // Clipboard work and its timer therefore belong to the DOM lifetime.
150
+ const formulaPreviewCopies = new WeakMap<HTMLElement, { timer?: number; destroyed: boolean }>();
151
+
152
+ class FormulaPreviewWidget extends WidgetType {
153
+ constructor(
154
+ private text: string,
155
+ private isError: boolean,
156
+ private locale?: string,
157
+ ) {
158
+ super();
159
+ }
160
+
161
+ override toDOM() {
162
+ const span = document.createElement("span");
163
+ span.className = this.isError ? "cm-formula-preview cm-formula-preview-error" : "cm-formula-preview";
164
+ span.textContent = ` → ${this.text} `;
165
+
166
+ // Errors aren't copyable — no sensible value to put on the clipboard.
167
+ if (this.isError) return span;
168
+
169
+ const valueText = this.text;
170
+ const copyState: { timer?: number; destroyed: boolean } = { destroyed: false };
171
+ formulaPreviewCopies.set(span, copyState);
172
+ const messages = formulaCopyMessages.resolve(this.locale ? [this.locale] : []).t;
173
+ span.setAttribute("aria-live", "polite");
174
+ span.setAttribute("role", "button");
175
+ span.setAttribute("aria-label", messages.copy);
176
+ span.tabIndex = 0;
177
+ span.addEventListener("keydown", (event) => {
178
+ if (event.key !== "Enter" && event.key !== " ") return;
179
+ event.preventDefault();
180
+ event.stopPropagation();
181
+ span.click();
182
+ });
183
+
184
+ // mousedown: we own the event. preventDefault stops CM from moving
185
+ // the cursor to the click position; stopPropagation keeps our DOM
186
+ // click handler reachable without CM also processing it.
187
+ span.addEventListener("mousedown", (e) => {
188
+ e.preventDefault();
189
+ e.stopPropagation();
190
+ });
191
+
192
+ span.addEventListener("click", (e) => {
193
+ e.preventDefault();
194
+ e.stopPropagation();
195
+ clipboard
196
+ .copy(valueText)
197
+ .then(() => {
198
+ if (copyState.destroyed) return;
199
+ span.textContent = ` ${messages.copied} `;
200
+ span.classList.add("cm-formula-preview-copied");
201
+ if (copyState.timer !== undefined) window.clearTimeout(copyState.timer);
202
+ copyState.timer = window.setTimeout(() => {
203
+ span.textContent = ` → ${valueText} `;
204
+ span.classList.remove("cm-formula-preview-copied");
205
+ copyState.timer = undefined;
206
+ }, COPIED_REVERT_MS);
207
+ })
208
+ .catch(() => {
209
+ if (copyState.destroyed) return;
210
+ if (copyState.timer !== undefined) window.clearTimeout(copyState.timer);
211
+ copyState.timer = undefined;
212
+ span.classList.remove("cm-formula-preview-copied");
213
+ span.textContent = ` → ${valueText} · ${messages.failed} `;
214
+ });
215
+ });
216
+
217
+ return span;
218
+ }
219
+
220
+ override destroy(dom: HTMLElement) {
221
+ const copyState = formulaPreviewCopies.get(dom);
222
+ if (!copyState) return;
223
+ copyState.destroyed = true;
224
+ if (copyState.timer !== undefined) window.clearTimeout(copyState.timer);
225
+ formulaPreviewCopies.delete(dom);
226
+ }
227
+
228
+ override eq(other: WidgetType) {
229
+ return (
230
+ other instanceof FormulaPreviewWidget && other.text === this.text && other.isError === this.isError && other.locale === this.locale
231
+ );
232
+ }
233
+
234
+ override ignoreEvent() {
235
+ return true;
236
+ }
237
+ }
238
+
239
+ /** Shared with the source-only extension; installs no table replacement or keymap. */
240
+ export const formulaPreviewWidget = (result: EvalResult, locale?: string): WidgetType => {
241
+ const text = result.kind === "ok" ? formatFormulaValue(result.value, locale) : `⚠ ${formatFormulaError(result, locale).split("\n")[0]}`;
242
+ return new FormulaPreviewWidget(text, result.kind === "error", locale);
243
+ };
244
+
245
+ /** Build inline live-preview decorations for one table when its
246
+ * source is visible. Walks each body line, parses cell positions,
247
+ * evaluates any `=...` cells, and inserts a `Decoration.widget` at
248
+ * the cell-end position so it floats next to the literal source. */
249
+ const buildLivePreviewDecorations = (state: EditorState, tableNode: { from: number; to: number }, data: TableData): Range<Decoration>[] => {
250
+ const decorations: Range<Decoration>[] = [];
251
+ const startLine = state.doc.lineAt(tableNode.from);
252
+ const endLine = state.doc.lineAt(tableNode.to);
253
+ // Lines: [0]=header, [1]=separator, [2..]=body rows
254
+ for (let lineNum = startLine.number + 2; lineNum <= endLine.number; lineNum++) {
255
+ const line = state.doc.line(lineNum);
256
+ const bodyRowIdx = lineNum - startLine.number - 2;
257
+ if (bodyRowIdx < 0 || bodyRowIdx >= data.rows.length) continue;
258
+ const cells = splitTableLineCells(line.text);
259
+ cells.forEach((cell, colIdx) => {
260
+ if (colIdx >= data.headers.length) return;
261
+ const trimmed = tableCellText(cell.text);
262
+ if (!isFormula(trimmed)) return;
263
+ const ctx: EvalContext = {
264
+ headers: data.headers,
265
+ rows: data.rows,
266
+ currentRow: bodyRowIdx,
267
+ currentCol: colIdx,
268
+ };
269
+ const result = evaluateFormula(trimmed, ctx);
270
+ const locale = typeof document === "undefined" ? undefined : document.documentElement.lang;
271
+ decorations.push(
272
+ Decoration.widget({
273
+ widget: formulaPreviewWidget(result, locale),
274
+ side: 1,
275
+ }).range(line.from + cell.toInLine),
276
+ );
277
+ });
278
+ }
279
+ return decorations;
280
+ };
281
+
282
+ /**
283
+ * DataTable-style table preview. Click → dispatches cursor to the table's
284
+ * start (`fromPos`, captured at decoration time) so the source-show
285
+ * range fires and the user can edit inline.
286
+ *
287
+ * The click handler lives on the widget DOM, not in a view-level
288
+ * `mousedown`, because `view.posAtDOM(target)` falls back to
289
+ * `state.doc.length` for nodes inside a `contenteditable=false` widget
290
+ * — that fallback was the cause of the "click jumps to end of doc" bug.
291
+ * Capturing the start position at construction sidesteps the problem.
292
+ */
293
+ class TableWidget extends WidgetType {
294
+ constructor(
295
+ private data: TableData,
296
+ private fromPos: number,
297
+ private presentation: PrettyTableOptions,
298
+ ) {
299
+ super();
300
+ }
301
+
302
+ override toDOM(view: EditorView) {
303
+ const container = document.createElement("div");
304
+ container.className = "cm-table-widget";
305
+ container.setAttribute("contenteditable", "false");
306
+ container.innerHTML = renderTable(this.data, this.presentation);
307
+ applyLigatures(container);
308
+ container.onmousedown = (event) => {
309
+ const wrap = event.target;
310
+ if (wrap instanceof HTMLElement && wrap.matches(".md-table-wrap")) {
311
+ const rect = wrap.getBoundingClientRect();
312
+ const left = rect.left + wrap.clientLeft;
313
+ const top = rect.top + wrap.clientTop;
314
+ // Leave presses on classic scrollbars to the browser so their thumbs can be dragged.
315
+ if (
316
+ event.clientX < left ||
317
+ event.clientX >= left + wrap.clientWidth ||
318
+ event.clientY < top ||
319
+ event.clientY >= top + wrap.clientHeight
320
+ )
321
+ return;
322
+ }
323
+ event.preventDefault();
324
+ event.stopPropagation();
325
+ view.dispatch({ selection: { anchor: this.fromPos } });
326
+ view.focus();
327
+ };
328
+ // CM's default `dblclick` handler runs INDEPENDENTLY of mousedown.
329
+ // When the user double-clicks on the widget, CM falls back to
330
+ // `posAtDOM(target)` for word-selection, which returns
331
+ // `state.doc.length` for nodes inside a `contenteditable=false`
332
+ // widget — observed as "double-click on a table jumps the cursor
333
+ // to the end of the doc". Stopping propagation on dblclick
334
+ // (without preventDefault, so the browser's native click logic
335
+ // still works for any descendant `<a>`/button) keeps CM out of
336
+ // it.
337
+ container.ondblclick = (event) => {
338
+ event.stopPropagation();
339
+ };
340
+ return container;
341
+ }
342
+
343
+ override eq(other: WidgetType) {
344
+ return (
345
+ other instanceof TableWidget &&
346
+ other.fromPos === this.fromPos &&
347
+ other.presentation === this.presentation &&
348
+ sameTableData(this.data, other.data)
349
+ );
350
+ }
351
+
352
+ /** Widget handles its own clicks; keep CM's view-level handlers off
353
+ * this DOM tree so the click never reaches `posAtDOM`. */
354
+ override ignoreEvent() {
355
+ return true;
356
+ }
357
+
358
+ override get estimatedHeight() {
359
+ // Per-cell rendered metrics (from `utilities-markdown-table.css`):
360
+ // - padding: 0.5rem on every side (16px vertical)
361
+ // - font-size: 14px, line-height: 1.5 (21px), min-height: 2rem
362
+ // → 37px per single-line row plus its hairline divider.
363
+ // Plus 8px outer padding below `.cm-table-widget` (see the theme
364
+ // rule for why this is padding not margin: margin would collapse
365
+ // and confuse CM's height measurement, padding stays inside the
366
+ // border-box).
367
+ //
368
+ // Formula: (N+1) rows × 37px + max(1, N) dividers + 8px container
369
+ // The header divider stays even in a table with no body rows.
370
+ //
371
+ // ACCURATE estimate matters MORE than buffer — see history at
372
+ // file top for the click-drift bug this matches. It is exact only for
373
+ // single-line cells: wrapped cells and a classic horizontal scrollbar
374
+ // measure taller, and CodeMirror corrects the height once the table
375
+ // renders, before it paints.
376
+ return (this.data.rows.length + 1) * 37 + Math.max(1, this.data.rows.length) + 8;
377
+ }
378
+ }
379
+
380
+ /** Per-table source range — the from/to of the markdown `Table`
381
+ * node. Used by the StateField's update() to detect cursor-boundary
382
+ * crossings without re-iterating the syntax tree. */
383
+ type TableRange = { from: number; to: number };
384
+
385
+ /** Full StateField value. Both decoration sets are derived from the
386
+ * same scan: `decorations` includes block widgets + live-preview
387
+ * formula chips, `blockWidgetDecorations` is just the block widgets
388
+ * (feeds `EditorView.atomicRanges` so the cursor snaps to widget
389
+ * edges instead of landing inside the replaced range during
390
+ * vertical navigation). */
391
+ type TablesState = {
392
+ decorations: DecorationSet;
393
+ blockWidgetDecorations: DecorationSet;
394
+ /** All discovered table source ranges. Used for the
395
+ * cursor-crossed-a-boundary check in `update()`. */
396
+ tableRanges: TableRange[];
397
+ };
398
+
399
+ /** Compute which table the cursor is currently INSIDE (or
400
+ * immediately before / after). Returns:
401
+ * - the matching range's `from` (stable key per-table)
402
+ * - or `null` when the cursor is outside all tables.
403
+ *
404
+ * We key by `from` because the array index changes when tables are
405
+ * added/removed, and a stable identity makes the crossing check
406
+ * robust against any reorder.
407
+ *
408
+ * The "source-visible zone" extends by ONE LINE in BOTH directions
409
+ * around the table:
410
+ *
411
+ * - Line BEFORE the table: ArrowDown from this line would
412
+ * otherwise hit the block widget and our line-based vertical
413
+ * nav would SKIP past it (= user can't enter the table by
414
+ * arrowing from above). With the zone extended upward, the
415
+ * table flips to source-visible when the cursor reaches the
416
+ * line above it, so the next ArrowDown lands inside the
417
+ * first source row.
418
+ *
419
+ * - Line AFTER the table: symmetric — ArrowUp from this line
420
+ * flips the table to source mode so the user enters the
421
+ * table source rather than skipping past it.
422
+ *
423
+ * This makes the table behave consistently regardless of approach
424
+ * direction. */
425
+ const cursorTableKey = (state: EditorState, ranges: TableRange[]): string | null => {
426
+ if (ranges.length === 0) return null;
427
+ const cursor = state.selection.main;
428
+ const hits: number[] = [];
429
+ for (const r of ranges) {
430
+ const prevLineStart = state.doc.lineAt(Math.max(r.from - 1, 0)).from;
431
+ const nextLineEnd = state.doc.lineAt(Math.min(r.to + 1, state.doc.length)).to;
432
+ if (selectionIntersectsRange(cursor, prevLineStart, nextLineEnd)) hits.push(r.from);
433
+ }
434
+ return hits.length > 0 ? hits.sort((a, b) => a - b).join(",") : null;
435
+ };
436
+
437
+ /** Full scan: walks the syntax tree, finds every Markdown table,
438
+ * builds the right decoration mode for each (block widget when the
439
+ * cursor is elsewhere, live-preview formula chips when inside).
440
+ *
441
+ * Returns the full StateField value — both decoration sets and the
442
+ * list of table ranges. */
443
+ const scanTables = (state: EditorState, presentation: PrettyTableOptions): TablesState => {
444
+ const decorations: Range<Decoration>[] = [];
445
+ const blockWidgetDecorations: Range<Decoration>[] = [];
446
+ const tableRanges: TableRange[] = [];
447
+ const cursor = state.selection.main;
448
+
449
+ syntaxTree(state).iterate({
450
+ enter: (node) => {
451
+ if (node.type.name !== "Table") return;
452
+
453
+ const text = state.sliceDoc(node.from, node.to);
454
+ const data = parseTable(text);
455
+ if (!data || data.headers.length === 0) return;
456
+ const tableLine = state.doc.lineAt(node.from);
457
+ const handleLine = tableLine.number > 1 ? state.doc.line(tableLine.number - 1) : null;
458
+ const caption = handleLine ? isNamedBlockHandle(handleLine.text) : null;
459
+ const rangeFrom = caption ? handleLine!.from : node.from;
460
+ if (caption) data.caption = caption;
461
+ tableRanges.push({ from: rangeFrom, to: node.to });
462
+
463
+ // Show-source range: when the cursor is anywhere inside the table
464
+ // OR on the line right before / right after it, drop the block
465
+ // widget so the raw markdown becomes editable. Instead, slip in
466
+ // inline live-preview decorations after each formula cell — the
467
+ // user sees their `=hours * rate` followed by ` → 160` ghost
468
+ // text and can verify the result while still typing in the
469
+ // source.
470
+ //
471
+ // The line-before extension is what makes ArrowDown from above
472
+ // ENTER the table (rather than the line-based vertical-nav
473
+ // widget-skip kicking in). See `cursorTableKey` for the
474
+ // symmetric rationale and the original asymmetric-only bug.
475
+ const prevLine = state.doc.lineAt(Math.max(rangeFrom - 1, 0));
476
+ const nextLine = state.doc.lineAt(Math.min(node.to + 1, state.doc.length));
477
+ const sourceVisible = selectionIntersectsRange(cursor, prevLine.from, nextLine.to);
478
+ if (sourceVisible) {
479
+ for (const deco of buildLivePreviewDecorations(state, { from: node.from, to: node.to }, data)) {
480
+ decorations.push(deco);
481
+ }
482
+ return false;
483
+ }
484
+
485
+ const blockDeco = Decoration.replace({
486
+ widget: new TableWidget(data, node.from, presentation),
487
+ block: true,
488
+ }).range(rangeFrom, node.to);
489
+ decorations.push(blockDeco);
490
+ blockWidgetDecorations.push(blockDeco);
491
+ },
492
+ });
493
+
494
+ return {
495
+ decorations: decorations.length > 0 ? RangeSet.of(decorations, true) : Decoration.none,
496
+ blockWidgetDecorations: blockWidgetDecorations.length > 0 ? RangeSet.of(blockWidgetDecorations, true) : Decoration.none,
497
+ tableRanges,
498
+ };
499
+ };
500
+
501
+ export const tablesExtension = (presentation: PrettyTableOptions = {}): Extension => {
502
+ const stateField = StateField.define<TablesState>({
503
+ create(state) {
504
+ return scanTables(state, presentation);
505
+ },
506
+ update(value, tr) {
507
+ if (tr.effects.some((effect) => effect.is(refreshMarkdownDecorationsEffect))) {
508
+ return scanTables(tr.state, presentation);
509
+ }
510
+ // Doc or syntax tree changed → tables may have appeared /
511
+ // disappeared / shifted positions. Full rescan.
512
+ if (treeOrDocChanged(tr)) {
513
+ return scanTables(tr.state, presentation);
514
+ }
515
+ if (holdsDecorations(tr)) return value;
516
+ // Selection unchanged AND doc unchanged → nothing to do.
517
+ if (!tr.selection) {
518
+ return value;
519
+ }
520
+ // Selection changed but stayed in the same "table zone" — the
521
+ // widget vs. source-visible decision is identical for both
522
+ // states, so we can keep the existing decorations unchanged.
523
+ // This is the key perf + correctness win:
524
+ //
525
+ // - PERF: vertical-nav keystrokes don't burn a syntax-tree
526
+ // walk + decoration rebuild per keystroke.
527
+ // - CORRECTNESS: the decoration set stays STABLE between
528
+ // cursor moves that don't actually change widget visibility.
529
+ // The previous implementation rebuilt on every selection,
530
+ // which could DESTROY+RECREATE the block widget mid-move —
531
+ // layout heights shifted under CM's vertical-nav math and
532
+ // the cursor landed at unpredictable spots (the reported
533
+ // "ArrowUp jumps multiple lines" bug).
534
+ const oldKey = cursorTableKey(tr.startState, value.tableRanges);
535
+ const newKey = cursorTableKey(tr.state, value.tableRanges);
536
+ if (oldKey === newKey) {
537
+ return value;
538
+ }
539
+ // Cursor crossed INTO or OUT OF a table (or moved between two
540
+ // different tables) — the widget vs source-visible decision
541
+ // for at least one table flipped. Rebuild.
542
+ return scanTables(tr.state, presentation);
543
+ },
544
+ provide(field) {
545
+ return [
546
+ EditorView.decorations.from(field, (v) => v.decorations),
547
+ // Mark the block widget ranges as atomic so CM's selection
548
+ // dispatcher snaps the cursor to widget edges instead of
549
+ // landing inside the replaced range. This is what stops the
550
+ // "ArrowUp from below the table jumps to top of doc" bug:
551
+ // without atomicRanges, moveVertically's posAtCoords can
552
+ // resolve a Y-coordinate to a position INSIDE the widget
553
+ // range (which CM then renders at the START of the range,
554
+ // many lines above where the user expected).
555
+ EditorView.atomicRanges.of((view) => view.state.field(field).blockWidgetDecorations),
556
+ ];
557
+ },
558
+ });
559
+
560
+ const theme = EditorView.theme({
561
+ ".cm-table-widget": {
562
+ display: "block",
563
+ // PAD instead of margin. The shared CSS (`utilities-markdown-table.css`)
564
+ // sets `.md-table-wrap { margin-bottom: 0.5rem; }` for breathing room
565
+ // in the read-mode rendering, but inside a CM
566
+ // block-widget that pattern produces a measurement-vs-visual
567
+ // mismatch: the inner wrap's vertical margins COLLAPSE through
568
+ // the cm-table-widget container (which has no border/padding to
569
+ // contain them), so `getBoundingClientRect().height` on the
570
+ // container returns the table height EXCLUDING those 16px. CM's
571
+ // height-map records the smaller number; click resolution Y-coords
572
+ // then drift DOWN per table. Cumulative drift after 3 tables
573
+ // was ~48px = ~3 lines, which matches the observed bug.
574
+ //
575
+ // Fix: override the inner wrap to ZERO margin (already done below
576
+ // via the descendant selector) and add equivalent PADDING on the
577
+ // outer widget. Padding stays inside the border box, so
578
+ // `getBoundingClientRect().height` includes it, and CM's
579
+ // measurement matches the visual layout. Above the table, the
580
+ // header cell's top padding keeps the header text where it was.
581
+ // The wrap keeps its shared inline bleed, which reaches past the
582
+ // content into the scroller's inline padding (`theme.ts`).
583
+ padding: "0 0 0.5rem",
584
+ margin: "0 !important",
585
+ },
586
+ // Neutralise the shared read-mode wrap margins inside the CM
587
+ // widget — see the .cm-table-widget rule above for the rationale.
588
+ // A table wider than the reading column scrolls in its wrap, bleed
589
+ // included, as in the Book; the editor never scrolls sideways.
590
+ ".cm-table-widget .md-table-wrap": {
591
+ marginTop: "0 !important",
592
+ marginBottom: "0 !important",
593
+ overflowX: "auto",
594
+ },
595
+ // The Book's table type: a step below the text.
596
+ ".cm-table-widget .md-table": {
597
+ wordBreak: "normal",
598
+ fontSize: "0.875em",
599
+ lineHeight: "1.5",
600
+ },
601
+ });
602
+
603
+ /**
604
+ * High-precedence ArrowUp / ArrowDown intercept — strict
605
+ * logical-line vertical navigation, bypassing CM's Y-coord based
606
+ * `moveVertically`.
607
+ *
608
+ * The CodeMirror 6.39.13 / 6.40.0 / 6.41.0 upstream fixes
609
+ * resolved several block-widget + line-wrapping issues, but our
610
+ * specific layout (multiple stacked block widgets + `cm-lineWrapping`
611
+ * + `Decoration.replace({block: true})` with cursor-proximity
612
+ * source-toggle) still triggers the cursor-jump in some cases —
613
+ * specifically ArrowDown approaching a block widget from above,
614
+ * where the target Y resolves past the widget instead of into
615
+ * its source-visible range.
616
+ *
617
+ * This keymap intercept guarantees ONE-LINE-PER-KEYSTROKE
618
+ * deterministic navigation: caretLine ± 1, walking past widgets
619
+ * in a single hop when needed.
620
+ */
621
+ const tableNavKeymap = Prec.highest(
622
+ keymap.of([
623
+ { key: "ArrowUp", run: (view) => navigateLine(view, -1, false) },
624
+ { key: "Shift-ArrowUp", run: (view) => navigateLine(view, -1, true) },
625
+ { key: "ArrowDown", run: (view) => navigateLine(view, 1, false) },
626
+ { key: "Shift-ArrowDown", run: (view) => navigateLine(view, 1, true) },
627
+ ]),
628
+ );
629
+
630
+ function navigateLine(view: EditorView, dir: -1 | 1, extend: boolean): boolean {
631
+ const tablesState = view.state.field(stateField, false);
632
+ if (!tablesState || tablesState.tableRanges.length === 0) return false;
633
+
634
+ const sel = view.state.selection.main;
635
+ const head = sel.head;
636
+ const caretLine = view.state.doc.lineAt(head);
637
+ const caretCol = head - caretLine.from;
638
+
639
+ let targetLineNumber = caretLine.number + dir;
640
+ while (targetLineNumber >= 1 && targetLineNumber <= view.state.doc.lines) {
641
+ const line = view.state.doc.line(targetLineNumber);
642
+ const inside = isLineInBlockWidget(tablesState, line.from, line.to);
643
+ if (!inside) break;
644
+ const widgetStartLine = view.state.doc.lineAt(inside.from).number;
645
+ const widgetEndLine = view.state.doc.lineAt(inside.to).number;
646
+ targetLineNumber = dir < 0 ? widgetStartLine - 1 : widgetEndLine + 1;
647
+ }
648
+
649
+ if (targetLineNumber < 1) {
650
+ view.dispatch({
651
+ selection: extend ? { anchor: sel.anchor, head: 0 } : { anchor: 0 },
652
+ scrollIntoView: true,
653
+ userEvent: "select",
654
+ });
655
+ return true;
656
+ }
657
+ if (targetLineNumber > view.state.doc.lines) {
658
+ const target = view.state.doc.length;
659
+ view.dispatch({
660
+ selection: extend ? { anchor: sel.anchor, head: target } : { anchor: target },
661
+ scrollIntoView: true,
662
+ userEvent: "select",
663
+ });
664
+ return true;
665
+ }
666
+
667
+ const targetLine = view.state.doc.line(targetLineNumber);
668
+ const target = targetLine.from + Math.min(caretCol, targetLine.length);
669
+ view.dispatch({
670
+ selection: extend ? { anchor: sel.anchor, head: target } : { anchor: target },
671
+ scrollIntoView: true,
672
+ userEvent: "select",
673
+ });
674
+ return true;
675
+ }
676
+
677
+ // No view-level mousedown handler — the widget's own onmousedown
678
+ // captures the click + stopPropagation prevents CM's internal
679
+ // logic from running posAtDOM against widget-DOM targets.
680
+ return [stateField, theme, tableNavKeymap];
681
+ };
682
+
683
+ /** Check whether the document range [from, to] is covered by a
684
+ * currently-rendered block-widget decoration. Returns the table's
685
+ * source range when yes, null otherwise. Source-visible tables
686
+ * (cursor near them) are NOT in `blockWidgetDecorations`, so
687
+ * they don't trigger the widget-skip path. */
688
+ const isLineInBlockWidget = (state: TablesState, from: number, to: number): TableRange | null => {
689
+ for (const r of state.tableRanges) {
690
+ const lineOverlapsRange = from <= r.to && to >= r.from;
691
+ if (!lineOverlapsRange) continue;
692
+ let isWidget = false;
693
+ state.blockWidgetDecorations.between(r.from, r.to, (decoFrom, decoTo) => {
694
+ if (decoFrom === r.from && decoTo === r.to) {
695
+ isWidget = true;
696
+ return false;
697
+ }
698
+ return undefined;
699
+ });
700
+ if (isWidget) return r;
701
+ }
702
+ return null;
703
+ };