@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.
- package/LICENSE +661 -0
- package/README.md +31 -2
- package/package.json +118 -4
- package/src/BubbleMenu.tsx +126 -0
- package/src/Editor.tsx +32 -0
- package/src/SlashMenu.tsx +13 -0
- package/src/Toolbar.tsx +205 -0
- package/src/actions.ts +102 -0
- package/src/chart-schema.ts +274 -0
- package/src/charts.ts +710 -0
- package/src/collab/history.ts +39 -0
- package/src/collab/index.ts +32 -0
- package/src/collab/relative-cursor.ts +18 -0
- package/src/collab/remote-cursors.ts +358 -0
- package/src/editor-state.ts +199 -0
- package/src/formula.ts +1283 -0
- package/src/index.ts +16 -0
- package/src/markdown/_lib/cursor-zone-field.ts +338 -0
- package/src/markdown/_lib/inline-syntax.ts +9 -0
- package/src/markdown/_lib/link-opener.ts +50 -0
- package/src/markdown/_lib/list-item-line.ts +34 -0
- package/src/markdown/_lib/list-structure.ts +227 -0
- package/src/markdown/_lib/math-syntax.ts +3 -0
- package/src/markdown/_lib/table-cell.ts +74 -0
- package/src/markdown/basic.ts +37 -0
- package/src/markdown/block-previews.tsx +453 -0
- package/src/markdown/chart-block-messages.ts +5 -0
- package/src/markdown/chart-copy.ts +20 -0
- package/src/markdown/code-fence-snippets.ts +167 -0
- package/src/markdown/code-font.ts +54 -0
- package/src/markdown/completion-icon.ts +9 -0
- package/src/markdown/data-block-template.ts +11 -0
- package/src/markdown/data-blocks.ts +214 -0
- package/src/markdown/editor-scope.ts +92 -0
- package/src/markdown/fade-scroll-margins.browser-harness.ts +78 -0
- package/src/markdown/fade-scroll-margins.ts +25 -0
- package/src/markdown/image-syntax.ts +26 -0
- package/src/markdown/images.ts +192 -0
- package/src/markdown/index.ts +54 -0
- package/src/markdown/info-block-snippets.ts +159 -0
- package/src/markdown/info-blocks.ts +156 -0
- package/src/markdown/katex.ts +303 -0
- package/src/markdown/lib/chart-blocks.ts +511 -0
- package/src/markdown/lib/heading-anchors.ts +40 -0
- package/src/markdown/lib/ligatures.ts +107 -0
- package/src/markdown/lib/markdown-context.ts +72 -0
- package/src/markdown/lib/markdown-fences.ts +22 -0
- package/src/markdown/lib/markdown-image.fixtures.ts +20 -0
- package/src/markdown/lib/markdown-image.ts +36 -0
- package/src/markdown/lib/named-blocks.ts +403 -0
- package/src/markdown/lib/query-blocks.ts +145 -0
- package/src/markdown/ligatures.ts +223 -0
- package/src/markdown/links.ts +158 -0
- package/src/markdown/list-commands.ts +1124 -0
- package/src/markdown/lists.ts +290 -0
- package/src/markdown/mark.ts +103 -0
- package/src/markdown/markdown.ts +78 -0
- package/src/markdown/markup.ts +385 -0
- package/src/markdown/mermaid-export.ts +78 -0
- package/src/markdown/mermaid-preview.tsx +76 -0
- package/src/markdown/mermaid-render.ts +22 -0
- package/src/markdown/mermaid-viewport.tsx +78 -0
- package/src/markdown/mermaid.ts +242 -0
- package/src/markdown/messages.ts +17 -0
- package/src/markdown/named-blocks.ts +84 -0
- package/src/markdown/note-scroller.browser-harness.ts +84 -0
- package/src/markdown/note-scroller.ts +57 -0
- package/src/markdown/notebook-charts.tsx +142 -0
- package/src/markdown/pretty-table-messages.ts +33 -0
- package/src/markdown/pretty-table.ts +220 -0
- package/src/markdown/search-theme.ts +98 -0
- package/src/markdown/shared-tables.ts +286 -0
- package/src/markdown/sub-sup.ts +115 -0
- package/src/markdown/surface-press.ts +17 -0
- package/src/markdown/tab-indent.ts +104 -0
- package/src/markdown/table-columns.ts +188 -0
- package/src/markdown/table-formulas.ts +178 -0
- package/src/markdown/tables.ts +703 -0
- package/src/markdown/theme.ts +243 -0
- package/src/markdown/urls.ts +29 -0
- package/src/messages.ts +45 -0
- package/src/slash-menu.ts +220 -0
- 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
|
+
};
|