@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.
Files changed (83) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +31 -2
  3. package/package.json +118 -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,242 @@
1
+ import { syntaxTree } from "@codemirror/language";
2
+ import type { EditorState, Extension, Range } from "@codemirror/state";
3
+ import { RangeSet } from "@codemirror/state";
4
+ import { Decoration, type EditorView, WidgetType } from "@codemirror/view";
5
+ import mermaid, { type MermaidConfig } from "mermaid";
6
+ import { createComponent, createSignal } from "solid-js";
7
+ import { render } from "solid-js/web";
8
+ import {
9
+ blockWidgetLineNavigationExtension,
10
+ type CursorZoneState,
11
+ cursorZoneStateField,
12
+ selectionIntersectsRange,
13
+ } from "./_lib/cursor-zone-field";
14
+ import { codeFenceLanguage } from "./lib/markdown-fences";
15
+ import { MermaidEditorPreview, type MermaidPreviewState } from "./mermaid-preview";
16
+ import { renderMermaid } from "./mermaid-render";
17
+
18
+ const isDarkTheme = () => document.documentElement.classList.contains("dark");
19
+
20
+ /** Cached SVGs carry theme colors, so a theme switch must not reuse them. */
21
+ const svgCacheKey = (code: string) => `${isDarkTheme() ? "dark" : "light"}\n${code}`;
22
+
23
+ export type MermaidOptions = {
24
+ /** The host's rendering policy and theme colors. */
25
+ configuration: (options: { dark: boolean }) => MermaidConfig;
26
+ /** Heading and file name used by the fullscreen/export controls. */
27
+ title?: (markdown: string) => string | null;
28
+ /** Locale of detached widget roots; omitted uses the inherited UI locale. */
29
+ locale?: string;
30
+ };
31
+
32
+ const svgCaches = new WeakMap<MermaidOptions["configuration"], Map<string, { svg: string; timestamp: number }>>();
33
+ const SVG_CACHE_MAX = 50;
34
+
35
+ /** Single-pass min-by-timestamp over the cache. The previous
36
+ * `Array.from(...).sort()[0]` allocated + sorted the whole map
37
+ * on every overflow; this is O(n) with no allocation. */
38
+ const evictOldestSvg = (globalSvgCache: Map<string, { svg: string; timestamp: number }>): void => {
39
+ let oldestKey: string | undefined;
40
+ let oldestTs = Number.POSITIVE_INFINITY;
41
+ for (const [key, entry] of globalSvgCache) {
42
+ if (entry.timestamp < oldestTs) {
43
+ oldestTs = entry.timestamp;
44
+ oldestKey = key;
45
+ }
46
+ }
47
+ if (oldestKey !== undefined) globalSvgCache.delete(oldestKey);
48
+ };
49
+
50
+ interface MermaidBlockParams {
51
+ code: string;
52
+ id: string;
53
+ fromPos: number;
54
+ }
55
+
56
+ class MermaidWidget extends WidgetType {
57
+ private code: string;
58
+ private id: string;
59
+ private fromPos: number;
60
+ private renderTimer: ReturnType<typeof setTimeout> | null = null;
61
+ private dispose?: () => void;
62
+
63
+ constructor(
64
+ { code, id, fromPos }: MermaidBlockParams,
65
+ private options: MermaidOptions,
66
+ private globalSvgCache: Map<string, { svg: string; timestamp: number }>,
67
+ ) {
68
+ super();
69
+ this.code = code;
70
+ this.id = `mermaid-${id}`;
71
+ this.fromPos = fromPos;
72
+ }
73
+
74
+ override eq(other: MermaidWidget) {
75
+ return other.code === this.code && other.fromPos === this.fromPos;
76
+ }
77
+
78
+ override toDOM(view: EditorView) {
79
+ const container = document.createElement("div");
80
+ container.className = "cm-mermaid-widget !m-0";
81
+ container.setAttribute("contenteditable", "false");
82
+ const edit = () => {
83
+ view.dispatch({ selection: { anchor: this.fromPos }, scrollIntoView: true });
84
+ view.focus();
85
+ };
86
+ // At fit a press edits the source, as it always has. Zoomed in, a press
87
+ // may start a pan, so only a click without dragging edits.
88
+ const zoomedIn = () => container.querySelector(".k2b-zoom-pan[data-zoomed]") !== null;
89
+ const onControl = (event: Event) => event.target instanceof Element && event.target.closest("button") !== null;
90
+ container.onmousedown = (event) => {
91
+ if (onControl(event)) return;
92
+ event.preventDefault();
93
+ event.stopPropagation();
94
+ if (!zoomedIn()) edit();
95
+ };
96
+ container.onclick = (event) => {
97
+ if (!onControl(event) && zoomedIn()) edit();
98
+ };
99
+
100
+ const wrapper = document.createElement("div");
101
+ wrapper.className = "rounded border border-[var(--ui-border)] bg-[var(--ui-surface)] overflow-hidden";
102
+ wrapper.style.height = "min(30vh, 400px)";
103
+ wrapper.style.minHeight = "200px";
104
+
105
+ const cached = this.globalSvgCache.get(svgCacheKey(this.code));
106
+ // Cache hit: show the SVG straight away and skip mermaid.render. The key is
107
+ // the theme plus `this.code`, so a re-render would produce the same SVG.
108
+ // Bump the timestamp so the LRU reflects "recently used".
109
+ if (cached) cached.timestamp = Date.now();
110
+ const [state, setState] = createSignal<MermaidPreviewState>(cached ? { kind: "ready", svg: cached.svg } : { kind: "loading" });
111
+ this.dispose = render(
112
+ () =>
113
+ createComponent(MermaidEditorPreview, {
114
+ state,
115
+ title: () => this.options.title?.(view.state.doc.toString()) ?? null,
116
+ configuration: this.options.configuration,
117
+ locale: this.options.locale,
118
+ exportSvg: () => this.renderExportSvg(),
119
+ }),
120
+ wrapper,
121
+ );
122
+ // Only schedule the expensive mermaid.render (~50–200ms Dagre layout)
123
+ // when there is nothing cached to display.
124
+ if (!cached) this.debouncedRender(setState);
125
+
126
+ container.appendChild(wrapper);
127
+ return container;
128
+ }
129
+
130
+ /** Called by CM when the widget's DOM is being removed (user
131
+ * deleted the code block, scrolled it out of the viewport, or
132
+ * the doc changed enough to invalidate the decoration). Without
133
+ * this, the 500ms debounced timer keeps a reference to the
134
+ * detached element and renders into a node that is no longer in
135
+ * the document — wasted work that piles up on rapid edits. */
136
+ override destroy(_dom: HTMLElement) {
137
+ if (this.renderTimer) {
138
+ clearTimeout(this.renderTimer);
139
+ this.renderTimer = null;
140
+ }
141
+ this.dispose?.();
142
+ this.dispose = undefined;
143
+ }
144
+
145
+ private debouncedRender(setState: (state: MermaidPreviewState) => void) {
146
+ if (this.renderTimer) clearTimeout(this.renderTimer);
147
+
148
+ this.renderTimer = setTimeout(() => {
149
+ this.renderTimer = null;
150
+ void this.renderDiagram(setState);
151
+ }, 500);
152
+ }
153
+
154
+ private async renderDiagram(setState: (state: MermaidPreviewState) => void) {
155
+ try {
156
+ const cacheKey = svgCacheKey(this.code);
157
+ const renderId = `${this.id}-${Date.now()}`;
158
+ const { svg } = await renderMermaid(mermaid, this.options.configuration({ dark: isDarkTheme() }), renderId, this.code);
159
+
160
+ this.globalSvgCache.set(cacheKey, { svg, timestamp: Date.now() });
161
+
162
+ if (this.globalSvgCache.size > SVG_CACHE_MAX) evictOldestSvg(this.globalSvgCache);
163
+
164
+ if (this.dispose) setState({ kind: "ready", svg });
165
+ } catch (error) {
166
+ if (this.dispose) setState({ kind: "error", message: error instanceof Error ? error.message : String(error) });
167
+ }
168
+ }
169
+
170
+ /** Exports need SVG text labels: HTML labels (foreignObject) taint a canvas and
171
+ * render poorly outside a browser. */
172
+ private async renderExportSvg(): Promise<string> {
173
+ const config = this.options.configuration({ dark: isDarkTheme() });
174
+ return (await renderMermaid(mermaid, { ...config, htmlLabels: false }, `${this.id}-export-${Date.now()}`, this.code)).svg;
175
+ }
176
+
177
+ override ignoreEvent() {
178
+ return true;
179
+ }
180
+
181
+ override get estimatedHeight() {
182
+ // The frame is min(30vh, 400px), with a 200px minimum; it includes its border.
183
+ return Math.max(200, Math.min(window.innerHeight * 0.3, 400));
184
+ }
185
+ }
186
+
187
+ export const mermaidExtension = (options: MermaidOptions): Extension => {
188
+ let globalSvgCache = svgCaches.get(options.configuration);
189
+ if (!globalSvgCache) {
190
+ globalSvgCache = new Map();
191
+ svgCaches.set(options.configuration, globalSvgCache);
192
+ }
193
+ const cache = globalSvgCache;
194
+ const decorate = (state: EditorState): CursorZoneState => {
195
+ const decorations: Range<Decoration>[] = [];
196
+ const atomicDecorations: Range<Decoration>[] = [];
197
+ const ranges: { from: number; to: number }[] = [];
198
+ const cursor = state.selection.main;
199
+ let widgetId = 0;
200
+
201
+ syntaxTree(state).iterate({
202
+ enter: ({ type, from, to }) => {
203
+ if (type.name === "FencedCode") {
204
+ const text = state.doc.sliceString(from, to);
205
+ const lines = text.split("\n");
206
+ const language = codeFenceLanguage(lines[0] ?? "");
207
+
208
+ if (language === "mermaid") {
209
+ ranges.push({ from, to });
210
+ if (selectionIntersectsRange(cursor, from, to)) return false;
211
+ const code = lines.slice(1, -1).join("\n");
212
+ const decoration = Decoration.replace({
213
+ widget: new MermaidWidget(
214
+ {
215
+ code,
216
+ id: `${from}-${widgetId++}`,
217
+ fromPos: from,
218
+ },
219
+ options,
220
+ cache,
221
+ ),
222
+ block: true,
223
+ }).range(from, to);
224
+ decorations.push(decoration);
225
+ atomicDecorations.push(decoration);
226
+ }
227
+ }
228
+ },
229
+ });
230
+
231
+ return {
232
+ decorations: decorations.length > 0 ? RangeSet.of(decorations, true) : Decoration.none,
233
+ atomicDecorations: atomicDecorations.length > 0 ? RangeSet.of(atomicDecorations, true) : Decoration.none,
234
+ ranges,
235
+ hasSyntax: ranges.length > 0,
236
+ };
237
+ };
238
+
239
+ const mermaidField = cursorZoneStateField(decorate);
240
+
241
+ return [mermaidField, blockWidgetLineNavigationExtension(mermaidField, (value) => value.atomicDecorations)];
242
+ };
@@ -0,0 +1,17 @@
1
+ import { i18n } from "@k2b/stdlib";
2
+
3
+ export const markdownMessages = i18n.define({
4
+ baseLocale: "en",
5
+ messages: {
6
+ en: {
7
+ invalidMermaid: "Invalid Mermaid syntax",
8
+ showErrorDetails: "Show error details",
9
+ loadingDiagram: "Loading diagram...",
10
+ },
11
+ de: {
12
+ invalidMermaid: "Ungültige Mermaid-Syntax",
13
+ showErrorDetails: "Fehlerdetails anzeigen",
14
+ loadingDiagram: "Diagramm wird geladen…",
15
+ },
16
+ },
17
+ });
@@ -0,0 +1,84 @@
1
+ import { type Extension, type Range, RangeSet } from "@codemirror/state";
2
+ import { Decoration, EditorView, WidgetType } from "@codemirror/view";
3
+ import {
4
+ blockWidgetLineNavigationExtension,
5
+ type CursorZoneState,
6
+ cursorZoneStateField,
7
+ selectionIntersectsRange,
8
+ } from "./_lib/cursor-zone-field";
9
+ import { extractNamedBlocks } from "./lib/named-blocks";
10
+
11
+ class NamedBlockHandleWidget extends WidgetType {
12
+ constructor(
13
+ private name: string,
14
+ private fromPos: number,
15
+ ) {
16
+ super();
17
+ }
18
+
19
+ override toDOM(view: EditorView) {
20
+ const el = document.createElement("div");
21
+ el.className = "cm-named-block-handle md-block-handle";
22
+ el.setAttribute("contenteditable", "false");
23
+ el.textContent = `@${this.name}`;
24
+ el.onmousedown = (event) => {
25
+ event.preventDefault();
26
+ event.stopPropagation();
27
+ view.dispatch({ selection: { anchor: this.fromPos }, scrollIntoView: true });
28
+ view.focus();
29
+ };
30
+ return el;
31
+ }
32
+
33
+ override eq(other: WidgetType) {
34
+ return other instanceof NamedBlockHandleWidget && other.name === this.name && other.fromPos === this.fromPos;
35
+ }
36
+
37
+ override ignoreEvent() {
38
+ return true;
39
+ }
40
+ }
41
+
42
+ export const namedBlocksExtension = (): Extension => {
43
+ const stateField = cursorZoneStateField((state): CursorZoneState => {
44
+ const decorations: Range<Decoration>[] = [];
45
+ const atomicDecorations: Range<Decoration>[] = [];
46
+ const ranges: { from: number; to: number }[] = [];
47
+ const cursor = state.selection.main;
48
+ const text = state.doc.toString();
49
+
50
+ for (const block of extractNamedBlocks(text)) {
51
+ const prevLine = state.doc.lineAt(Math.max(block.handleStart - 1, 0));
52
+ const nextLine = state.doc.lineAt(Math.min(block.handleEnd + 1, state.doc.length));
53
+ const sourceVisibleRange = { from: prevLine.from, to: nextLine.to };
54
+ ranges.push(sourceVisibleRange);
55
+ // Table/data handles are rendered by their block widgets together
56
+ // with the preview so the handle and body collapse as one stable block.
57
+ if (block.type === "table" || block.type === "data") continue;
58
+ if (selectionIntersectsRange(cursor, sourceVisibleRange.from, sourceVisibleRange.to)) continue;
59
+ const handleDecoration = Decoration.replace({
60
+ widget: new NamedBlockHandleWidget(block.name, block.handleStart),
61
+ block: true,
62
+ }).range(block.handleStart, block.handleEnd);
63
+ decorations.push(handleDecoration);
64
+ atomicDecorations.push(handleDecoration);
65
+ }
66
+
67
+ return {
68
+ decorations: decorations.length > 0 ? RangeSet.of(decorations, true) : Decoration.none,
69
+ atomicDecorations: atomicDecorations.length > 0 ? RangeSet.of(atomicDecorations, true) : Decoration.none,
70
+ ranges,
71
+ hasSyntax: decorations.length > 0,
72
+ };
73
+ });
74
+
75
+ return [
76
+ stateField,
77
+ blockWidgetLineNavigationExtension(stateField, (value) => value.atomicDecorations),
78
+ EditorView.theme({
79
+ ".cm-named-block-handle": {
80
+ margin: "0 !important",
81
+ },
82
+ }),
83
+ ];
84
+ };
@@ -0,0 +1,84 @@
1
+ import { EditorState, type Range, StateField } from "@codemirror/state";
2
+ import { Decoration, type DecorationSet, EditorView, WidgetType } from "@codemirror/view";
3
+ import { noteScroller } from "./note-scroller";
4
+
5
+ // The smallest note page that reproduces the WebKit jump: a page scroller under
6
+ // a size container, CodeMirror inside it, and a block widget that parses an SVG
7
+ // `viewBox` while CodeMirror builds the lines it scrolled to, as the notice icon does.
8
+
9
+ class IconWidget extends WidgetType {
10
+ override eq() {
11
+ return true;
12
+ }
13
+
14
+ override toDOM() {
15
+ const element = document.createElement("div");
16
+ element.style.height = "60px";
17
+ // Parsed from markup like the notice card's icon; WebKit lays the page out while it parses the viewBox.
18
+ element.innerHTML = '<svg viewBox="0 0 24 24" width="20" height="20"></svg>';
19
+ return element;
20
+ }
21
+
22
+ override get estimatedHeight() {
23
+ return 60;
24
+ }
25
+ }
26
+
27
+ const icons = (state: EditorState): DecorationSet => {
28
+ const ranges: Range<Decoration>[] = [];
29
+ for (const match of state.doc.toString().matchAll(/^:::icon$/gm))
30
+ ranges.push(Decoration.replace({ widget: new IconWidget(), block: true }).range(match.index, match.index + match[0].length));
31
+ return Decoration.set(ranges);
32
+ };
33
+
34
+ const iconField = StateField.define<DecorationSet>({
35
+ create: icons,
36
+ update: (value, tr) => (tr.docChanged ? icons(tr.state) : value),
37
+ provide: (field) => EditorView.decorations.from(field),
38
+ });
39
+
40
+ const doc = Array.from({ length: 6 }, (_, block) =>
41
+ [
42
+ ...Array.from({ length: 35 }, (_, line) => `Paragraph ${block + 1}.${line + 1}`),
43
+ ":::icon",
44
+ `Anchor ${block + 1}`,
45
+ ...Array.from({ length: 35 }, (_, line) => `Following ${block + 1}.${line + 1}`),
46
+ ].join("\n\n"),
47
+ ).join("\n\n");
48
+
49
+ const port = document.getElementById("port")!;
50
+ const view = new EditorView({
51
+ parent: port,
52
+ state: EditorState.create({ doc, extensions: [iconField, noteScroller, EditorView.theme({ ".cm-line": { lineHeight: "20px" } })] }),
53
+ });
54
+
55
+ const frame = () => new Promise((resolve) => requestAnimationFrame(resolve));
56
+
57
+ const harness = {
58
+ settle: async () => {
59
+ for (let index = 0; index < 5; index++) await frame();
60
+ },
61
+ /** Scrolls the page so the line sits in the middle, then reports where the page scroller ends up. */
62
+ scrollTo: async (text: string) => {
63
+ const from = view.state.doc.toString().indexOf(text);
64
+ const top = view.documentTop + view.lineBlockAt(from).top - port.getBoundingClientRect().top;
65
+ const target = port.scrollTop + top - port.clientHeight / 2;
66
+ port.scrollTop = target;
67
+ const written = port.scrollTop;
68
+ for (let index = 0; index < 4; index++) await frame();
69
+ return { written, scrollTop: port.scrollTop };
70
+ },
71
+ /** Deletes the start of the note while the focused editor sits at the bottom, as a collaborator's edit arrives. */
72
+ deleteAboveAtBottom: async (length: number) => {
73
+ view.focus();
74
+ port.scrollTop = port.scrollHeight;
75
+ for (let index = 0; index < 4; index++) await frame();
76
+ const before = port.scrollHeight - port.clientHeight - port.scrollTop;
77
+ view.dispatch({ changes: { from: 0, to: length } });
78
+ for (let index = 0; index < 4; index++) await frame();
79
+ return { before, after: port.scrollHeight - port.clientHeight - port.scrollTop };
80
+ },
81
+ };
82
+
83
+ export type NoteScrollerHarness = typeof harness;
84
+ Object.assign(window, { harness });
@@ -0,0 +1,57 @@
1
+ import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
2
+
3
+ /**
4
+ * The note page's arrangement: CodeMirror grows inside the page's scroll area
5
+ * and insets the text by 1rem, also on a phone. The reading column centers in
6
+ * the rest.
7
+ */
8
+ const noteScrollerTheme = EditorView.theme({
9
+ ".cm-editor": { minHeight: "100%" },
10
+ ".cm-scroller": { width: "100%", minHeight: "100%", padding: "1rem" },
11
+ // The page scrolls, not CodeMirror. Clipping sideways makes the scroller no
12
+ // scroll container, so CodeMirror cannot shift the text sideways to follow
13
+ // a caret behind spaces that hang past the column (see `richFontCSS`).
14
+ "&.cm-editor .cm-scroller": { overflowX: "clip", overflowY: "visible" },
15
+ });
16
+
17
+ /**
18
+ * CodeMirror builds the lines it scrolled to in several DOM steps and locks the
19
+ * content's height only for the last one. A layout between the earlier steps
20
+ * sees a note shorter than its height map, and the page scroller clamps its
21
+ * position to that height. WebKit runs such a layout when a widget parses an
22
+ * SVG `viewBox` or path (the notice icon does) while the page has a size
23
+ * container, which every app area is. This holds the height for the whole DOM
24
+ * update. WebKit's layout during the update uses the content's style from
25
+ * before it, so the lock is resolved at once; releasing it with the update
26
+ * keeps CodeMirror's own measuring and scroll correction as they were.
27
+ */
28
+ const lockHeightWhileRendering = ViewPlugin.fromClass(
29
+ class {
30
+ constructor(private readonly view: EditorView) {}
31
+
32
+ // Plugins update right before CodeMirror's DOM update, with the height it knows from its height map.
33
+ // CSS pixels, as in CodeMirror's own lock.
34
+ update({ view }: ViewUpdate) {
35
+ const content = view.contentDOM;
36
+ content.style.minHeight = `${view.contentHeight / view.scaleY}px`;
37
+ void getComputedStyle(content).minHeight;
38
+ // An update that draws nothing ends without `docViewUpdate`.
39
+ queueMicrotask(() => this.release());
40
+ }
41
+
42
+ docViewUpdate() {
43
+ this.release();
44
+ }
45
+
46
+ destroy() {
47
+ this.release();
48
+ }
49
+
50
+ private release() {
51
+ this.view.contentDOM.style.removeProperty("min-height");
52
+ }
53
+ },
54
+ );
55
+
56
+ /** The page scrolls the note; CodeMirror only renders it. */
57
+ export const noteScroller = [noteScrollerTheme, lockHeightWhileRendering];
@@ -0,0 +1,142 @@
1
+ import { i18n } from "@k2b/stdlib";
2
+ import { CopyButton, IconButton, LocaleProvider, prepareChartSnapshot, toast } from "@k2b/ui";
3
+ import { type ChartTooltip, inspectChart } from "@k2b/ui/chart-inspection";
4
+ import { createSignal, onCleanup, Show } from "solid-js";
5
+ import { render } from "solid-js/web";
6
+ import { type ChartInput, type ChartTable, chartMarkKey, chartReferenceMark, chartRenderOptions, chartRows, chartTooltip } from "../charts";
7
+ import { chartTableText } from "./chart-copy";
8
+ import { type MarkdownChartOptions, notebookChartTableHtml } from "./lib/chart-blocks";
9
+
10
+ export const notebookChartMessages = i18n.define({
11
+ baseLocale: "en",
12
+ messages: {
13
+ en: {
14
+ interactiveChart: "Interactive chart. Use arrow keys to read values and Escape to close.",
15
+ showTable: "Show as table",
16
+ copyData: "Copy data",
17
+ copyFailed: "Could not copy the chart data. Try again or select the values in the table.",
18
+ },
19
+ de: {
20
+ interactiveChart: "Interaktives Diagramm. Mit den Pfeiltasten Werte lesen, mit Escape schließen.",
21
+ showTable: "Als Tabelle",
22
+ copyData: "Daten kopieren",
23
+ copyFailed: "Die Daten konnten nicht kopiert werden. Versuche es erneut oder markiere die Werte in der Tabelle.",
24
+ },
25
+ },
26
+ });
27
+
28
+ type Drawn = { tooltips: Map<string, ChartTooltip>; table: ChartTable };
29
+
30
+ const readInput = (figure: HTMLElement, options: Pick<MarkdownChartOptions, "schema">): ChartInput | null => {
31
+ try {
32
+ const result = options.schema.safeParse(JSON.parse(figure.dataset.chart ?? "null"));
33
+ return result.success ? result.data : null;
34
+ } catch {
35
+ return null;
36
+ }
37
+ };
38
+
39
+ /** The same drawing as the server's (`renderNotebookChartFigure`), for the tooltips and table of its marks. */
40
+ const draw = (input: ChartInput, locale: string): Drawn => {
41
+ const rows = chartRows(input, locale);
42
+ const snapshot = prepareChartSnapshot(chartRenderOptions(input, locale, { legend: "outside" }), {
43
+ key: ({ datum }) => chartMarkKey(datum),
44
+ tooltip: ({ datum }) => {
45
+ const row = rows.add(datum);
46
+ return chartTooltip(rows.columns(), row);
47
+ },
48
+ reference: ({ datum }) => chartReferenceMark(input, datum),
49
+ });
50
+ return { tooltips: new Map(snapshot.marks.map((mark) => [mark.key, mark.tooltip])), table: rows.table() };
51
+ };
52
+
53
+ function ChartControls(props: { figure: HTMLElement; viewport: HTMLElement; drawn: () => Drawn; locale: string; onView: () => void }) {
54
+ const t = () => notebookChartMessages.resolve([props.locale]).t;
55
+ const [table, setTable] = createSignal(false);
56
+ let host: HTMLElement | undefined;
57
+ const toggle = () => {
58
+ const next = !table();
59
+ props.onView();
60
+ host?.remove();
61
+ host = undefined;
62
+ if (next) {
63
+ // The note's own table markup, as the PDF prints it below the figure.
64
+ const label = props.figure.querySelector(".notebook-book-chart__title")?.textContent ?? "";
65
+ const scratch = document.createElement("div");
66
+ scratch.innerHTML = notebookChartTableHtml(props.drawn().table, { label, className: "notebook-book-chart__table" });
67
+ const element = scratch.firstElementChild;
68
+ if (!(element instanceof HTMLElement)) return;
69
+ // The table scrolls inside the chart's frame; as the drawing's tab stop is hidden, the scroll area takes it.
70
+ element.tabIndex = 0;
71
+ element.setAttribute("role", "region");
72
+ element.setAttribute("aria-label", label);
73
+ host = element;
74
+ props.viewport.append(host);
75
+ props.figure.dataset.view = "table";
76
+ } else delete props.figure.dataset.view;
77
+ setTable(next);
78
+ };
79
+ onCleanup(() => host?.remove());
80
+ return (
81
+ <LocaleProvider locale={props.locale}>
82
+ <CopyButton
83
+ text={chartTableText(props.drawn().table)}
84
+ label={t().copyData}
85
+ iconOnly
86
+ size="sm"
87
+ variant="ghost"
88
+ onCopyError={() => toast.error(t().copyFailed)}
89
+ />
90
+ <IconButton label={t().showTable} size="sm" variant="ghost" aria-pressed={table()} onClick={toggle}>
91
+ <Show when={table()} fallback={<i class="ti ti-table" aria-hidden="true" />}>
92
+ <i class="ti ti-chart-bar" aria-hidden="true" />
93
+ </Show>
94
+ </IconButton>
95
+ </LocaleProvider>
96
+ );
97
+ }
98
+
99
+ /**
100
+ * Adds the shared chart inspection (tooltips by pointer, tap and keyboard), the table view and Copy data to every
101
+ * server-drawn `:::chart` figure under `root`. The server's SVG stays in place, and the controls fill the box the
102
+ * frame reserves for them, so nothing moves. A figure without valid chart data keeps its static drawing, whose marks
103
+ * still carry their values as SVG titles. Returns the cleanup that restores the static figures.
104
+ */
105
+ export function enhanceNotebookCharts(root: HTMLElement, locale: string, options: Pick<MarkdownChartOptions, "schema">): () => void {
106
+ const cleanups: (() => void)[] = [];
107
+ for (const figure of root.querySelectorAll<HTMLElement>("figure.notebook-book-chart[data-chart]:not([data-enhanced])")) {
108
+ const chart = figure.querySelector<HTMLElement>(".notebook-book-chart__viewport > .k2b-chart");
109
+ const viewport = chart?.parentElement;
110
+ const actions = figure.querySelector<HTMLElement>(".notebook-book-chart__actions");
111
+ const kind = chart?.dataset.chartKind;
112
+ const input = readInput(figure, options);
113
+ if (!chart || !viewport || !actions || !input || kind !== input.kind) continue;
114
+ let drawn: Drawn | undefined;
115
+ // The drawing waits for the first tooltip, table or copy: a note can hold many charts nobody inspects.
116
+ const lazy = () => (drawn ??= draw(input, locale));
117
+ const t = notebookChartMessages.resolve([locale]).t;
118
+ // The group is named by its title; the description tells how to read its values.
119
+ chart.setAttribute("role", "group");
120
+ chart.setAttribute("aria-label", figure.querySelector(".notebook-book-chart__title")?.textContent ?? "");
121
+ chart.setAttribute("aria-description", t.interactiveChart);
122
+ chart.dataset.interactive = "true";
123
+ const inspection = inspectChart(chart, {
124
+ kind: input.kind,
125
+ format: ({ datum }) => lazy().tooltips.get(chartMarkKey(datum)) ?? { rows: [] },
126
+ });
127
+ const dispose = render(
128
+ () => <ChartControls figure={figure} viewport={viewport} drawn={lazy} locale={locale} onView={inspection.close} />,
129
+ actions,
130
+ );
131
+ figure.dataset.enhanced = "true";
132
+ cleanups.push(() => {
133
+ dispose();
134
+ inspection.destroy();
135
+ // The inspector removes its geometry properties, but the browser keeps an empty style attribute.
136
+ if (!chart.getAttribute("style")) chart.removeAttribute("style");
137
+ for (const name of ["role", "aria-label", "aria-description", "data-interactive"]) chart.removeAttribute(name);
138
+ for (const name of ["data-enhanced", "data-view"]) figure.removeAttribute(name);
139
+ });
140
+ }
141
+ return () => cleanups.splice(0).forEach((cleanup) => cleanup());
142
+ }
@@ -0,0 +1,33 @@
1
+ import { i18n } from "@k2b/stdlib";
2
+
3
+ export const prettyTableMessages = i18n.define({
4
+ baseLocale: "en",
5
+ messages: {
6
+ en: {
7
+ showNotesWithTag: ({ tag }: { tag: string }) => `Show notes with #${tag}`,
8
+ suggestion: ({ value }: { value: string }) => `Suggestion: ${value}`,
9
+ formulaName: "Formula",
10
+ parseError: "Invalid formula",
11
+ unknownFunction: ({ name }: { name: string }) => `Unknown function “${name}”`,
12
+ unknownColumn: ({ name }: { name: string }) => `Unknown column “${name}”`,
13
+ wrongArgumentCount: ({ name }: { name: string }) => `${name} received the wrong number of arguments`,
14
+ nonNumeric: ({ name }: { name: string }) => `${name} requires a numeric value`,
15
+ divisionByZero: ({ name }: { name: string }) => `${name} cannot divide by zero`,
16
+ typeError: ({ name }: { name: string }) => `${name} received an incompatible value`,
17
+ circularReference: ({ name }: { name: string }) => `Circular reference involving “${name}”`,
18
+ },
19
+ de: {
20
+ showNotesWithTag: ({ tag }) => `Notizen mit #${tag} anzeigen`,
21
+ suggestion: ({ value }) => `Vorschlag: ${value}`,
22
+ formulaName: "Die Formel",
23
+ parseError: "Ungültige Formel",
24
+ unknownFunction: ({ name }) => `Unbekannte Funktion „${name}“`,
25
+ unknownColumn: ({ name }) => `Unbekannte Spalte „${name}“`,
26
+ wrongArgumentCount: ({ name }) => `${name} hat die falsche Anzahl an Argumenten erhalten`,
27
+ nonNumeric: ({ name }) => `${name} benötigt einen Zahlenwert`,
28
+ divisionByZero: ({ name }) => `${name} kann nicht durch null teilen`,
29
+ typeError: ({ name }) => `${name} hat einen ungeeigneten Wert erhalten`,
30
+ circularReference: ({ name }) => `Zirkelbezug mit „${name}“`,
31
+ },
32
+ },
33
+ });