@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,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
|
+
};
|