@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.
- package/LICENSE +661 -0
- package/README.md +32 -1
- package/package.json +116 -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,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markup-hiding extension for CodeMirror.
|
|
3
|
+
*
|
|
4
|
+
* Hides markdown syntax markers when the cursor is not on the same line/range:
|
|
5
|
+
* - Heading markers (# ## ### etc.) — hidden when cursor is on a different line
|
|
6
|
+
* - Bold markers (**) — hidden when cursor is outside the bold range
|
|
7
|
+
* - Italic markers (* or _) — hidden when cursor is outside the italic range
|
|
8
|
+
* - Strikethrough markers (~~) — hidden when cursor is outside the range
|
|
9
|
+
*
|
|
10
|
+
* Also renders horizontal rules (--- / *** / ___) as visual lines.
|
|
11
|
+
*/
|
|
12
|
+
import { syntaxTree } from "@codemirror/language";
|
|
13
|
+
import type { EditorState, Extension, Range } from "@codemirror/state";
|
|
14
|
+
import { RangeSet, StateField } from "@codemirror/state";
|
|
15
|
+
import type { DecorationSet } from "@codemirror/view";
|
|
16
|
+
import { Decoration, EditorView, WidgetType } from "@codemirror/view";
|
|
17
|
+
import { holdsDecorations, refreshMarkdownDecorationsEffect, treeOrDocChanged } from "./_lib/cursor-zone-field";
|
|
18
|
+
|
|
19
|
+
// =============================================================================
|
|
20
|
+
// Widgets
|
|
21
|
+
// =============================================================================
|
|
22
|
+
|
|
23
|
+
class HorizontalRuleWidget extends WidgetType {
|
|
24
|
+
constructor(private fromPos: number) {
|
|
25
|
+
super();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
override toDOM(view: EditorView) {
|
|
29
|
+
const hr = document.createElement("div");
|
|
30
|
+
hr.className = "cm-hr-widget";
|
|
31
|
+
hr.setAttribute("contenteditable", "false");
|
|
32
|
+
hr.onmousedown = (event) => {
|
|
33
|
+
event.preventDefault();
|
|
34
|
+
event.stopPropagation();
|
|
35
|
+
view.dispatch({ selection: { anchor: this.fromPos }, scrollIntoView: true });
|
|
36
|
+
view.focus();
|
|
37
|
+
};
|
|
38
|
+
return hr;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
override eq(other: WidgetType) {
|
|
42
|
+
return other instanceof HorizontalRuleWidget && other.fromPos === this.fromPos;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
override ignoreEvent() {
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// =============================================================================
|
|
51
|
+
// Decoration builders
|
|
52
|
+
// =============================================================================
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Check if the cursor is on a given line.
|
|
56
|
+
*/
|
|
57
|
+
const cursorOnLine = (state: EditorState, lineFrom: number, lineTo: number): boolean => {
|
|
58
|
+
for (const range of state.selection.ranges) {
|
|
59
|
+
const rLineFrom = state.doc.lineAt(range.from).number;
|
|
60
|
+
const rLineTo = state.doc.lineAt(range.to).number;
|
|
61
|
+
const targetLineFrom = state.doc.lineAt(lineFrom).number;
|
|
62
|
+
const targetLineTo = state.doc.lineAt(lineTo).number;
|
|
63
|
+
if (rLineFrom <= targetLineTo && rLineTo >= targetLineFrom) return true;
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Check if the cursor is inside a range (inclusive).
|
|
70
|
+
*/
|
|
71
|
+
const cursorInRange = (state: EditorState, from: number, to: number): boolean => {
|
|
72
|
+
for (const range of state.selection.ranges) {
|
|
73
|
+
if (range.from <= to && range.to >= from) return true;
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Collect all code ranges to avoid decorating inside code blocks.
|
|
80
|
+
*/
|
|
81
|
+
const getCodeRanges = (state: EditorState): { from: number; to: number }[] => {
|
|
82
|
+
const ranges: { from: number; to: number }[] = [];
|
|
83
|
+
syntaxTree(state).iterate({
|
|
84
|
+
enter: (node) => {
|
|
85
|
+
if (node.type.name === "FencedCode" || node.type.name === "InlineCode" || node.type.name === "CodeBlock") {
|
|
86
|
+
ranges.push({ from: node.from, to: node.to });
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
return ranges;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const isInsideCode = (codeRanges: { from: number; to: number }[], from: number, to: number): boolean =>
|
|
94
|
+
codeRanges.some((r) => from >= r.from && to <= r.to);
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Walk the syntax tree once to collect:
|
|
98
|
+
* - Line numbers of every line that hosts a heading / HR
|
|
99
|
+
* (cursor-on-line decisions depend on these).
|
|
100
|
+
* - Source ranges of every emphasis / strong / strikethrough /
|
|
101
|
+
* codespan node (cursor-in-range decisions depend on these).
|
|
102
|
+
*
|
|
103
|
+
* Cached in the StateField value so subsequent selection-only
|
|
104
|
+
* transactions can decide whether to rebuild WITHOUT another
|
|
105
|
+
* walk: if the cursor neither changed lines (for line-based
|
|
106
|
+
* decorations) nor crossed any tracked range boundary (for
|
|
107
|
+
* range-based decorations), nothing's changed and we keep the
|
|
108
|
+
* existing decoration set.
|
|
109
|
+
*/
|
|
110
|
+
const collectMarkupTrackedRanges = (state: EditorState): { lines: Set<number>; ranges: Array<{ from: number; to: number }> } => {
|
|
111
|
+
const lines = new Set<number>();
|
|
112
|
+
const ranges: Array<{ from: number; to: number }> = [];
|
|
113
|
+
syntaxTree(state).iterate({
|
|
114
|
+
enter: (node) => {
|
|
115
|
+
const { name, from, to } = node;
|
|
116
|
+
if (name.startsWith("ATXHeading") || name === "HorizontalRule" || name === "SetextHeading") {
|
|
117
|
+
const fromLine = state.doc.lineAt(from).number;
|
|
118
|
+
const toLine = state.doc.lineAt(to).number;
|
|
119
|
+
for (let l = fromLine; l <= toLine; l++) lines.add(l);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (name === "Emphasis" || name === "StrongEmphasis" || name === "Strikethrough" || name === "InlineCode") {
|
|
123
|
+
ranges.push({ from, to });
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
return { lines, ranges };
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Build all decorations for markup hiding and horizontal rules.
|
|
132
|
+
*/
|
|
133
|
+
const buildDecorations = (state: EditorState): Range<Decoration>[] => {
|
|
134
|
+
const decorations: Range<Decoration>[] = [];
|
|
135
|
+
const codeRanges = getCodeRanges(state);
|
|
136
|
+
|
|
137
|
+
syntaxTree(state).iterate({
|
|
138
|
+
enter: (node) => {
|
|
139
|
+
const { name, from, to } = node;
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------
|
|
142
|
+
// Headings: hide the "# " marker when cursor is on another line
|
|
143
|
+
// ---------------------------------------------------------------
|
|
144
|
+
if (name.startsWith("ATXHeading")) {
|
|
145
|
+
if (isInsideCode(codeRanges, from, to)) return;
|
|
146
|
+
if (cursorOnLine(state, from, to)) return;
|
|
147
|
+
|
|
148
|
+
// Find the HeaderMark node (the # characters)
|
|
149
|
+
let markEnd = from;
|
|
150
|
+
const nodeRef = node.node;
|
|
151
|
+
const markCursor = nodeRef.cursor();
|
|
152
|
+
if (markCursor.firstChild()) {
|
|
153
|
+
do {
|
|
154
|
+
if (markCursor.name === "HeaderMark") {
|
|
155
|
+
// Include the space after the #
|
|
156
|
+
markEnd = markCursor.to;
|
|
157
|
+
const afterMark = state.doc.sliceString(markEnd, markEnd + 1);
|
|
158
|
+
if (afterMark === " ") markEnd++;
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
} while (markCursor.nextSibling());
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (markEnd > from) {
|
|
165
|
+
decorations.push(Decoration.replace({}).range(from, markEnd));
|
|
166
|
+
}
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------
|
|
171
|
+
// Bold: hide ** or __ markers
|
|
172
|
+
// ---------------------------------------------------------------
|
|
173
|
+
if (name === "StrongEmphasis") {
|
|
174
|
+
if (isInsideCode(codeRanges, from, to)) return;
|
|
175
|
+
if (cursorInRange(state, from, to)) return;
|
|
176
|
+
|
|
177
|
+
const text = state.doc.sliceString(from, to);
|
|
178
|
+
const marker = text.startsWith("**") ? "**" : text.startsWith("__") ? "__" : null;
|
|
179
|
+
if (!marker) return;
|
|
180
|
+
|
|
181
|
+
const markerLen = marker.length;
|
|
182
|
+
// Hide opening marker
|
|
183
|
+
decorations.push(Decoration.replace({}).range(from, from + markerLen));
|
|
184
|
+
// Hide closing marker
|
|
185
|
+
decorations.push(Decoration.replace({}).range(to - markerLen, to));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------
|
|
190
|
+
// Italic: hide * or _ markers
|
|
191
|
+
// ---------------------------------------------------------------
|
|
192
|
+
if (name === "Emphasis") {
|
|
193
|
+
if (isInsideCode(codeRanges, from, to)) return;
|
|
194
|
+
if (cursorInRange(state, from, to)) return;
|
|
195
|
+
|
|
196
|
+
const text = state.doc.sliceString(from, to);
|
|
197
|
+
const marker = text.startsWith("*") ? "*" : text.startsWith("_") ? "_" : null;
|
|
198
|
+
if (!marker) return;
|
|
199
|
+
|
|
200
|
+
const markerLen = marker.length;
|
|
201
|
+
// Hide opening marker
|
|
202
|
+
decorations.push(Decoration.replace({}).range(from, from + markerLen));
|
|
203
|
+
// Hide closing marker
|
|
204
|
+
decorations.push(Decoration.replace({}).range(to - markerLen, to));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ---------------------------------------------------------------
|
|
209
|
+
// Strikethrough: hide ~~ markers
|
|
210
|
+
// ---------------------------------------------------------------
|
|
211
|
+
if (name === "Strikethrough") {
|
|
212
|
+
if (isInsideCode(codeRanges, from, to)) return;
|
|
213
|
+
if (cursorInRange(state, from, to)) return;
|
|
214
|
+
|
|
215
|
+
// Hide opening ~~
|
|
216
|
+
decorations.push(Decoration.replace({}).range(from, from + 2));
|
|
217
|
+
// Hide closing ~~
|
|
218
|
+
decorations.push(Decoration.replace({}).range(to - 2, to));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---------------------------------------------------------------
|
|
223
|
+
// Horizontal Rule: replace --- / *** / ___ with a visual line
|
|
224
|
+
// ---------------------------------------------------------------
|
|
225
|
+
if (name === "HorizontalRule") {
|
|
226
|
+
if (cursorOnLine(state, from, to)) return;
|
|
227
|
+
|
|
228
|
+
decorations.push(
|
|
229
|
+
Decoration.replace({
|
|
230
|
+
widget: new HorizontalRuleWidget(from),
|
|
231
|
+
block: true,
|
|
232
|
+
}).range(from, to),
|
|
233
|
+
);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
return decorations;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const buildAtomicDecorations = (state: EditorState): Range<Decoration>[] => {
|
|
243
|
+
const decorations: Range<Decoration>[] = [];
|
|
244
|
+
syntaxTree(state).iterate({
|
|
245
|
+
enter: ({ name, from, to }) => {
|
|
246
|
+
if (name !== "HorizontalRule") return;
|
|
247
|
+
if (cursorOnLine(state, from, to)) return;
|
|
248
|
+
decorations.push(
|
|
249
|
+
Decoration.replace({
|
|
250
|
+
widget: new HorizontalRuleWidget(from),
|
|
251
|
+
block: true,
|
|
252
|
+
}).range(from, to),
|
|
253
|
+
);
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
return decorations;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
// =============================================================================
|
|
260
|
+
// Extension
|
|
261
|
+
// =============================================================================
|
|
262
|
+
|
|
263
|
+
/** All decoration-affecting ranges discovered during the last
|
|
264
|
+
* rebuild. Markup makes two kinds of cursor-sensitive decisions:
|
|
265
|
+
*
|
|
266
|
+
* - LINE-BASED (`cursorOnLine`): headings + HRs. Rebuild needed
|
|
267
|
+
* when the cursor moves to a different line.
|
|
268
|
+
*
|
|
269
|
+
* - RANGE-BASED (`cursorInRange`): emphasis / strong markers.
|
|
270
|
+
* Rebuild needed when the cursor's containment status changes
|
|
271
|
+
* for any tracked range (entered or left any of them).
|
|
272
|
+
*
|
|
273
|
+
* We cache both so the `update()` can decide whether to rebuild
|
|
274
|
+
* by comparing the new cursor's relationship to these ranges
|
|
275
|
+
* against the old state's. */
|
|
276
|
+
type MarkupState = {
|
|
277
|
+
decorations: DecorationSet;
|
|
278
|
+
atomicDecorations: DecorationSet;
|
|
279
|
+
/** Line numbers of every line that has a cursor-sensitive
|
|
280
|
+
* decoration (heading, HR). If the cursor crosses any of these
|
|
281
|
+
* lines, we rebuild. */
|
|
282
|
+
cursorSensitiveLines: Set<number>;
|
|
283
|
+
/** Emphasis / strong ranges — decisions depend on `cursorInRange`. */
|
|
284
|
+
cursorSensitiveRanges: Array<{ from: number; to: number }>;
|
|
285
|
+
/** Cached current cursor signature so we can compare across
|
|
286
|
+
* transactions cheaply. */
|
|
287
|
+
cursorLine: number;
|
|
288
|
+
cursorRangeKey: string;
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const cursorLineNumber = (state: EditorState): number => state.doc.lineAt(state.selection.main.head).number;
|
|
292
|
+
|
|
293
|
+
/** Compact "set of ranges the cursor is inside" identifier. We use
|
|
294
|
+
* a sorted comma-separated index string so equality is a cheap
|
|
295
|
+
* string compare. */
|
|
296
|
+
const computeCursorRangeKey = (state: EditorState, ranges: Array<{ from: number; to: number }>): string => {
|
|
297
|
+
const cursor = state.selection.main;
|
|
298
|
+
const hits: number[] = [];
|
|
299
|
+
for (let i = 0; i < ranges.length; i++) {
|
|
300
|
+
const r = ranges[i]!;
|
|
301
|
+
if (cursor.from <= r.to && cursor.to >= r.from) hits.push(i);
|
|
302
|
+
}
|
|
303
|
+
return hits.join(",");
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
export const markupExtension = (): Extension => {
|
|
307
|
+
const stateField = StateField.define<MarkupState>({
|
|
308
|
+
create(state) {
|
|
309
|
+
const tracked = collectMarkupTrackedRanges(state);
|
|
310
|
+
return {
|
|
311
|
+
decorations: RangeSet.of(buildDecorations(state), true),
|
|
312
|
+
atomicDecorations: RangeSet.of(buildAtomicDecorations(state), true),
|
|
313
|
+
cursorSensitiveLines: tracked.lines,
|
|
314
|
+
cursorSensitiveRanges: tracked.ranges,
|
|
315
|
+
cursorLine: cursorLineNumber(state),
|
|
316
|
+
cursorRangeKey: computeCursorRangeKey(state, tracked.ranges),
|
|
317
|
+
};
|
|
318
|
+
},
|
|
319
|
+
update(value, tr) {
|
|
320
|
+
if (treeOrDocChanged(tr) || tr.effects.some((effect) => effect.is(refreshMarkdownDecorationsEffect))) {
|
|
321
|
+
// Keep cached cursor signatures consistent with the rebuilt decorations.
|
|
322
|
+
const tracked = collectMarkupTrackedRanges(tr.state);
|
|
323
|
+
return {
|
|
324
|
+
decorations: RangeSet.of(buildDecorations(tr.state), true),
|
|
325
|
+
atomicDecorations: RangeSet.of(buildAtomicDecorations(tr.state), true),
|
|
326
|
+
cursorSensitiveLines: tracked.lines,
|
|
327
|
+
cursorSensitiveRanges: tracked.ranges,
|
|
328
|
+
cursorLine: cursorLineNumber(tr.state),
|
|
329
|
+
cursorRangeKey: computeCursorRangeKey(tr.state, tracked.ranges),
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
if (holdsDecorations(tr)) return value;
|
|
333
|
+
if (!tr.selection) {
|
|
334
|
+
return value;
|
|
335
|
+
}
|
|
336
|
+
// Selection moved. Two checks:
|
|
337
|
+
// 1. Cursor changed line AND old or new line had a markup
|
|
338
|
+
// decoration (heading / HR). Rebuild.
|
|
339
|
+
// 2. Cursor crossed an emphasis/strong range boundary
|
|
340
|
+
// (entered or left). Rebuild.
|
|
341
|
+
const newLine = cursorLineNumber(tr.state);
|
|
342
|
+
const lineChangedAndMatters =
|
|
343
|
+
newLine !== value.cursorLine && (value.cursorSensitiveLines.has(value.cursorLine) || value.cursorSensitiveLines.has(newLine));
|
|
344
|
+
const newRangeKey = computeCursorRangeKey(tr.state, value.cursorSensitiveRanges);
|
|
345
|
+
const rangesChanged = newRangeKey !== value.cursorRangeKey;
|
|
346
|
+
if (!lineChangedAndMatters && !rangesChanged) {
|
|
347
|
+
// Selection moved but didn't affect any markup decision —
|
|
348
|
+
// skip the rebuild. This is the common case for typing
|
|
349
|
+
// within a plain paragraph: lots of cursor moves with no
|
|
350
|
+
// decoration impact.
|
|
351
|
+
return { ...value, cursorLine: newLine };
|
|
352
|
+
}
|
|
353
|
+
const tracked = collectMarkupTrackedRanges(tr.state);
|
|
354
|
+
return {
|
|
355
|
+
decorations: RangeSet.of(buildDecorations(tr.state), true),
|
|
356
|
+
atomicDecorations: RangeSet.of(buildAtomicDecorations(tr.state), true),
|
|
357
|
+
cursorSensitiveLines: tracked.lines,
|
|
358
|
+
cursorSensitiveRanges: tracked.ranges,
|
|
359
|
+
cursorLine: newLine,
|
|
360
|
+
cursorRangeKey: computeCursorRangeKey(tr.state, tracked.ranges),
|
|
361
|
+
};
|
|
362
|
+
},
|
|
363
|
+
provide(field) {
|
|
364
|
+
return [
|
|
365
|
+
EditorView.decorations.from(field, (v) => v.decorations),
|
|
366
|
+
EditorView.atomicRanges.of((view) => view.state.field(field).atomicDecorations),
|
|
367
|
+
];
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
const theme = EditorView.theme({
|
|
372
|
+
".cm-hr-widget": {
|
|
373
|
+
borderTop: "2px solid",
|
|
374
|
+
borderColor: "var(--color-zinc-300)",
|
|
375
|
+
margin: "0 !important",
|
|
376
|
+
padding: "0.75em 0 0",
|
|
377
|
+
boxSizing: "border-box",
|
|
378
|
+
},
|
|
379
|
+
".dark .cm-hr-widget": {
|
|
380
|
+
borderColor: "var(--color-zinc-600)",
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
return [stateField, theme];
|
|
385
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { MermaidConfig } from "mermaid";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* iOS Safari refuses canvases above 16,777,216 pixels (4096²), the smallest
|
|
5
|
+
* limit among supported browsers, so the PNG scale shrinks to stay below it.
|
|
6
|
+
*/
|
|
7
|
+
const MAX_CANVAS_PIXELS = 16_777_216;
|
|
8
|
+
|
|
9
|
+
export type StandaloneSvg = { svg: string; width: number; height: number };
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Mermaid sizes its SVG relative to the page (`width="100%"`, `max-width`).
|
|
13
|
+
* A downloaded file and a rasterized image need the intrinsic size from the
|
|
14
|
+
* `viewBox` instead.
|
|
15
|
+
*/
|
|
16
|
+
export const standaloneSvg = (markup: string): StandaloneSvg => {
|
|
17
|
+
const document = new DOMParser().parseFromString(markup, "image/svg+xml");
|
|
18
|
+
const root = document.documentElement;
|
|
19
|
+
if (root.localName !== "svg" || root.querySelector("parsererror")) throw new Error("Diagram is not an SVG document");
|
|
20
|
+
const box = (root.getAttribute("viewBox") ?? "")
|
|
21
|
+
.trim()
|
|
22
|
+
.split(/[\s,]+/)
|
|
23
|
+
.map(Number);
|
|
24
|
+
const width = Math.ceil(box[2] ?? 0);
|
|
25
|
+
const height = Math.ceil(box[3] ?? 0);
|
|
26
|
+
if (!(width > 0) || !(height > 0)) throw new Error("Diagram has no usable viewBox");
|
|
27
|
+
root.setAttribute("width", String(width));
|
|
28
|
+
root.setAttribute("height", String(height));
|
|
29
|
+
root.style.removeProperty("max-width");
|
|
30
|
+
if (!root.getAttribute("style")?.trim()) root.removeAttribute("style");
|
|
31
|
+
return { svg: new XMLSerializer().serializeToString(root), width, height };
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** The page background of the diagram's theme; exported PNGs are opaque. */
|
|
35
|
+
export const diagramBackground = (dark: boolean, config?: MermaidConfig): string => {
|
|
36
|
+
const background: unknown = config?.themeVariables?.background;
|
|
37
|
+
return typeof background === "string" ? background : dark ? "#11151b" : "#ffffff";
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const pngScale = (width: number, height: number, devicePixelRatio: number): number =>
|
|
41
|
+
Math.min(Math.max(2, devicePixelRatio), Math.sqrt(MAX_CANVAS_PIXELS / (width * height)));
|
|
42
|
+
|
|
43
|
+
const loadImage = (url: string): Promise<HTMLImageElement> =>
|
|
44
|
+
new Promise((resolve, reject) => {
|
|
45
|
+
const image = new Image();
|
|
46
|
+
image.onload = () => resolve(image);
|
|
47
|
+
image.onerror = () => reject(new Error("Diagram image could not be decoded"));
|
|
48
|
+
image.src = url;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/** Rasterize a standalone SVG through an image sink, so its markup never becomes live DOM. */
|
|
52
|
+
export const rasterizeSvg = async (diagram: StandaloneSvg, options: { background: string; scale: number }): Promise<Blob> => {
|
|
53
|
+
const url = URL.createObjectURL(new Blob([diagram.svg], { type: "image/svg+xml" }));
|
|
54
|
+
try {
|
|
55
|
+
const image = await loadImage(url);
|
|
56
|
+
const canvas = document.createElement("canvas");
|
|
57
|
+
canvas.width = Math.max(1, Math.round(diagram.width * options.scale));
|
|
58
|
+
canvas.height = Math.max(1, Math.round(diagram.height * options.scale));
|
|
59
|
+
const context = canvas.getContext("2d");
|
|
60
|
+
if (!context) throw new Error("Canvas is unavailable");
|
|
61
|
+
context.fillStyle = options.background;
|
|
62
|
+
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
63
|
+
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
|
64
|
+
return await new Promise<Blob>((resolve, reject) =>
|
|
65
|
+
canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error("PNG encoding failed"))), "image/png"),
|
|
66
|
+
);
|
|
67
|
+
} finally {
|
|
68
|
+
URL.revokeObjectURL(url);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** File name base from the note title; `diagram` when the note has none. */
|
|
73
|
+
export const diagramFilename = (title: string | null | undefined): string =>
|
|
74
|
+
(title ?? "")
|
|
75
|
+
.replace(/[\r\n/:*?"<>|\\]/gu, "-")
|
|
76
|
+
.replace(/\s+/gu, " ")
|
|
77
|
+
.trim()
|
|
78
|
+
.slice(0, 200) || "diagram";
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { useLocale } from "@k2b/ui";
|
|
2
|
+
import { type Accessor, type JSX, Match, Switch } from "solid-js";
|
|
3
|
+
import type { MermaidOptions } from "./mermaid";
|
|
4
|
+
import { MermaidViewport } from "./mermaid-viewport";
|
|
5
|
+
import { markdownMessages } from "./messages";
|
|
6
|
+
|
|
7
|
+
export type MermaidPreviewState = { kind: "loading" } | { kind: "ready"; svg: string } | { kind: "error"; message: string };
|
|
8
|
+
|
|
9
|
+
/** Parse rendered Mermaid markup into a fresh element that fits its box without overflowing. */
|
|
10
|
+
const diagramElement = (markup: string): JSX.Element => {
|
|
11
|
+
const host = document.createElement("div");
|
|
12
|
+
host.innerHTML = markup;
|
|
13
|
+
const svg = host.querySelector("svg");
|
|
14
|
+
if (!svg) return host;
|
|
15
|
+
svg.style.maxWidth = "90%";
|
|
16
|
+
svg.style.maxHeight = "90%";
|
|
17
|
+
svg.style.width = "auto";
|
|
18
|
+
svg.style.height = "auto";
|
|
19
|
+
return svg;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Editor preview of one Mermaid block: loading, the zoomable diagram, or the syntax error. */
|
|
23
|
+
export function MermaidEditorPreview(props: {
|
|
24
|
+
state: Accessor<MermaidPreviewState>;
|
|
25
|
+
title: () => string | null;
|
|
26
|
+
exportSvg: () => Promise<string>;
|
|
27
|
+
configuration: MermaidOptions["configuration"];
|
|
28
|
+
locale?: string;
|
|
29
|
+
}): JSX.Element {
|
|
30
|
+
const locale = useLocale();
|
|
31
|
+
const t = () => markdownMessages.resolve([props.locale ?? locale()]).t;
|
|
32
|
+
const svg = () => {
|
|
33
|
+
const state = props.state();
|
|
34
|
+
return state.kind === "ready" ? state.svg : undefined;
|
|
35
|
+
};
|
|
36
|
+
const error = () => {
|
|
37
|
+
const state = props.state();
|
|
38
|
+
return state.kind === "error" ? state.message : undefined;
|
|
39
|
+
};
|
|
40
|
+
return (
|
|
41
|
+
<Switch
|
|
42
|
+
fallback={
|
|
43
|
+
<div class="flex h-full items-center justify-center gap-2 p-4 text-gray-500">
|
|
44
|
+
<i class="ti ti-loader animate-spin" aria-hidden="true" />
|
|
45
|
+
<span class="text-sm">{t().loadingDiagram}</span>
|
|
46
|
+
</div>
|
|
47
|
+
}
|
|
48
|
+
>
|
|
49
|
+
<Match when={svg()}>
|
|
50
|
+
{(markup) => (
|
|
51
|
+
<MermaidViewport
|
|
52
|
+
class="h-full w-full"
|
|
53
|
+
configuration={props.configuration}
|
|
54
|
+
locale={props.locale}
|
|
55
|
+
controls="hover"
|
|
56
|
+
diagram={() => diagramElement(markup())}
|
|
57
|
+
title={props.title()}
|
|
58
|
+
exportSvg={props.exportSvg}
|
|
59
|
+
/>
|
|
60
|
+
)}
|
|
61
|
+
</Match>
|
|
62
|
+
<Match when={error()}>
|
|
63
|
+
{(message) => (
|
|
64
|
+
<div class="flex h-full flex-col items-center justify-center gap-2 p-4 text-red-500">
|
|
65
|
+
<i class="ti ti-alert-circle text-2xl" aria-hidden="true" />
|
|
66
|
+
<span class="text-sm font-mono">{t().invalidMermaid}</span>
|
|
67
|
+
<details class="text-xs text-gray-500 max-w-full">
|
|
68
|
+
<summary class="cursor-pointer hover:text-gray-700 dark:hover:text-gray-300">{t().showErrorDetails}</summary>
|
|
69
|
+
<pre class="mt-2 p-2 bg-gray-100 dark:bg-gray-800 rounded text-left overflow-x-auto">{message()}</pre>
|
|
70
|
+
</details>
|
|
71
|
+
</div>
|
|
72
|
+
)}
|
|
73
|
+
</Match>
|
|
74
|
+
</Switch>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Mermaid, MermaidConfig } from "mermaid";
|
|
2
|
+
|
|
3
|
+
let pending = Promise.resolve();
|
|
4
|
+
|
|
5
|
+
/** Mermaid reads global config during rendering, so configure and render as one queued operation. */
|
|
6
|
+
export function renderMermaid(
|
|
7
|
+
mermaid: Pick<Mermaid, "initialize" | "render">,
|
|
8
|
+
config: MermaidConfig,
|
|
9
|
+
id: string,
|
|
10
|
+
source: string,
|
|
11
|
+
container?: Element,
|
|
12
|
+
) {
|
|
13
|
+
const result = pending.then(() => {
|
|
14
|
+
mermaid.initialize(config);
|
|
15
|
+
return mermaid.render(id, source, container);
|
|
16
|
+
});
|
|
17
|
+
pending = result.then(
|
|
18
|
+
() => undefined,
|
|
19
|
+
() => undefined,
|
|
20
|
+
);
|
|
21
|
+
return result;
|
|
22
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { i18n } from "@k2b/stdlib";
|
|
2
|
+
import { files } from "@k2b/stdlib/browser";
|
|
3
|
+
import { toast, useLocale, ZoomPanViewport } from "@k2b/ui";
|
|
4
|
+
import type { JSX } from "solid-js";
|
|
5
|
+
import type { MermaidOptions } from "./mermaid";
|
|
6
|
+
import { diagramBackground, diagramFilename, pngScale, rasterizeSvg, standaloneSvg } from "./mermaid-export";
|
|
7
|
+
|
|
8
|
+
export const mermaidViewportMessages = i18n.define({
|
|
9
|
+
baseLocale: "en",
|
|
10
|
+
messages: {
|
|
11
|
+
en: {
|
|
12
|
+
diagram: "Diagram",
|
|
13
|
+
downloadSvg: "Download SVG",
|
|
14
|
+
downloadPng: "Download PNG",
|
|
15
|
+
exportFailed: "The diagram could not be exported.",
|
|
16
|
+
},
|
|
17
|
+
de: {
|
|
18
|
+
diagram: "Diagramm",
|
|
19
|
+
downloadSvg: "SVG herunterladen",
|
|
20
|
+
downloadPng: "PNG herunterladen",
|
|
21
|
+
exportFailed: "Das Diagramm konnte nicht exportiert werden.",
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export type MermaidViewportProps = {
|
|
27
|
+
/** Returns a new diagram node on every call: once inline, once per fullscreen opening. */
|
|
28
|
+
diagram: () => JSX.Element;
|
|
29
|
+
/** Note title for the fullscreen heading and the export file names. */
|
|
30
|
+
title?: string | null;
|
|
31
|
+
/** Rendered Mermaid SVG markup without HTML labels, so it can be rasterized. */
|
|
32
|
+
exportSvg: () => string | Promise<string>;
|
|
33
|
+
controls?: "visible" | "hover";
|
|
34
|
+
class?: string;
|
|
35
|
+
configuration?: MermaidOptions["configuration"];
|
|
36
|
+
locale?: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** A Mermaid diagram that can be zoomed, panned, opened fullscreen, and exported as SVG or PNG. */
|
|
40
|
+
export function MermaidViewport(props: MermaidViewportProps): JSX.Element {
|
|
41
|
+
const locale = useLocale();
|
|
42
|
+
const t = () => mermaidViewportMessages.resolve([props.locale ?? locale()]).t;
|
|
43
|
+
const exporting = (format: "svg" | "png") => async () => {
|
|
44
|
+
try {
|
|
45
|
+
const diagram = standaloneSvg(await props.exportSvg());
|
|
46
|
+
if (format === "svg") {
|
|
47
|
+
files.downloadFileFromContent(diagram.svg, `${diagramFilename(props.title)}.svg`, "image/svg+xml");
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const dark = document.documentElement.classList.contains("dark");
|
|
51
|
+
const png = await rasterizeSvg(diagram, {
|
|
52
|
+
background: diagramBackground(dark, props.configuration?.({ dark })),
|
|
53
|
+
scale: pngScale(diagram.width, diagram.height, window.devicePixelRatio || 1),
|
|
54
|
+
});
|
|
55
|
+
files.downloadFileFromContent(png, `${diagramFilename(props.title)}.png`, "image/png");
|
|
56
|
+
} catch (error) {
|
|
57
|
+
console.error("Mermaid export failed", error);
|
|
58
|
+
toast.error(t().exportFailed);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
return (
|
|
62
|
+
<ZoomPanViewport
|
|
63
|
+
class={props.class}
|
|
64
|
+
label={t().diagram}
|
|
65
|
+
controls={props.controls}
|
|
66
|
+
fullscreen={{
|
|
67
|
+
title: props.title?.trim() || t().diagram,
|
|
68
|
+
content: props.diagram,
|
|
69
|
+
actions: [
|
|
70
|
+
{ label: t().downloadSvg, icon: "ti ti-file-vector", onSelect: exporting("svg") },
|
|
71
|
+
{ label: t().downloadPng, icon: "ti ti-photo-down", onSelect: exporting("png") },
|
|
72
|
+
],
|
|
73
|
+
}}
|
|
74
|
+
>
|
|
75
|
+
{props.diagram()}
|
|
76
|
+
</ZoomPanViewport>
|
|
77
|
+
);
|
|
78
|
+
}
|