@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,115 @@
1
+ /**
2
+ * Subscript and Superscript extension for CodeMirror.
3
+ *
4
+ * H~2~O ← `~2~` rendered with `vertical-align: sub`
5
+ * E=mc^2^ ← `^2^` rendered with `vertical-align: super`
6
+ *
7
+ * The lezer markdown parser doesn't model these, so we scan the doc with
8
+ * regex and decorate inner spans. Markers stay visible (KISS — same as
9
+ * `==mark==`); the visual sub/super-shift is enough signal.
10
+ *
11
+ * Carefully scoped regexes:
12
+ * - subscript: a single `~`, the inner content has no whitespace or `~`,
13
+ * closing `~` not adjacent to another `~`. Avoids stomping on
14
+ * GFM strikethrough (`~~text~~`).
15
+ * - superscript: a single `^`, no whitespace, closing `^`.
16
+ */
17
+
18
+ import type { EditorState, Extension, Range, Transaction } from "@codemirror/state";
19
+ import { RangeSet, StateField } from "@codemirror/state";
20
+ import type { DecorationSet } from "@codemirror/view";
21
+ import { Decoration, EditorView } from "@codemirror/view";
22
+ import { SUB_REGEX, SUP_REGEX } from "./_lib/inline-syntax";
23
+
24
+ const subDecoration = Decoration.mark({ class: "cm-subscript" });
25
+ const supDecoration = Decoration.mark({ class: "cm-superscript" });
26
+
27
+ type SubSupState = {
28
+ decorations: DecorationSet;
29
+ ranges: Array<{ from: number; to: number }>;
30
+ };
31
+
32
+ const findSubSup = (state: EditorState): SubSupState => {
33
+ const decorations: Range<Decoration>[] = [];
34
+ const ranges: Array<{ from: number; to: number }> = [];
35
+ const text = state.doc.toString();
36
+ for (const match of text.matchAll(SUB_REGEX)) {
37
+ if (match.index === undefined) continue;
38
+ const from = match.index;
39
+ const to = match.index + match[0].length;
40
+ decorations.push(subDecoration.range(from, to));
41
+ ranges.push({ from, to });
42
+ }
43
+ for (const match of text.matchAll(SUP_REGEX)) {
44
+ if (match.index === undefined) continue;
45
+ const from = match.index;
46
+ const to = match.index + match[0].length;
47
+ decorations.push(supDecoration.range(from, to));
48
+ ranges.push({ from, to });
49
+ }
50
+ // Decorations must be in document order for RangeSet.of(..., true).
51
+ decorations.sort((a, b) => a.from - b.from);
52
+ ranges.sort((a, b) => a.from - b.from);
53
+ return { decorations: RangeSet.of(decorations, true), ranges };
54
+ };
55
+
56
+ const intersectsRanges = (ranges: Array<{ from: number; to: number }>, from: number, to: number): boolean =>
57
+ ranges.some((range) => from <= range.to && to >= range.from);
58
+
59
+ const changesMightAffectSubSup = (tr: Transaction, ranges: Array<{ from: number; to: number }>): boolean => {
60
+ let might = false;
61
+ tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
62
+ if (might) return;
63
+ if (intersectsRanges(ranges, fromA, toA)) {
64
+ might = true;
65
+ return;
66
+ }
67
+ if (/[~^]/.test(inserted.toString())) {
68
+ might = true;
69
+ return;
70
+ }
71
+ const from = Math.max(0, fromB - 2);
72
+ const to = Math.min(tr.state.doc.length, toB + 2);
73
+ might = /[~^]/.test(tr.state.doc.sliceString(from, to));
74
+ });
75
+ return might;
76
+ };
77
+
78
+ const mapRanges = (tr: Transaction, ranges: Array<{ from: number; to: number }>): Array<{ from: number; to: number }> =>
79
+ ranges
80
+ .map((range) => ({
81
+ from: tr.changes.mapPos(range.from, 1),
82
+ to: tr.changes.mapPos(range.to, -1),
83
+ }))
84
+ .filter((range) => range.from < range.to);
85
+
86
+ export const subSupExtension = (): Extension => {
87
+ const stateField = StateField.define<SubSupState>({
88
+ create(state) {
89
+ return findSubSup(state);
90
+ },
91
+ update(value, tr) {
92
+ if (tr.docChanged) {
93
+ if (changesMightAffectSubSup(tr, value.ranges)) return findSubSup(tr.state);
94
+ return { decorations: value.decorations.map(tr.changes), ranges: mapRanges(tr, value.ranges) };
95
+ }
96
+ return value;
97
+ },
98
+ provide(field) {
99
+ return EditorView.decorations.from(field, (value) => value.decorations);
100
+ },
101
+ });
102
+
103
+ const theme = EditorView.theme({
104
+ ".cm-subscript": {
105
+ verticalAlign: "sub",
106
+ fontSize: "0.75em",
107
+ },
108
+ ".cm-superscript": {
109
+ verticalAlign: "super",
110
+ fontSize: "0.75em",
111
+ },
112
+ });
113
+
114
+ return [stateField, theme];
115
+ };
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Whether a press on the note's surface missed the editor, so the surface puts the caret at the end of the note.
3
+ *
4
+ * A press the editor already handled did not miss it, even when handling it replaced the pressed element: ticking a
5
+ * checklist box redraws the box, so by the time the press reaches the surface its target is no longer inside the
6
+ * editor. Treating that press as a miss would move the caret to the end and scroll the note there. A press on the
7
+ * comments beside the text belongs to them, not to the text.
8
+ * Shared-editor chrome, including preserved-input fields outside `.cm-editor`, keeps its native interaction too.
9
+ */
10
+ export const pressMissedEditor = (event: MouseEvent): boolean =>
11
+ !event.defaultPrevented &&
12
+ !(
13
+ event.target instanceof Element &&
14
+ event.target.closest(
15
+ '.cm-editor, .notebook-comments, .notebook-comments-gutter, .notebook-comments-open, button, input, textarea, select, label, a[href], [contenteditable="true"], [role="button"]',
16
+ )
17
+ );
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Tab and Shift+Tab in the note editor.
3
+ *
4
+ * - With a suggestion list open, Tab accepts the highlighted suggestion.
5
+ * - In a table, Tab selects the next cell and Shift+Tab the previous one.
6
+ * - On a list item, Tab nests the selected items one level and Shift+Tab
7
+ * lifts them one level; the items and text nested under them move along.
8
+ * A level is the content column of the item above, so ordered and bullet
9
+ * lists nest the way Markdown renders them. The first item stays put;
10
+ * items joining a list adopt its marker, and new ordered sublists start at 1.
11
+ * - Everywhere else, including code blocks, Tab inserts two spaces at the
12
+ * cursor or indents the selected lines, and Shift+Tab removes up to two
13
+ * spaces of indentation.
14
+ *
15
+ * The note keeps plain spaces. Keyboard users leave the editor with
16
+ * CodeMirror's escape: Esc, then Tab. People who prefer Tab for focus
17
+ * movement leave this extension out through their Notebooks preference.
18
+ */
19
+ import { acceptCompletion } from "@codemirror/autocomplete";
20
+ import { indentLess, indentMore } from "@codemirror/commands";
21
+ import { syntaxTree } from "@codemirror/language";
22
+ import { EditorSelection, type EditorState, type Extension, type Line } from "@codemirror/state";
23
+ import { type Command, EditorView, keymap } from "@codemirror/view";
24
+ import type { SyntaxNode } from "@lezer/common";
25
+ import { leadingWhitespace } from "./_lib/list-item-line";
26
+ import { splitTableLineCells } from "./_lib/table-cell";
27
+ import { indentListItems, outdentListItems } from "./list-commands";
28
+
29
+ const INDENT = " ";
30
+ type Direction = 1 | -1;
31
+
32
+ const tableAround = (state: EditorState, line: Line): SyntaxNode | null => {
33
+ if (!line.text.trim()) return null;
34
+ for (
35
+ let node: SyntaxNode | null = syntaxTree(state).resolveInner(line.from + leadingWhitespace(line.text), 1);
36
+ node;
37
+ node = node.parent
38
+ ) {
39
+ if (node.name === "Table") return node;
40
+ }
41
+ return null;
42
+ };
43
+
44
+ /** Every cell in document order; the delimiter row below the header holds none. Empty cells count. */
45
+ const tableCells = (state: EditorState, table: SyntaxNode) => {
46
+ const first = state.doc.lineAt(table.from).number;
47
+ const last = state.doc.lineAt(table.to).number;
48
+ const cells: { from: number; to: number }[] = [];
49
+ for (let number = first; number <= last; number++) {
50
+ if (number === first + 1) continue;
51
+ const line = state.doc.line(number);
52
+ for (const cell of splitTableLineCells(line.text)) cells.push({ from: line.from + cell.fromInLine, to: line.from + cell.toInLine });
53
+ }
54
+ return cells;
55
+ };
56
+
57
+ const moveTableCell = (view: EditorView, direction: Direction): boolean => {
58
+ const { state } = view;
59
+ const head = state.selection.main.head;
60
+ const table = tableAround(state, state.doc.lineAt(head));
61
+ if (!table) return false;
62
+ const cells = tableCells(state, table);
63
+ const current = cells.findIndex((cell) => cell.from <= head && head <= cell.to);
64
+ const target =
65
+ current >= 0
66
+ ? cells[current + direction]
67
+ : direction > 0
68
+ ? cells.find((cell) => cell.from > head)
69
+ : cells.findLast((cell) => cell.to < head);
70
+ // The first and last cell keep the cursor; Esc, then Tab still leaves the editor.
71
+ if (!target) return true;
72
+ const text = state.sliceDoc(target.from, target.to);
73
+ const from = target.from + leadingWhitespace(text);
74
+ const to = target.to - (text.length - text.trimEnd().length);
75
+ const selection = from < to ? EditorSelection.range(from, to) : EditorSelection.cursor(Math.min(target.from + 1, target.to));
76
+ view.dispatch({ selection, scrollIntoView: true, userEvent: "select" });
77
+ return true;
78
+ };
79
+
80
+ const insertIndent: Command = (view) => {
81
+ if (view.state.selection.ranges.some((range) => !range.empty)) return indentMore(view);
82
+ view.dispatch(view.state.replaceSelection(INDENT), { scrollIntoView: true, userEvent: "input" });
83
+ return true;
84
+ };
85
+
86
+ /** Tab: accepted suggestion, next table cell, nested list item, or two spaces of indentation. */
87
+ const indentOnTab: Command = (view) =>
88
+ !view.state.readOnly && (acceptCompletion(view) || moveTableCell(view, 1) || indentListItems(view) || insertIndent(view));
89
+
90
+ /** Shift+Tab: previous table cell, lifted list item, or up to two spaces less indentation. */
91
+ const outdentOnShiftTab: Command = (view) =>
92
+ !view.state.readOnly && (moveTableCell(view, -1) || outdentListItems(view) || indentLess(view));
93
+
94
+ /**
95
+ * CodeMirror arms its Esc-then-Tab escape only when no key binding handles Esc, but Esc also collapses
96
+ * a selection and closes suggestions. Arming it on every Esc keeps "Esc, then Tab" leaving the editor.
97
+ */
98
+ const armTabEscape = EditorView.domEventObservers({
99
+ keydown: (event, view) => {
100
+ if (event.key === "Escape") view.setTabFocusMode(2000);
101
+ },
102
+ });
103
+
104
+ export const tabIndentExtension = (): Extension => [keymap.of([{ key: "Tab", run: indentOnTab, shift: outdentOnShiftTab }]), armTabEscape];
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Autocomplete für Spaltennamen in Tabellen-Formel-Argumenten.
3
+ *
4
+ * Trigger: cursor sits inside the parentheses of a formula call in
5
+ * a table cell — i.e. after `=FUNC(` or after `=FUNC(arg, `. The
6
+ * partial word being typed (or empty after `(` / `,`) is matched
7
+ * against the column names in the current table's header row.
8
+ *
9
+ * Examples (`|` = cursor position):
10
+ *
11
+ * | =SUM(| → columns
12
+ * | =SUM(Co| → columns starting with "Co"
13
+ * | =IF(ColA, | → columns (for the second argument)
14
+ * | =IF(ColA, ColB, Co| → columns starting with "Co"
15
+ *
16
+ * # Header detection
17
+ *
18
+ * Markdown tables are anchored by their separator row (`|---|---|`).
19
+ * The HEADER is the line immediately before the separator. We walk
20
+ * backwards from the cursor line — at most a handful of lines for
21
+ * any realistic table — and return the cells of the line above the
22
+ * first separator we hit. If we hit a non-table line before any
23
+ * separator (e.g. cursor IS on the header line), the table is
24
+ * malformed or we're typing into the header itself; return no
25
+ * suggestions either way.
26
+ *
27
+ * # Performance
28
+ *
29
+ * Hot-path cost when NOT inside a table: a single `lineAt` call +
30
+ * `isTableRow` check on the current line text. ~µs. Once we know
31
+ * we're in a table cell, the in-cell regex match is the next gate.
32
+ * Header lookup only runs after both pass.
33
+ *
34
+ * Header extraction is O(table height) — bounded by table size,
35
+ * not document size. A 50-row table costs ~50 line reads, sub-ms.
36
+ *
37
+ * # Scope
38
+ *
39
+ * No fenced-code check needed — the table-row gate already
40
+ * excludes code-block context (Markdown tables aren't recognised
41
+ * inside fenced blocks; lines that begin and end with `|` inside a
42
+ * code fence are just literal pipe characters, and the user is
43
+ * not editing a table there).
44
+ */
45
+ import type { Completion, CompletionContext, CompletionResult } from "@codemirror/autocomplete";
46
+ import type { EditorState } from "@codemirror/state";
47
+ import { cellTextBeforeCursor, isTableRow, splitTableLineCells, TABLE_SEPARATOR_RE, tableCellText, withIcon } from "@k2b/editor/markdown";
48
+
49
+ const isSeparatorRow = (lineText: string): boolean => TABLE_SEPARATOR_RE.test(lineText);
50
+
51
+ /** Walk backwards from the cursor's line to find the table's
52
+ * separator row, then return the cells of the line immediately
53
+ * before it (the header). Empty array if no header is reachable
54
+ * (cursor is on the header line, or the table has no separator). */
55
+ const extractHeaderColumns = (state: EditorState, currentLineNumber: number): string[] => {
56
+ for (let n = currentLineNumber - 1; n >= 1; n--) {
57
+ const lineText = state.doc.line(n).text;
58
+ if (isSeparatorRow(lineText)) {
59
+ // Header is the line just above the separator. If we'd run
60
+ // off the doc start, there's no header.
61
+ if (n - 1 < 1) return [];
62
+ const headerText = state.doc.line(n - 1).text;
63
+ return splitHeaderCells(headerText);
64
+ }
65
+ if (!isTableRow(lineText)) {
66
+ // Hit a non-table line before finding the separator —
67
+ // current cursor is above or outside the header section.
68
+ return [];
69
+ }
70
+ }
71
+ return [];
72
+ };
73
+
74
+ /** Split a header row into cell labels. Strips the leading/trailing
75
+ * pipes and trims whitespace from each cell. */
76
+ const splitHeaderCells = (lineText: string): string[] => {
77
+ return splitTableLineCells(lineText)
78
+ .map((cell) => tableCellText(cell.text))
79
+ .filter(Boolean);
80
+ };
81
+
82
+ /** Match: cursor is positioned at the END of cell text that looks
83
+ * like a partial formula call with an open-but-unclosed paren —
84
+ * i.e. we're inside the argument list and the user is typing a
85
+ * word fragment (or nothing yet, right after `(` or `,`).
86
+ *
87
+ * Captures the partial word being typed at position 1 (may be the
88
+ * empty string when cursor sits right after `(` or `, `).
89
+ *
90
+ * Examples that match (`|` = cursor, capture group in []):
91
+ * `=SUM(|` → []
92
+ * `=SUM(Co|` → [Co]
93
+ * `=IF(ColA, |` → []
94
+ * `=IF(ColA, ColB, Co|` → [Co]
95
+ *
96
+ * Doesn't match once the paren closes — by then the user is past
97
+ * argument entry. */
98
+ const FORMULA_ARG_RE = /=\w+\([^)]*?(\w*)$/;
99
+
100
+ /** Strict identifier pattern matching what the formula tokenizer
101
+ * accepts as a bare IDENT (see `isIdentStart` / `isIdentChar` in
102
+ * `cloud/shared/markdown/formula.ts`): `[a-zA-Z_]` to start, then
103
+ * word chars. Anything else needs backtick quoting. */
104
+ const BARE_IDENT_RE = /^[a-zA-Z_]\w*$/;
105
+
106
+ /** Wrap a column name for safe insertion into formula source.
107
+ * - Bare identifiers → returned as-is.
108
+ * - Everything else (spaces, parens, `%`, leading digit, …) →
109
+ * backtick-quoted with `\\` and `\\\`` escapes, mirroring the
110
+ * backtick-ident tokenizer rules in `formula.ts`.
111
+ *
112
+ * `userTypedBacktick = true` means the user already typed an
113
+ * opening backtick before our `partialFrom`; we then emit ONLY
114
+ * the escaped body + a closing backtick so the result reads as
115
+ * `` `Tax (19%)` `` rather than `` ``Tax (19%)` `` (which would
116
+ * fail to parse as an identifier). */
117
+ const buildColumnApply = (col: string, userTypedBacktick: boolean): string => {
118
+ if (!userTypedBacktick && BARE_IDENT_RE.test(col)) return col;
119
+ const escaped = col.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\|/g, "\\|");
120
+ return userTypedBacktick ? `${escaped}\`` : `\`${escaped}\``;
121
+ };
122
+
123
+ /**
124
+ * Completion source. Wire after `tableFormulaCompletionSource` in
125
+ * the `autocompletion({override: …})` array.
126
+ */
127
+ export const tableColumnCompletionSource = (context: CompletionContext): CompletionResult | null => {
128
+ // Stage 1 — cheap: must be on a line that LOOKS like a table row.
129
+ const line = context.state.doc.lineAt(context.pos);
130
+ if (!isTableRow(line.text)) return null;
131
+
132
+ // Stage 2 — must be in a cell that has formula-call-with-open-paren
133
+ // shape leading up to the cursor.
134
+ const cursorCol = context.pos - line.from;
135
+ const cell = cellTextBeforeCursor(line.text, cursorCol);
136
+ if (!cell) return null;
137
+ const match = FORMULA_ARG_RE.exec(cell.text);
138
+ if (!match) return null;
139
+
140
+ // Stage 3 — extract the table header (one walk-backwards, capped
141
+ // at the table's vertical size).
142
+ const columns = extractHeaderColumns(context.state, line.number);
143
+ if (columns.length === 0) return null;
144
+
145
+ // Build completions. `completionIcon` is set via structural cast (same
146
+ // pattern as the other completion sources) so the option-list shows a
147
+ // distinct glyph (`ti-columns-3`) for column entries — visually
148
+ // separates them from ordinary identifiers.
149
+ //
150
+ // The `apply` text is computed per-completion based on whether
151
+ // the column name needs backtick-quoting AND whether the user
152
+ // has already typed an opening backtick. Without this the
153
+ // autocomplete would insert raw `Tax (19%)` for a special-char
154
+ // column, which the formula parser rejects (`%` is not a valid
155
+ // identifier char). With the quoting it emits `` `Tax (19%)` ``,
156
+ // which is what the formula tokenizer accepts via its
157
+ // backtick-ident rule.
158
+ const partial = match[1] ?? "";
159
+ const partialFrom = context.pos - partial.length;
160
+ // Check whether the char immediately before our match start is a
161
+ // backtick — that signals the user is already typing inside a
162
+ // quoted-ident, so our apply should NOT add another opening
163
+ // backtick (would produce ``…` which doesn't parse).
164
+ const charBefore = partialFrom > 0 ? context.state.sliceDoc(partialFrom - 1, partialFrom) : "";
165
+ const userTypedBacktick = charBefore === "`";
166
+
167
+ const options: Completion[] = columns.map((col) => {
168
+ const c: Completion = {
169
+ // `label` is the raw column name — used both for the popup
170
+ // display AND for CM's prefix filter. So a user typing "Tax"
171
+ // narrows to "Tax (19%)" even though the actual inserted
172
+ // text is the backtick-quoted form.
173
+ label: col,
174
+ type: "property",
175
+ detail: "column",
176
+ apply: buildColumnApply(col, userTypedBacktick),
177
+ };
178
+ withIcon(c, "ti-columns-3");
179
+ return c;
180
+ });
181
+
182
+ return {
183
+ from: partialFrom,
184
+ to: context.pos,
185
+ options,
186
+ validFor: /^\w*$/,
187
+ };
188
+ };
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Autocomplete für `=FORMULA(...)` in Markdown-Tabellenzellen.
3
+ *
4
+ * Trigger: cursor is inside a table cell whose typed content so far
5
+ * begins with `=` (optionally followed by an in-progress function
6
+ * name). Accepting a suggestion expands to a full call template
7
+ * with tab-stops for arguments — e.g. `=SUM(${1:Column})` lands
8
+ * the cursor inside the parens with the column-name placeholder
9
+ * already selected for replacement.
10
+ *
11
+ * Functions mirror the registry in
12
+ * `@k2b/editor/formula` (the same module
13
+ * that EVALUATES the formulas in both edit-mode preview and
14
+ * read-mode rendering). Keep them in sync when new functions are
15
+ * added there.
16
+ *
17
+ * # Scope detection
18
+ *
19
+ * We don't lean on the Lezer-markdown `Table` / `TableRow` /
20
+ * `TableCell` nodes because they require the table to be
21
+ * structurally complete to be emitted — while the user is actively
22
+ * typing a new row (no separator line yet, or a cell being filled
23
+ * mid-edit) the parser often hasn't promoted the lines to a Table
24
+ * node yet, and the completions wouldn't fire. The structural
25
+ * heuristic is more robust:
26
+ *
27
+ * 1. Current line begins AND ends with `|` after trimming
28
+ * 2. Current line is NOT the separator row (`|---|---|`)
29
+ * 3. The cell the cursor sits in (text between the nearest `|`
30
+ * before the cursor and the cursor itself) trims to `=<partial>`
31
+ *
32
+ * All three checks are O(line length) — cheap.
33
+ */
34
+ import { type Completion, type CompletionContext, type CompletionResult, snippetCompletion } from "@codemirror/autocomplete";
35
+ import { cellTextBeforeCursor, isTableRow, withIcon } from "@k2b/editor/markdown";
36
+
37
+ type Formula = {
38
+ /** Function name as it appears in the formula source. */
39
+ name: string;
40
+ /** Signature shown inline as the option detail. */
41
+ detail: string;
42
+ /** Snippet body inside the parens. `${1:foo}` are tab-stops the
43
+ * user can step through after accepting. */
44
+ args: string;
45
+ /** Tabler icon — categorises functions visually. */
46
+ icon: string;
47
+ };
48
+
49
+ /** Registry — keep in sync with `FUNCTIONS` in
50
+ * `packages/cloud/src/shared/markdown/formula.ts`. */
51
+ const FORMULAS: Formula[] = [
52
+ // Math ─────────────────────────────────────────────────────────
53
+ { name: "ROUND", detail: "(number, digits)", args: "${1:value}, ${2:2}", icon: "ti-math-function" },
54
+ { name: "ABS", detail: "(number)", args: "${1:value}", icon: "ti-math-function" },
55
+ { name: "SQRT", detail: "(number) — square root", args: "${1:value}", icon: "ti-math-function" },
56
+ { name: "POW", detail: "(base, exp) — power", args: "${1:base}, ${2:exp}", icon: "ti-math-function" },
57
+ { name: "MOD", detail: "(a, b) — remainder", args: "${1:a}, ${2:b}", icon: "ti-math-function" },
58
+ // Column aggregates ────────────────────────────────────────────
59
+ { name: "SUM", detail: "(column) — sum of column", args: "${1:Column}", icon: "ti-sum" },
60
+ { name: "AVG", detail: "(column) — average of column", args: "${1:Column}", icon: "ti-math-avg" },
61
+ { name: "MEAN", detail: "(column) — alias for AVG", args: "${1:Column}", icon: "ti-math-avg" },
62
+ { name: "MIN", detail: "(column) — minimum of column", args: "${1:Column}", icon: "ti-math-min" },
63
+ { name: "MAX", detail: "(column) — maximum of column", args: "${1:Column}", icon: "ti-math-max" },
64
+ { name: "COUNT", detail: "(column) — non-empty count", args: "${1:Column}", icon: "ti-tallymark-4" },
65
+ { name: "MEDIAN", detail: "(column) — median of column", args: "${1:Column}", icon: "ti-math-avg" },
66
+ { name: "UNIQUE", detail: "(column) — distinct non-empty count", args: "${1:Column}", icon: "ti-fingerprint" },
67
+ { name: "STDEV", detail: "(column) — sample standard deviation", args: "${1:Column}", icon: "ti-chart-bar" },
68
+ { name: "COUNTIF", detail: "(column, value) — count matching cells", args: "${1:Column}, ${2:'value'}", icon: "ti-tallymark-4" },
69
+ { name: "SUMIF", detail: "(sumCol, condCol, value) — conditional sum", args: "${1:SumCol}, ${2:CondCol}, ${3:'value'}", icon: "ti-sum" },
70
+ // Sugar ────────────────────────────────────────────────────────
71
+ { name: "PERCENT", detail: "(part, total) — rounded percent", args: "${1:part}, ${2:total}", icon: "ti-percentage" },
72
+ { name: "PROGRESS", detail: "(ratio) or (done, total) — progress bar", args: "${1:done}, ${2:total}", icon: "ti-progress" },
73
+ // Row aggregates ───────────────────────────────────────────────
74
+ { name: "ROWSUM", detail: "() — sum of other cells in row", args: "", icon: "ti-sum" },
75
+ { name: "ROWAVG", detail: "() — average of other cells in row", args: "", icon: "ti-math-avg" },
76
+ { name: "ROWMEAN", detail: "() — alias for ROWAVG", args: "", icon: "ti-math-avg" },
77
+ // Conditional ──────────────────────────────────────────────────
78
+ { name: "IF", detail: "(cond, then, else)", args: "${1:condition}, ${2:then}, ${3:else}", icon: "ti-git-branch" },
79
+ { name: "IFEMPTY", detail: "(value, fallback)", args: "${1:value}, ${2:fallback}", icon: "ti-git-branch" },
80
+ { name: "IFERROR", detail: "(value, fallback)", args: "${1:value}, ${2:fallback}", icon: "ti-git-branch" },
81
+ // Logical ──────────────────────────────────────────────────────
82
+ { name: "AND", detail: "(a, b, ...) — all truthy", args: "${1:a}, ${2:b}", icon: "ti-logic-and" },
83
+ { name: "OR", detail: "(a, b, ...) — any truthy", args: "${1:a}, ${2:b}", icon: "ti-logic-or" },
84
+ { name: "NOT", detail: "(a) — invert truthiness", args: "${1:value}", icon: "ti-logic-not" },
85
+ { name: "CONTAINS", detail: "(haystack, needle) — substring match", args: "${1:haystack}, ${2:'needle'}", icon: "ti-search" },
86
+ // Text ─────────────────────────────────────────────────────────
87
+ { name: "CONCAT", detail: "(...parts) — join strings", args: "${1:a}, ${2:b}", icon: "ti-typography" },
88
+ { name: "UPPER", detail: "(text) — uppercase", args: "${1:text}", icon: "ti-letter-case-upper" },
89
+ { name: "LOWER", detail: "(text) — lowercase", args: "${1:text}", icon: "ti-letter-case-lower" },
90
+ { name: "TRIM", detail: "(text) — strip whitespace", args: "${1:text}", icon: "ti-typography" },
91
+ { name: "LEFT", detail: "(text, n) — first n chars", args: "${1:text}, ${2:n}", icon: "ti-cut" },
92
+ { name: "RIGHT", detail: "(text, n) — last n chars", args: "${1:text}, ${2:n}", icon: "ti-cut" },
93
+ { name: "LEN", detail: "(text) — character count", args: "${1:text}", icon: "ti-ruler-measure" },
94
+ { name: "SUBSTRING", detail: "(text, start, length)", args: "${1:text}, ${2:start}, ${3:length}", icon: "ti-cut" },
95
+ { name: "REPLACE", detail: "(text, search, replacement)", args: "${1:text}, ${2:'search'}, ${3:'replacement'}", icon: "ti-replace" },
96
+ // Date / time ──────────────────────────────────────────────────
97
+ { name: "NOW", detail: "() — current YYYY-MM-DD HH:MM:SS", args: "", icon: "ti-clock" },
98
+ { name: "TODAY", detail: "() — current YYYY-MM-DD", args: "", icon: "ti-calendar" },
99
+ {
100
+ name: "DATEDIFF",
101
+ detail: "(d1, d2, unit?) — date diff (d/h/m/s/ms)",
102
+ args: "${1:'2026-01-01'}, ${2:'2026-12-31'}, ${3:'d'}",
103
+ icon: "ti-calendar-stats",
104
+ },
105
+ ];
106
+
107
+ /** Snippet template. Does NOT include the leading `=` because the
108
+ * `from` position in the CompletionResult is anchored AFTER the
109
+ * `=` the user already typed — CM's prefix-filter compares the
110
+ * typed name fragment against `label` ("SUM", "AVG", …), and on
111
+ * acceptance only the text between `from` and the cursor is
112
+ * replaced. The user's typed `=` stays in place. The trailing
113
+ * `${0}` parks the final cursor outside the parens. */
114
+ const buildSnippet = (f: Formula): string => (f.args.length === 0 ? `${f.name}()` : `${f.name}(${f.args})\${0}`);
115
+
116
+ const COMPLETIONS: Completion[] = FORMULAS.map((f) => {
117
+ const c = snippetCompletion(buildSnippet(f), {
118
+ label: f.name,
119
+ type: "function",
120
+ detail: f.detail,
121
+ });
122
+ withIcon(c, f.icon);
123
+ return c;
124
+ });
125
+
126
+ /**
127
+ * Completion source. Wire into `autocompletion({override: […]})`.
128
+ *
129
+ * # Two-stage filter for hot-path performance
130
+ *
131
+ * Per keystroke, MOST users are NOT inside a table cell. Doing
132
+ * `lineAt` + `isTableRow` + `cellTextBeforeCursor` on every
133
+ * keystroke would burn CPU for nothing. We bail early via the
134
+ * cheapest possible check first:
135
+ *
136
+ * 1. `matchBefore(/=\w*\$/)` — the cursor must be on text that
137
+ * ends with `=<word>`. If not, return null immediately. This
138
+ * is the same `Line.text.search()` call CM does internally;
139
+ * sub-millisecond on any sane line length.
140
+ *
141
+ * 2. ONLY THEN do we verify the line is a table row AND the cell
142
+ * content matches the formula pattern.
143
+ *
144
+ * Without this two-stage filter the source was the dominant
145
+ * per-keystroke cost in ordinary text (it always called
146
+ * `lineAt` + ran a regex over the full line, even when typing in
147
+ * a plain paragraph far from any table).
148
+ */
149
+ export const tableFormulaCompletionSource = (context: CompletionContext): CompletionResult | null => {
150
+ // Stage 1 — cheap: must be typing something that ends in `=<word>`.
151
+ const word = context.matchBefore(/=\w*/);
152
+ if (!word) return null;
153
+
154
+ // Stage 2 — confirm we're in a table cell.
155
+ const line = context.state.doc.lineAt(context.pos);
156
+ if (!isTableRow(line.text)) return null;
157
+
158
+ const cursorCol = context.pos - line.from;
159
+ const cell = cellTextBeforeCursor(line.text, cursorCol);
160
+ if (!cell) return null;
161
+
162
+ // Cell content must START with `=` (after optional whitespace).
163
+ // Once the user types `(` or any non-word char, this source
164
+ // stops firing (correct — they're inside an argument list, not
165
+ // picking a function).
166
+ if (!/^\s*=\w*$/.test(cell.text)) return null;
167
+
168
+ // Anchor `from` AFTER the `=` so CM's prefix-filter sees just the
169
+ // function-name fragment (e.g. "SU" → "SUM"). Anchoring at the `=`
170
+ // would force the labels to start with `=`, which they don't, so
171
+ // CM would silently drop every option → empty popup.
172
+ return {
173
+ from: word.from + 1,
174
+ to: context.pos,
175
+ options: COMPLETIONS,
176
+ validFor: /^\w*$/,
177
+ };
178
+ };