@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,192 @@
1
+ import { syntaxTree } from "@codemirror/language";
2
+ import type { EditorState, Extension, Range } from "@codemirror/state";
3
+ import { RangeSet } from "@codemirror/state";
4
+ import { Decoration, EditorView, WidgetType } from "@codemirror/view";
5
+ import {
6
+ blockWidgetLineNavigationExtension,
7
+ type CursorZoneState,
8
+ cursorZoneStateField,
9
+ selectionIntersectsRange,
10
+ } from "./_lib/cursor-zone-field";
11
+ import { parseSizedMarkdownImage } from "./lib/markdown-image";
12
+ import { isSafeMarkdownUrl, type MarkdownUrlOptions } from "./urls";
13
+
14
+ class ImageWidget extends WidgetType {
15
+ constructor(
16
+ private url: string,
17
+ private alt: string,
18
+ private width: string | null,
19
+ private height: string | null,
20
+ private title: string | null,
21
+ private options: MarkdownUrlOptions,
22
+ ) {
23
+ super();
24
+ }
25
+
26
+ override toDOM() {
27
+ const container = document.createElement("div");
28
+ container.className = "cm-image-widget cursor-pointer";
29
+ container.setAttribute("contenteditable", "false");
30
+ container.setAttribute("tabindex", "0");
31
+
32
+ if (this.options.foreignImagesAsLinks && new URL(this.url, window.location.href).origin !== window.location.origin) {
33
+ container.removeAttribute("tabindex");
34
+ const link = document.createElement("a");
35
+ link.href = this.url;
36
+ link.target = "_blank";
37
+ link.rel = "noopener noreferrer";
38
+ link.className = "k2b-text-link";
39
+ link.textContent = this.alt || this.url;
40
+ container.append(link);
41
+ return container;
42
+ }
43
+
44
+ const figure = document.createElement("figure");
45
+ figure.className = "flex flex-col items-start max-w-full";
46
+
47
+ const img = document.createElement("img");
48
+ // Drop the `max-h-[400px]` cap when an explicit size is requested so
49
+ // the user's `=WxH` actually wins.
50
+ img.className =
51
+ this.width || this.height
52
+ ? "block rounded border border-gray-200 dark:border-gray-700"
53
+ : "block max-h-[400px] rounded border border-gray-200 dark:border-gray-700";
54
+ img.src = this.url;
55
+ img.alt = this.alt || "";
56
+ if (this.title !== null) img.title = this.title;
57
+ img.loading = "lazy";
58
+ // The suffix reserves the image's place before it loads: width and height give the aspect ratio, a width alone its
59
+ // width. The image never grows past the column; a height alone keeps the image's proportions inside that height.
60
+ img.style.maxWidth = "100%";
61
+ if (this.width) img.setAttribute("width", this.width);
62
+ if (this.height) img.setAttribute("height", this.height);
63
+ if (this.height && !this.width) {
64
+ img.style.height = `${this.height}px`;
65
+ img.style.objectFit = "contain";
66
+ } else {
67
+ img.style.height = "auto";
68
+ }
69
+
70
+ if (this.alt) {
71
+ const caption = document.createElement("figcaption");
72
+ caption.className = "text-sm text-gray-500 dark:text-gray-400 mt-2 italic";
73
+ caption.textContent = this.alt;
74
+ figure.appendChild(img);
75
+ figure.appendChild(caption);
76
+ } else {
77
+ figure.appendChild(img);
78
+ }
79
+
80
+ container.appendChild(figure);
81
+
82
+ // Click → an attachment opens in the lightbox, any other image keeps the
83
+ // download confirm. Stop propagation so CM doesn't reposition the cursor
84
+ // (consistent with the file-pill widget).
85
+ const open = () => {
86
+ if (this.options.openImage) this.options.openImage(img, this.url, this.alt);
87
+ else window.open(this.url, "_blank", "noopener,noreferrer");
88
+ };
89
+ container.onmousedown = (e) => {
90
+ e.preventDefault();
91
+ e.stopPropagation();
92
+ };
93
+ container.onclick = (e) => {
94
+ e.preventDefault();
95
+ e.stopPropagation();
96
+ open();
97
+ };
98
+ container.onkeydown = (e) => {
99
+ if (e.key !== "Enter" && e.key !== " ") return;
100
+ e.preventDefault();
101
+ e.stopPropagation();
102
+ open();
103
+ };
104
+
105
+ return container;
106
+ }
107
+
108
+ override eq(other: WidgetType) {
109
+ return (
110
+ other instanceof ImageWidget &&
111
+ other.url === this.url &&
112
+ other.alt === this.alt &&
113
+ other.width === this.width &&
114
+ other.height === this.height &&
115
+ other.title === this.title
116
+ );
117
+ }
118
+
119
+ override ignoreEvent(event: Event) {
120
+ return event.type !== "mousedown";
121
+ }
122
+ }
123
+
124
+ /** Source-byte ranges of every Image node (incl. the next-line
125
+ * source-visible extension). Drives the cursor-zone rebuild gate. */
126
+ const findMarkdownImages = (state: EditorState, options: MarkdownUrlOptions): CursorZoneState => {
127
+ const decorations: Range<Decoration>[] = [];
128
+ const atomicDecorations: Range<Decoration>[] = [];
129
+ const ranges: { from: number; to: number }[] = [];
130
+ const cursor = state.selection.main;
131
+
132
+ syntaxTree(state).iterate({
133
+ enter: (node) => {
134
+ if (node.type.name !== "Image") return;
135
+
136
+ const prevLine = state.doc.lineAt(Math.max(node.from - 1, 0));
137
+ const nextLine = state.doc.lineAt(Math.min(node.to + 1, state.doc.length));
138
+ ranges.push({ from: prevLine.from, to: nextLine.to });
139
+ if (selectionIntersectsRange(cursor, prevLine.from, nextLine.to)) return false;
140
+
141
+ const text = state.sliceDoc(node.from, node.to);
142
+ const sized = parseSizedMarkdownImage(text);
143
+ const urlNode = node.node.getChild("URL");
144
+ if (!sized && !urlNode) return false;
145
+ const marks = node.node.getChildren("LinkMark");
146
+ const titleNode = node.node.getChild("LinkTitle");
147
+ const alt = sized ? sized.alt : state.sliceDoc(marks[0]!.to, marks[1]!.from);
148
+ const href = sized ? sized.href : state.sliceDoc(urlNode!.from, urlNode!.to);
149
+ const title = sized ? sized.title : titleNode ? state.sliceDoc(titleNode.from + 1, titleNode.to - 1) : null;
150
+ const width = sized?.width == null ? null : String(sized.width);
151
+ const height = sized?.height == null ? null : String(sized.height);
152
+ const resolvedUrl = options.resolveUrl?.("image", href) ?? href;
153
+ // Only script of this document can create a blob: URL, so a host may hand one over for a locally stored image;
154
+ // the same URL typed into the text stays source.
155
+ const hostBlob = resolvedUrl !== href && resolvedUrl.startsWith("blob:");
156
+ if (!hostBlob && !isSafeMarkdownUrl(resolvedUrl)) return false;
157
+ const imageDecoration = Decoration.replace({
158
+ widget: new ImageWidget(resolvedUrl, alt, width, height, title, options),
159
+ block: true,
160
+ }).range(node.from, node.to);
161
+ decorations.push(imageDecoration);
162
+ atomicDecorations.push(imageDecoration);
163
+ },
164
+ });
165
+
166
+ return {
167
+ decorations: decorations.length > 0 ? RangeSet.of(decorations, true) : Decoration.none,
168
+ atomicDecorations: atomicDecorations.length > 0 ? RangeSet.of(atomicDecorations, true) : Decoration.none,
169
+ ranges,
170
+ };
171
+ };
172
+
173
+ export const imageExtension = (options: MarkdownUrlOptions = {}): Extension => {
174
+ const stateField = cursorZoneStateField((state) => findMarkdownImages(state, options));
175
+
176
+ const theme = EditorView.theme({
177
+ ".cm-image-widget": {
178
+ display: "block",
179
+ margin: "0 !important",
180
+ lineHeight: "1",
181
+ },
182
+ ".cm-image-widget:focus": {
183
+ outline: "2px solid var(--color-blue-500)",
184
+ outlineOffset: "2px",
185
+ borderRadius: "4px",
186
+ },
187
+ });
188
+
189
+ // No view-level mousedown handler needed — the widget container swallows
190
+ // its own clicks (preventDefault + stopPropagation in toDOM).
191
+ return [stateField, blockWidgetLineNavigationExtension(stateField, (value) => value.atomicDecorations), theme];
192
+ };
@@ -0,0 +1,54 @@
1
+ export * from "./_lib/cursor-zone-field";
2
+ export * from "./_lib/inline-syntax";
3
+ export * from "./_lib/link-opener";
4
+ export * from "./_lib/list-item-line";
5
+ export * from "./_lib/list-structure";
6
+ export * from "./_lib/math-syntax";
7
+ export * from "./_lib/table-cell";
8
+ export * from "./basic";
9
+ export * from "./block-previews";
10
+ export * from "./code-fence-snippets";
11
+ export * from "./code-font";
12
+ export * from "./completion-icon";
13
+ export * from "./data-block-template";
14
+ export * from "./data-blocks";
15
+ export * from "./editor-scope";
16
+ export * from "./fade-scroll-margins";
17
+ export * from "./image-syntax";
18
+ export * from "./images";
19
+ export * from "./info-block-snippets";
20
+ export * from "./info-blocks";
21
+ export * from "./katex";
22
+ export * from "./lib/heading-anchors";
23
+ export * from "./lib/ligatures";
24
+ export * from "./lib/markdown-context";
25
+ export * from "./lib/markdown-fences";
26
+ export * from "./lib/markdown-image";
27
+ export * from "./lib/named-blocks";
28
+ export * from "./lib/query-blocks";
29
+ export * from "./ligatures";
30
+ export * from "./links";
31
+ export * from "./list-commands";
32
+ export * from "./lists";
33
+ export * from "./mark";
34
+ export * from "./markdown";
35
+ export * from "./markup";
36
+ export * from "./mermaid";
37
+ export * from "./mermaid-export";
38
+ export * from "./mermaid-preview";
39
+ export * from "./mermaid-render";
40
+ export * from "./mermaid-viewport";
41
+ export * from "./messages";
42
+ export * from "./named-blocks";
43
+ export * from "./note-scroller";
44
+ export * from "./pretty-table";
45
+ export * from "./search-theme";
46
+ export * from "./shared-tables";
47
+ export * from "./sub-sup";
48
+ export * from "./surface-press";
49
+ export * from "./tab-indent";
50
+ export * from "./table-columns";
51
+ export * from "./table-formulas";
52
+ export * from "./tables";
53
+ export * from "./theme";
54
+ export * from "./urls";
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Autocomplete für `:::TYPE` Info-Block-Direktiven.
3
+ *
4
+ * Trigger: line starts with three colons optionally followed by a
5
+ * partial type name (`:::`, `:::n`, `:::warn` …). Accepting a
6
+ * suggestion expands to the full block including the closing
7
+ * `:::` marker, with the cursor parked on the body line ready for
8
+ * content entry.
9
+ *
10
+ * Types match exactly what `info-blocks.ts` recognises in its
11
+ * `blockConfig` registry (note / info / success / warning / danger),
12
+ * with the SAME icon glyphs so the autocomplete option-list previews
13
+ * what the rendered block will look like.
14
+ *
15
+ * Scope: only in regular markdown context. We skip inside existing
16
+ * FencedCode blocks — typing `:::` in a code fence body is just
17
+ * literal text, not a directive.
18
+ */
19
+ import {
20
+ type Completion,
21
+ type CompletionContext,
22
+ type CompletionResult,
23
+ pickedCompletion,
24
+ snippetCompletion,
25
+ } from "@codemirror/autocomplete";
26
+ import type { EditorView } from "@codemirror/view";
27
+ import { i18n } from "@k2b/stdlib";
28
+ import { withIcon } from "./completion-icon";
29
+ import { buildDataBlockTemplate, dataBlockRefSelection } from "./data-block-template";
30
+ import { isInsideFencedCode } from "./editor-scope";
31
+
32
+ type BlockEntry = {
33
+ /** The directive name as it appears after the `:::`. */
34
+ name: string;
35
+ /** Short label shown in the option detail. */
36
+ detail: string;
37
+ /** Tabler icon class — mirrors `info-blocks.ts` blockConfig so the
38
+ * picker preview matches the rendered widget. */
39
+ icon: string;
40
+ /** Data blocks need to insert an `@ref` handle before the directive. */
41
+ kind?: "callout" | "data";
42
+ };
43
+
44
+ /** Source of truth: keep in sync with the `blockConfig` map in
45
+ * `info-blocks.ts`. We don't import it here to avoid a circular
46
+ * module dependency (info-blocks imports from `@codemirror/state`
47
+ * + `@codemirror/view`, nothing else heavy, so it'd technically be
48
+ * fine — but keeping the registry duplicated and tiny is easier to
49
+ * read than threading types through). */
50
+ const BLOCKS: BlockEntry[] = [
51
+ { name: "note", detail: "Note callout", icon: "ti-chevron-right" },
52
+ { name: "info", detail: "Info callout", icon: "ti-info-circle" },
53
+ { name: "success", detail: "Success callout", icon: "ti-check" },
54
+ { name: "warning", detail: "Warning callout", icon: "ti-alert-circle" },
55
+ { name: "danger", detail: "Danger callout", icon: "ti-alert-hexagon" },
56
+ { name: "data", detail: "Referenceable data block", icon: "ti-database", kind: "data" },
57
+ { name: "chart", detail: "Chart from a table or list", icon: "ti-chart-bar" },
58
+ { name: "query", detail: "Filtered note list or table", icon: "ti-filter" },
59
+ { name: "toc", detail: "Table of contents", icon: "ti-list-details" },
60
+ ];
61
+
62
+ const blockHelp = i18n.define({
63
+ baseLocale: "en",
64
+ messages: {
65
+ en: {
66
+ note: "Note callout",
67
+ info: "Info callout",
68
+ success: "Success callout",
69
+ warning: "Warning callout",
70
+ danger: "Danger callout",
71
+ data: "Referenceable data block",
72
+ chart: "Chart from a table or list",
73
+ query: "Filtered note list or table",
74
+ toc: "Table of contents",
75
+ },
76
+ de: {
77
+ note: "Hinweisblock",
78
+ info: "Informationsblock",
79
+ success: "Erfolgsblock",
80
+ warning: "Warnungsblock",
81
+ danger: "Gefahrenhinweis",
82
+ data: "Referenzierbarer Datenblock",
83
+ chart: "Diagramm aus einer Tabelle oder Liste",
84
+ query: "Gefilterte Notizliste oder Tabelle",
85
+ toc: "Inhaltsverzeichnis",
86
+ },
87
+ },
88
+ });
89
+
90
+ /** Snippet template. Does NOT include the leading `:::` because
91
+ * the `from` position in the CompletionResult below is anchored
92
+ * AFTER the `:::` the user already typed — CM's prefix-filter
93
+ * matches the typed name fragment against `label` (e.g. "note"),
94
+ * and on acceptance only the text between `from` and the cursor
95
+ * is replaced. So we only need to emit the type name + body + the
96
+ * closing fence here; the user's typed `:::` stays in place.
97
+ * `${0}` parks the final cursor on the empty body line. */
98
+ export const buildInfoBlockSnippet = (name: string): string =>
99
+ name === "chart"
100
+ ? "chart\ntype: bar\ntitle: Values\ncolumns: [Label, Value]\nrows:\n - [Jan, 12]\n - [Feb, 18]\nx: Label\ny: Value\n${0}\n:::"
101
+ : `${name}\n${name === "query" ? "source: notes\n" : ""}\${0}\n:::`;
102
+
103
+ const COMPLETIONS: Completion[] = BLOCKS.map((b) => {
104
+ if (b.kind === "data") {
105
+ const c: Completion = {
106
+ label: b.name,
107
+ type: "keyword",
108
+ detail: b.detail,
109
+ apply: (view: EditorView, _completion: Completion, from: number, to: number) => {
110
+ const directiveStart = from - 3;
111
+ const insert = buildDataBlockTemplate();
112
+ view.dispatch({
113
+ changes: { from: directiveStart, to, insert },
114
+ selection: dataBlockRefSelection(directiveStart),
115
+ annotations: pickedCompletion.of(_completion),
116
+ userEvent: "input.complete",
117
+ });
118
+ view.focus();
119
+ },
120
+ };
121
+ withIcon(c, b.icon);
122
+ return c;
123
+ }
124
+
125
+ const c = snippetCompletion(buildInfoBlockSnippet(b.name), {
126
+ label: b.name,
127
+ type: "keyword",
128
+ detail: b.detail,
129
+ });
130
+ withIcon(c, b.icon);
131
+ return c;
132
+ });
133
+
134
+ /**
135
+ * Completion source. Wire into `autocompletion({override: […]})`.
136
+ */
137
+ export const infoBlockCompletionSource = (context: CompletionContext): CompletionResult | null => {
138
+ const word = context.matchBefore(/^:::\w*/);
139
+ if (!word) return null;
140
+ const line = context.state.doc.lineAt(context.pos);
141
+ if (word.from !== line.from) return null;
142
+ if (isInsideFencedCode(context)) return null;
143
+ // CRITICAL: anchor `from` AFTER the typed `:::` so CM's prefix
144
+ // filter compares the user's partial type name (e.g. "su") to the
145
+ // option labels ("success"). If we anchored at the line start the
146
+ // typed text would include `:::` which doesn't prefix-match any
147
+ // option label, and CM would silently drop the entire option list
148
+ // → empty popup → looks broken.
149
+ const t = blockHelp.resolve(typeof document === "undefined" ? ["en"] : [document.documentElement.lang || "en"]).t;
150
+ return {
151
+ from: word.from + 3,
152
+ to: word.to,
153
+ options: COMPLETIONS.map((completion) => ({
154
+ ...completion,
155
+ detail: t[completion.label as keyof typeof t],
156
+ })),
157
+ validFor: /^\w*$/,
158
+ };
159
+ };
@@ -0,0 +1,156 @@
1
+ import type { EditorState, Extension, Range } from "@codemirror/state";
2
+ import { RangeSet } from "@codemirror/state";
3
+ import { Decoration, EditorView, WidgetType } from "@codemirror/view";
4
+ import { type MarkdownInfoBlockType, NOTICE_CARD_CLASSES, renderMarkdownInfoBlock, scanMarkdownInfoBlock } from "@k2b/ui";
5
+ import {
6
+ blockWidgetLineNavigationExtension,
7
+ type CursorZoneState,
8
+ cursorZoneStateField,
9
+ selectionIntersectsRange,
10
+ } from "./_lib/cursor-zone-field";
11
+ import { literalMarkdownLines, NOTEBOOK_DIRECTIVE_START } from "./lib/markdown-context";
12
+ import { applyLigatures } from "./ligatures";
13
+
14
+ type InfoBlockData = {
15
+ type: MarkdownInfoBlockType;
16
+ title?: string;
17
+ content: string;
18
+ };
19
+
20
+ const escapeHtml = (value: string): string => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
21
+
22
+ const renderContent = (content: string): string => {
23
+ const codeSpans: string[] = [];
24
+ return escapeHtml(content)
25
+ .replace(/`([^`]+)`/g, (_match, body: string) => {
26
+ const index = codeSpans.push(`<code class="bg-black/10 dark:bg-white/10 px-1 py-0.5 rounded text-sm">${body}</code>`) - 1;
27
+ return `\u0000CODE${index}\u0000`;
28
+ })
29
+ .replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
30
+ .replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, "<em>$1</em>")
31
+ .replace(/\n/g, "<br>")
32
+ .replace(/\u0000CODE(\d+)\u0000/g, (_match, index: string) => codeSpans[Number(index)] ?? "");
33
+ };
34
+
35
+ class InfoBlockWidget extends WidgetType {
36
+ constructor(
37
+ private blockData: InfoBlockData,
38
+ private fromPos: number,
39
+ private locale: string,
40
+ ) {
41
+ super();
42
+ }
43
+
44
+ override toDOM(view: EditorView) {
45
+ const container = document.createElement("div");
46
+ container.className = "cm-notice-card-widget cursor-pointer";
47
+ container.setAttribute("contenteditable", "false");
48
+ container.setAttribute("tabindex", "0");
49
+ container.onmousedown = (event) => {
50
+ event.preventDefault();
51
+ event.stopPropagation();
52
+ view.dispatch({ selection: { anchor: this.fromPos }, scrollIntoView: true });
53
+ view.focus();
54
+ };
55
+ container.ondblclick = (event) => {
56
+ event.stopPropagation();
57
+ };
58
+
59
+ // The shared callout with its type icon and tone colour, as in Book; the editor preview renders inline formatting only.
60
+ container.innerHTML = renderMarkdownInfoBlock({
61
+ type: this.blockData.type,
62
+ title: this.blockData.title,
63
+ bodyHtml: renderContent(this.blockData.content),
64
+ locale: this.locale,
65
+ });
66
+ const body = container.querySelector<HTMLElement>(`.${NOTICE_CARD_CLASSES.body}`);
67
+ if (body) applyLigatures(body);
68
+ return container;
69
+ }
70
+
71
+ override eq(other: WidgetType) {
72
+ return (
73
+ other instanceof InfoBlockWidget &&
74
+ other.fromPos === this.fromPos &&
75
+ other.locale === this.locale &&
76
+ other.blockData.type === this.blockData.type &&
77
+ other.blockData.title === this.blockData.title &&
78
+ other.blockData.content === this.blockData.content
79
+ );
80
+ }
81
+
82
+ override ignoreEvent() {
83
+ return true;
84
+ }
85
+
86
+ override get estimatedHeight() {
87
+ // At 16px / 26px, count each title and unwrapped body line, plus 32px padding, 2px border and a 4px title gap.
88
+ // The icon sits beside the text and adds no height. A wrapped line measures taller; CodeMirror corrects the height once the notice renders.
89
+ const lines = this.blockData.content.split("\n").length + (this.blockData.title ? 1 : 0);
90
+ return lines * 26 + 34 + (this.blockData.title ? 4 : 0);
91
+ }
92
+ }
93
+
94
+ /** Finds notices with the same block scanner as the book view, so every
95
+ * directive ends at its own `:::` and code or list content stays source. */
96
+ const findInfoBlocks = (state: EditorState, locale: string): CursorZoneState => {
97
+ const decorations: Range<Decoration>[] = [];
98
+ const ranges: { from: number; to: number }[] = [];
99
+ const cursor = state.selection.main;
100
+ const text = state.doc.toString();
101
+ if (!text.includes(":::")) return { decorations: Decoration.none, atomicDecorations: Decoration.none, ranges };
102
+ const literal = literalMarkdownLines(text);
103
+
104
+ for (let number = 1; number <= state.doc.lines; number++) {
105
+ const opener = state.doc.line(number);
106
+ if (!NOTEBOOK_DIRECTIVE_START.test(opener.text) || literal.has(number - 1)) continue;
107
+ const notice = scanMarkdownInfoBlock(text.slice(opener.from));
108
+ if (!notice?.closed) continue;
109
+ const blockLines = text
110
+ .slice(opener.from, opener.from + notice.length)
111
+ .replace(/\n$/, "")
112
+ .split("\n");
113
+ const blockStart = opener.from;
114
+ const blockEnd = state.doc.line(number + blockLines.length - 1).to;
115
+ const sourceVisibleStart = state.doc.lineAt(Math.max(blockStart - 1, 0)).from;
116
+ const sourceVisibleEnd = state.doc.lineAt(Math.min(blockEnd + 1, state.doc.length)).to;
117
+ ranges.push({ from: sourceVisibleStart, to: sourceVisibleEnd });
118
+
119
+ // Cursor is inside the block → don't render the widget so the user
120
+ // can edit the raw `:::xxx` markers.
121
+ if (selectionIntersectsRange(cursor, sourceVisibleStart, sourceVisibleEnd)) continue;
122
+
123
+ const blockData = { type: notice.type, title: notice.title, content: notice.body.trim() };
124
+ decorations.push(
125
+ Decoration.replace({
126
+ widget: new InfoBlockWidget(blockData, blockStart, locale),
127
+ block: true,
128
+ }).range(blockStart, blockEnd),
129
+ );
130
+ }
131
+
132
+ const set = decorations.length > 0 ? RangeSet.of(decorations, true) : Decoration.none;
133
+ return { decorations: set, atomicDecorations: set, ranges };
134
+ };
135
+
136
+ /** `locale` names an untitled notice's type for screen readers; sighted readers see its type icon and tone tint. */
137
+ export const infoBlocksExtension = (locale: string): Extension => {
138
+ // Container context anywhere above a notice decides whether it renders, so
139
+ // every document change rescans, like the query and table-of-contents blocks.
140
+ const stateField = cursorZoneStateField((state) => findInfoBlocks(state, locale));
141
+
142
+ const theme = EditorView.theme({
143
+ ".cm-notice-card-widget": {
144
+ display: "block",
145
+ margin: "0 !important",
146
+ lineHeight: "1",
147
+ },
148
+ // Notices read at the note's text size, as in the Book.
149
+ ".cm-notice-card-widget .k2b-notice-card": {
150
+ fontSize: "inherit",
151
+ lineHeight: "1.625",
152
+ },
153
+ });
154
+
155
+ return [stateField, blockWidgetLineNavigationExtension(stateField, (value) => value.atomicDecorations), theme];
156
+ };