@k2b/editor 0.0.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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,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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
+
};
|