@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,303 @@
1
+ import { syntaxTree } from "@codemirror/language";
2
+ import type { EditorState, Extension, Range, Transaction } from "@codemirror/state";
3
+ import { RangeSet } from "@codemirror/state";
4
+ import { Decoration, EditorView, WidgetType } from "@codemirror/view";
5
+ import katex from "katex";
6
+ import {
7
+ blockWidgetLineNavigationExtension,
8
+ type CursorZoneState,
9
+ cursorZoneStateField,
10
+ selectionIntersectsRange,
11
+ } from "./_lib/cursor-zone-field";
12
+ import { blockMathPattern, inlineMathPattern } from "./_lib/math-syntax";
13
+ import { codeFenceLanguage } from "./lib/markdown-fences";
14
+
15
+ // =============================================================================
16
+ // Module-scoped render cache
17
+ // =============================================================================
18
+ //
19
+ // KaTeX is fast (~1–5ms per formula), but on a Numpy-style note with 50+
20
+ // formulas, repeatedly re-rendering on scroll-past-and-back adds up. CM's
21
+ // widget reuse handles the in-viewport case via `eq()`, but widgets that
22
+ // scroll out of the viewport are destroyed + recreated → fresh katex.render
23
+ // every time. Cache the rendered DOM keyed by (mode, latex) and hand out
24
+ // deep clones so callers can insert without aliasing.
25
+ //
26
+ // `cloneNode(true)` is safe here: katex output is plain spans + divs with
27
+ // CSS classes (no event handlers, no shadow DOM, no canvas). The font
28
+ // references live in global stylesheets so classes survive the clone.
29
+
30
+ type RenderEntry = { node: HTMLElement; timestamp: number };
31
+ const RENDER_CACHE_MAX = 200;
32
+ const renderCache = new Map<string, RenderEntry>();
33
+
34
+ /** Single-pass min-by-timestamp eviction (mirrors mermaid's
35
+ * `evictOldestSvg`). LRU keeps the working set hot without
36
+ * scanning + sorting on every overflow. */
37
+ const evictOldestRender = (): void => {
38
+ let oldestKey: string | undefined;
39
+ let oldestTs = Number.POSITIVE_INFINITY;
40
+ for (const [k, e] of renderCache) {
41
+ if (e.timestamp < oldestTs) {
42
+ oldestTs = e.timestamp;
43
+ oldestKey = k;
44
+ }
45
+ }
46
+ if (oldestKey !== undefined) renderCache.delete(oldestKey);
47
+ };
48
+
49
+ /** Render LaTeX to a detached DOM element (block or inline). On
50
+ * cache hit, returns a deep clone of the cached prototype and
51
+ * bumps its timestamp for LRU. On miss, runs `katex.render` once,
52
+ * caches the prototype, returns a clone. Returns `null` if katex
53
+ * throws (rare with `throwOnError: false`, but defensive) so the
54
+ * caller can render the error fallback instead. */
55
+ const tryRenderKatex = (latex: string, displayMode: boolean): HTMLElement | null => {
56
+ const key = `${displayMode ? "B" : "I"}:${latex}`;
57
+ const entry = renderCache.get(key);
58
+ if (entry) {
59
+ entry.timestamp = Date.now();
60
+ return entry.node.cloneNode(true) as HTMLElement;
61
+ }
62
+ // Use a `div` host for block math (katex-display wraps as block)
63
+ // and a `span` for inline so the natural flow keeps inline math
64
+ // inline.
65
+ const prototype = document.createElement(displayMode ? "div" : "span");
66
+ try {
67
+ katex.render(latex, prototype, { throwOnError: false, displayMode });
68
+ } catch {
69
+ return null;
70
+ }
71
+ renderCache.set(key, { node: prototype, timestamp: Date.now() });
72
+ if (renderCache.size > RENDER_CACHE_MAX) evictOldestRender();
73
+ return prototype.cloneNode(true) as HTMLElement;
74
+ };
75
+
76
+ class InlineMathWidget extends WidgetType {
77
+ constructor(private latex: string) {
78
+ super();
79
+ }
80
+
81
+ override toDOM() {
82
+ const rendered = tryRenderKatex(this.latex, false);
83
+ const span = rendered ?? document.createElement("span");
84
+ span.className = "cm-katex-inline";
85
+ if (!rendered) {
86
+ span.innerHTML = `<span class="cm-katex-error text-red-500">$${this.latex}$</span>`;
87
+ }
88
+ return span;
89
+ }
90
+
91
+ override ignoreEvent(event: Event) {
92
+ return event.type !== "mousedown";
93
+ }
94
+
95
+ override eq(other: WidgetType) {
96
+ return other instanceof InlineMathWidget && other.latex === this.latex;
97
+ }
98
+ }
99
+
100
+ class BlockMathWidget extends WidgetType {
101
+ constructor(
102
+ private latex: string,
103
+ private fromPos: number,
104
+ ) {
105
+ super();
106
+ }
107
+
108
+ override toDOM(view: EditorView) {
109
+ const container = document.createElement("div");
110
+ container.className = "cm-katex-block !m-0";
111
+ container.setAttribute("contenteditable", "false");
112
+ container.onmousedown = (event) => {
113
+ event.preventDefault();
114
+ event.stopPropagation();
115
+ view.dispatch({ selection: { anchor: this.fromPos }, scrollIntoView: true });
116
+ view.focus();
117
+ };
118
+ container.ondblclick = (event) => {
119
+ event.stopPropagation();
120
+ };
121
+
122
+ const wrapper = document.createElement("div");
123
+ wrapper.className = "p-1 overflow-x-auto flex items-center justify-center";
124
+
125
+ const rendered = tryRenderKatex(this.latex, true);
126
+ const mathDiv = rendered ?? document.createElement("div");
127
+ mathDiv.className = "text-center";
128
+ if (!rendered) {
129
+ mathDiv.innerHTML = `
130
+ <div class="text-red-500 font-mono text-sm">
131
+ <i class="ti ti-alert-circle"></i> Invalid LaTeX
132
+ <pre class="mt-2">${this.latex}</pre>
133
+ </div>`;
134
+ }
135
+
136
+ wrapper.appendChild(mathDiv);
137
+ container.appendChild(wrapper);
138
+ return container;
139
+ }
140
+
141
+ override eq(other: WidgetType) {
142
+ return other instanceof BlockMathWidget && other.latex === this.latex && other.fromPos === this.fromPos;
143
+ }
144
+
145
+ override ignoreEvent() {
146
+ return true;
147
+ }
148
+ }
149
+
150
+ const hasMathMarker = (text: string): boolean => text.includes("$") || text.includes("\\");
151
+
152
+ /** Predicate for the incremental cursor-zone mode. Cheap O(insert + 4)
153
+ * scan to decide whether a doc change might introduce or remove a
154
+ * math marker — `$` or `\` in the inserted text or the ±2 char
155
+ * window around it. False positives (e.g. typing `$` inside code)
156
+ * fall back to a baseline rebuild; false negatives would leave
157
+ * stale decorations, so the predicate is intentionally generous. */
158
+ const changesMightAffectMath = (tr: Transaction): boolean => {
159
+ let might = false;
160
+ tr.changes.iterChanges((_fromA, _toA, fromB, toB, inserted) => {
161
+ if (might) return;
162
+ if (hasMathMarker(inserted.toString())) {
163
+ might = true;
164
+ return;
165
+ }
166
+ const from = Math.max(0, fromB - 2);
167
+ const to = Math.min(tr.state.doc.length, toB + 2);
168
+ might = hasMathMarker(tr.state.doc.sliceString(from, to));
169
+ });
170
+ return might;
171
+ };
172
+
173
+ const intersectsAnyRange = (ranges: { from: number; to: number }[], from: number, to: number): boolean =>
174
+ ranges.some(
175
+ (range) => (from >= range.from && from < range.to) || (to > range.from && to <= range.to) || (from <= range.from && to >= range.to),
176
+ );
177
+
178
+ const buildKatexDecorations = (state: EditorState): CursorZoneState => {
179
+ const decorations: Range<Decoration>[] = [];
180
+ const atomicDecorations: Range<Decoration>[] = [];
181
+ const cursor = state.selection.main;
182
+ const doc = state.doc.toString();
183
+ let hasSyntax = false;
184
+ const ranges: { from: number; to: number }[] = [];
185
+
186
+ const codeRanges: { from: number; to: number }[] = [];
187
+
188
+ syntaxTree(state).iterate({
189
+ enter: (node) => {
190
+ if (node.type.name === "FencedCode") {
191
+ codeRanges.push({ from: node.from, to: node.to });
192
+
193
+ const text = state.sliceDoc(node.from, node.to);
194
+ const lines = text.split("\n");
195
+ const language = codeFenceLanguage(lines[0] ?? "");
196
+
197
+ if (language === "math") {
198
+ hasSyntax = true;
199
+ const prevLine = state.doc.lineAt(Math.max(node.from - 1, 0));
200
+ const nextLine = state.doc.lineAt(Math.min(node.to + 1, state.doc.length));
201
+ ranges.push({ from: prevLine.from, to: nextLine.to });
202
+ if (selectionIntersectsRange(cursor, prevLine.from, nextLine.to)) return false;
203
+ const latex = lines.slice(1, -1).join("\n");
204
+ const blockDecoration = Decoration.replace({
205
+ widget: new BlockMathWidget(latex, node.from),
206
+ block: true,
207
+ }).range(node.from, node.to);
208
+ decorations.push(blockDecoration);
209
+ atomicDecorations.push(blockDecoration);
210
+ }
211
+ }
212
+ if (node.type.name === "InlineCode") {
213
+ codeRanges.push({ from: node.from, to: node.to });
214
+ }
215
+ },
216
+ });
217
+
218
+ /**
219
+ * Walk a global regex over the doc, push a decoration per match
220
+ * UNLESS the match falls inside a code range (handled separately)
221
+ * or the cursor sits inside it (= edit mode for that math snippet).
222
+ *
223
+ * CRITICAL: advance `match = re.exec(doc)` BEFORE any `continue`.
224
+ * The regex's `lastIndex` only moves when `.exec` runs; a `continue`
225
+ * after a skip-decision without re-exec would pin the regex on the
226
+ * same match → infinite loop → tab freeze. Observed when the doc
227
+ * contained a `$$…$$` or `\[…\]` inside a code block.
228
+ */
229
+ const scanMath = (re: RegExp, makeWidget: (latex: string, from: number) => WidgetType, blockWidget: boolean) => {
230
+ let match: RegExpExecArray | null = re.exec(doc);
231
+ while (match !== null) {
232
+ const from = match.index;
233
+ const to = from + match[0].length;
234
+ const latex = match[1] ?? match[2] ?? "";
235
+ hasSyntax = true;
236
+ let sourceVisibleFrom = from;
237
+ let sourceVisibleTo = to;
238
+ if (blockWidget) {
239
+ const fromLine = state.doc.lineAt(from);
240
+ const toLine = state.doc.lineAt(to);
241
+ if (from !== fromLine.from || to !== toLine.to) {
242
+ match = re.exec(doc);
243
+ continue;
244
+ }
245
+ const prevLine = state.doc.lineAt(Math.max(from - 1, 0));
246
+ const nextLine = state.doc.lineAt(Math.min(to + 1, state.doc.length));
247
+ sourceVisibleFrom = prevLine.from;
248
+ sourceVisibleTo = nextLine.to;
249
+ ranges.push({ from: sourceVisibleFrom, to: sourceVisibleTo });
250
+ } else {
251
+ ranges.push({ from, to });
252
+ }
253
+ const cursorInside = selectionIntersectsRange(cursor, sourceVisibleFrom, sourceVisibleTo);
254
+ const skip = intersectsAnyRange(codeRanges, from, to) || cursorInside;
255
+ match = re.exec(doc);
256
+ if (skip) continue;
257
+ const decoration = Decoration.replace(
258
+ blockWidget ? { widget: makeWidget(latex, from), block: true } : { widget: makeWidget(latex, from) },
259
+ ).range(from, to);
260
+ decorations.push(decoration);
261
+ if (blockWidget) atomicDecorations.push(decoration);
262
+ }
263
+ };
264
+
265
+ scanMath(blockMathPattern(), (latex, from) => new BlockMathWidget(latex, from), true);
266
+ scanMath(inlineMathPattern(), (latex) => new InlineMathWidget(latex), false);
267
+
268
+ return {
269
+ decorations: decorations.length > 0 ? RangeSet.of(decorations, true) : Decoration.none,
270
+ atomicDecorations: atomicDecorations.length > 0 ? RangeSet.of(atomicDecorations, true) : Decoration.none,
271
+ ranges,
272
+ hasSyntax,
273
+ };
274
+ };
275
+
276
+ export const katexExtension = (): Extension => {
277
+ const stateField = cursorZoneStateField(buildKatexDecorations, {
278
+ changesMightAffectSyntax: changesMightAffectMath,
279
+ });
280
+
281
+ const theme = EditorView.theme({
282
+ ".cm-katex-inline": {
283
+ display: "inline-block",
284
+ padding: "0 4px",
285
+ borderRadius: "var(--radius-sm)",
286
+ lineHeight: "1",
287
+ },
288
+ ".cm-katex-inline:hover": { background: "rgba(59, 130, 246, 0.05)" },
289
+ ".cm-katex-block": {
290
+ display: "block",
291
+ margin: "0 !important",
292
+ borderRadius: "var(--radius-sm)",
293
+ },
294
+ ".cm-katex-block:hover": { background: "rgba(59, 130, 246, 0.05)" },
295
+ ".cm-katex-error": {
296
+ fontFamily: "var(--font-mono)",
297
+ fontSize: "0.875em",
298
+ padding: "0 2px",
299
+ },
300
+ });
301
+
302
+ return [stateField, blockWidgetLineNavigationExtension(stateField, (value) => value.atomicDecorations), theme];
303
+ };