@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.
Files changed (83) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +32 -1
  3. package/package.json +116 -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,145 @@
1
+ import { literalMarkdownLines } from "./markdown-context";
2
+
3
+ export type Directive = {
4
+ type: "query" | "toc" | "chart" | "map";
5
+ body: string[];
6
+ line: number;
7
+ closed: boolean;
8
+ from: number;
9
+ to: number;
10
+ };
11
+
12
+ export const directives = (md: string): Directive[] => {
13
+ if (!/:::(?:query|toc|chart|map)\b/.test(md)) return [];
14
+ const lines = md.split("\n");
15
+ const literalLines = literalMarkdownLines(md);
16
+ const offsets: number[] = [];
17
+ let offset = 0;
18
+ for (const text of lines) {
19
+ offsets.push(offset);
20
+ offset += text.length + 1;
21
+ }
22
+ const found: Directive[] = [];
23
+ for (let i = 0; i < lines.length; i++) {
24
+ if (literalLines.has(i)) continue;
25
+ const text = lines[i]!;
26
+ const opener = text.match(/^ {0,3}:::(query|toc|chart|map)[ \t]*\r?$/);
27
+ if (!opener?.[1]) continue;
28
+ const line = i + 1;
29
+ const body: string[] = [];
30
+ let closed = false;
31
+ for (i = i + 1; i < lines.length; i++) {
32
+ if (/^ {0,3}:::[ \t]*\r?$/.test(lines[i]!)) {
33
+ closed = true;
34
+ break;
35
+ }
36
+ // Keep raw offsets above; normalize only the body consumed by the DSL.
37
+ body.push(lines[i]!.replace(/\r$/, ""));
38
+ }
39
+ found.push({
40
+ type: opener[1] as Directive["type"],
41
+ body,
42
+ line,
43
+ closed,
44
+ from: offsets[line - 1]!,
45
+ to: closed ? offsets[i]! + lines[i]!.length : md.length,
46
+ });
47
+ }
48
+ return found;
49
+ };
50
+
51
+ /** Source geometry only; query validation and result rendering stay server-owned. */
52
+ export const extractNotebookDirectiveRanges = (md: string): Array<Pick<Directive, "type" | "line" | "from" | "to" | "closed">> =>
53
+ directives(md).map(({ type, line, from, to, closed }) => ({ type, line, from, to, closed }));
54
+
55
+ export const DIRECTIVE_MAX_STRING_LENGTH = 2_000;
56
+ export type DirectiveScalar = string | number | boolean;
57
+ export type DirectiveValue = DirectiveScalar | DirectiveScalar[];
58
+
59
+ export type NotebookBlockDiagnosticCode =
60
+ | "duplicate-key"
61
+ | "invalid-field"
62
+ | "invalid-type"
63
+ | "missing-key"
64
+ | "missing-value"
65
+ | "too-many-items"
66
+ | "unexpected-line"
67
+ | "unknown-key"
68
+ | "unclosed-block"
69
+ | "unknown-block"
70
+ | "invalid-source"
71
+ | "duplicate-block"
72
+ | "unknown-column"
73
+ | "not-a-number"
74
+ | "chart-x-type"
75
+ | "chart-one-series"
76
+ | "chart-unsupported";
77
+
78
+ export type NotebookBlockDiagnostic = {
79
+ code: NotebookBlockDiagnosticCode;
80
+ line: number;
81
+ path: string;
82
+ params?: { name?: string; row?: number; limit?: number };
83
+ };
84
+
85
+ export const parseScalar = (raw: string): DirectiveScalar | null => {
86
+ const value = raw.trim();
87
+ if (!value) return null;
88
+ if (value === "true") return true;
89
+ if (value === "false") return false;
90
+ if (/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(value)) {
91
+ const parsed = Number(value);
92
+ return Number.isFinite(parsed) ? parsed : null;
93
+ }
94
+ if (value.startsWith('"')) {
95
+ try {
96
+ const parsed: unknown = JSON.parse(value);
97
+ return typeof parsed === "string" && parsed.length <= DIRECTIVE_MAX_STRING_LENGTH ? parsed : null;
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+ if (value.startsWith("'")) {
103
+ if (!/^'(?:[^']|'')*'$/.test(value)) return null;
104
+ const parsed = value.slice(1, -1).replace(/''/g, "'");
105
+ return parsed.length <= DIRECTIVE_MAX_STRING_LENGTH ? parsed : null;
106
+ }
107
+ if (/^[\[\]{}&*!|>@`]/.test(value)) return null;
108
+ return value.length <= DIRECTIVE_MAX_STRING_LENGTH ? value : null;
109
+ };
110
+
111
+ export const splitInlineList = (raw: string): string[] | null => {
112
+ const value = raw.trim();
113
+ if (!value.startsWith("[") || !value.endsWith("]")) return null;
114
+ const inner = value.slice(1, -1).trim();
115
+ if (!inner) return [];
116
+ const items: string[] = [];
117
+ let quote: "'" | '"' | null = null;
118
+ let start = 0;
119
+ for (let i = 0; i < inner.length; i++) {
120
+ const char = inner[i]!;
121
+ if (quote === '"' && char === "\\") {
122
+ i++;
123
+ } else if (quote && char === quote) {
124
+ if (quote === "'" && inner[i + 1] === "'") i++;
125
+ else quote = null;
126
+ } else if (!quote && (char === "'" || char === '"') && !inner.slice(start, i).trim()) {
127
+ quote = char;
128
+ } else if (char === "," && !quote) {
129
+ items.push(inner.slice(start, i));
130
+ start = i + 1;
131
+ }
132
+ }
133
+ if (quote) return null;
134
+ items.push(inner.slice(start));
135
+ return items;
136
+ };
137
+
138
+ export const parseValue = (raw: string): DirectiveValue | null => {
139
+ const list = splitInlineList(raw);
140
+ if (list) {
141
+ const values = list.map(parseScalar);
142
+ return values.every((value): value is DirectiveScalar => value !== null) ? values : null;
143
+ }
144
+ return parseScalar(raw);
145
+ };
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Display ligatures for the rich editor: `->` shows as `→`, `!=` as `≠` and
3
+ * so on (see `lib/ligatures.ts`). The document text never changes. A
4
+ * sequence shows its typed characters again while the selection touches it,
5
+ * the same way hidden Markdown marks reappear.
6
+ *
7
+ * Only visible ranges are scanned. The Markdown syntax tree decides what is
8
+ * prose; code, HTML, URLs, links, marks and table rules are skipped. Notebook
9
+ * syntax that the tree does not model (math, data, query and TOC blocks, YAML
10
+ * front matter) is resolved once per document version, and only when a
11
+ * visible candidate needs it.
12
+ */
13
+ import { syntaxTree } from "@codemirror/language";
14
+ import { type Text as DocText, type EditorSelection, type Extension, Prec, type Range } from "@codemirror/state";
15
+ import { Decoration, type DecorationSet, type EditorView, ViewPlugin, type ViewUpdate, WidgetType } from "@codemirror/view";
16
+ import { holdsDecorations, pointerSelectionHeld, refreshMarkdownDecorationsEffect } from "./_lib/cursor-zone-field";
17
+ import type { MarkdownRangeOptions } from "./_lib/list-structure";
18
+ import { blockMathPattern, inlineMathPattern } from "./_lib/math-syntax";
19
+ import { findLigatures, frontMatterLength, type LigatureMatch } from "./lib/ligatures";
20
+
21
+ type Span = { from: number; to: number };
22
+
23
+ /** Syntax-tree nodes whose text is never prose. Children are not visited. */
24
+ const EXCLUDED_NODES = new Set([
25
+ "FencedCode",
26
+ "CodeBlock",
27
+ "InlineCode",
28
+ "HTMLBlock",
29
+ "HTMLTag",
30
+ "Comment",
31
+ "CommentBlock",
32
+ "ProcessingInstruction",
33
+ "ProcessingInstructionBlock",
34
+ "URL",
35
+ "Autolink",
36
+ "Link",
37
+ "Image",
38
+ "LinkReference",
39
+ "Escape",
40
+ "HorizontalRule",
41
+ "TableDelimiter",
42
+ "HeaderMark",
43
+ "QuoteMark",
44
+ "ListMark",
45
+ "EmphasisMark",
46
+ "StrikethroughMark",
47
+ "TaskMarker",
48
+ ]);
49
+
50
+ const overlaps = (spans: readonly Span[], from: number, to: number): boolean => spans.some((span) => from < span.to && to > span.from);
51
+
52
+ /** Math, data, query and TOC blocks and front matter: whole-document syntax, cached per document version. */
53
+ const notebookSyntax = (doc: DocText, options: MarkdownRangeOptions, cache: WeakMap<DocText, Span[]>): Span[] => {
54
+ const cached = cache.get(doc);
55
+ if (cached) return cached;
56
+ const markdown = doc.toString();
57
+ const spans: Span[] = [];
58
+ const frontMatter = frontMatterLength(markdown);
59
+ if (frontMatter) spans.push({ from: 0, to: frontMatter });
60
+ if (markdown.includes("$") || markdown.includes("\\")) {
61
+ for (const pattern of [blockMathPattern(), inlineMathPattern()]) {
62
+ for (const match of markdown.matchAll(pattern)) spans.push({ from: match.index, to: match.index + match[0].length });
63
+ }
64
+ }
65
+ spans.push(...(options.excludedRanges?.(markdown) ?? []));
66
+ cache.set(doc, spans);
67
+ return spans;
68
+ };
69
+
70
+ /** Ligature candidates in the visible prose. */
71
+ const findVisibleLigatures = (view: EditorView, options: MarkdownRangeOptions, cache: WeakMap<DocText, Span[]>): LigatureMatch[] => {
72
+ const { state } = view;
73
+ const matches: LigatureMatch[] = [];
74
+ for (const visible of view.visibleRanges) {
75
+ const excluded: Span[] = [];
76
+ syntaxTree(state).iterate({
77
+ from: visible.from,
78
+ to: visible.to,
79
+ enter: (node) => {
80
+ if (!EXCLUDED_NODES.has(node.name)) return;
81
+ excluded.push({ from: node.from, to: node.to });
82
+ return false;
83
+ },
84
+ });
85
+ for (let position = visible.from; position <= visible.to; ) {
86
+ const line = state.doc.lineAt(position);
87
+ const from = Math.max(line.from, visible.from) - line.from;
88
+ const to = Math.min(line.to, visible.to) - line.from;
89
+ for (const match of findLigatures(line.text, from, to)) {
90
+ const found = { ...match, from: match.from + line.from, to: match.to + line.from };
91
+ if (!overlaps(excluded, found.from, found.to)) matches.push(found);
92
+ }
93
+ position = line.to + 1;
94
+ }
95
+ }
96
+ if (matches.length === 0) return matches;
97
+ const notebook = notebookSyntax(state.doc, options, cache);
98
+ return matches.filter((match) => !overlaps(notebook, match.from, match.to));
99
+ };
100
+
101
+ /** Same rule as hidden Markdown marks: a selection that touches the sequence reveals it. */
102
+ const touched = (selection: EditorSelection, match: LigatureMatch): boolean =>
103
+ selection.ranges.some((range) => range.from <= match.to && range.to >= match.from);
104
+
105
+ const touchedKey = (selection: EditorSelection, matches: readonly LigatureMatch[]): string =>
106
+ matches.flatMap((match, index) => (touched(selection, match) ? [index] : [])).join(",");
107
+
108
+ class LigatureWidget extends WidgetType {
109
+ constructor(private readonly symbol: string) {
110
+ super();
111
+ }
112
+
113
+ override eq(other: WidgetType) {
114
+ return other instanceof LigatureWidget && other.symbol === this.symbol;
115
+ }
116
+
117
+ override toDOM() {
118
+ const span = document.createElement("span");
119
+ span.className = "cm-ligature";
120
+ span.textContent = this.symbol;
121
+ return span;
122
+ }
123
+
124
+ /** Let the editor place the cursor on click, which then reveals the typed characters. */
125
+ override ignoreEvent() {
126
+ return false;
127
+ }
128
+ }
129
+
130
+ const widgets = new Map<string, Decoration>();
131
+ const ligatureDecoration = (symbol: string): Decoration => {
132
+ let decoration = widgets.get(symbol);
133
+ if (!decoration) {
134
+ decoration = Decoration.replace({ widget: new LigatureWidget(symbol) });
135
+ widgets.set(symbol, decoration);
136
+ }
137
+ return decoration;
138
+ };
139
+
140
+ const decorate = (selection: EditorSelection, matches: readonly LigatureMatch[]): DecorationSet => {
141
+ const ranges: Range<Decoration>[] = [];
142
+ for (const match of matches) {
143
+ if (!touched(selection, match)) ranges.push(ligatureDecoration(match.symbol).range(match.from, match.to));
144
+ }
145
+ return Decoration.set(ranges, true);
146
+ };
147
+
148
+ /** Highest precedence: only marks of lower precedence, such as syntax highlighting, wrap a replaced widget, so the symbol keeps heading and emphasis styling. */
149
+ export const ligaturesExtension = (options: MarkdownRangeOptions = {}): Extension => {
150
+ const cache = new WeakMap<DocText, Span[]>();
151
+ return Prec.highest(
152
+ ViewPlugin.fromClass(
153
+ class {
154
+ matches: LigatureMatch[];
155
+ selection: EditorSelection;
156
+ key: string;
157
+ decorations: DecorationSet;
158
+
159
+ constructor(view: EditorView) {
160
+ this.matches = findVisibleLigatures(view, options, cache);
161
+ this.selection = view.state.selection;
162
+ this.key = touchedKey(this.selection, this.matches);
163
+ this.decorations = decorate(this.selection, this.matches);
164
+ }
165
+
166
+ update(update: ViewUpdate) {
167
+ const refresh = update.transactions.some((tr) => tr.effects.some((effect) => effect.is(refreshMarkdownDecorationsEffect)));
168
+ // While the button is down only pointer selections wait for the release; scrolling and edits keep the press-time selection.
169
+ const held =
170
+ update.state.field(pointerSelectionHeld, false) === true &&
171
+ !refresh &&
172
+ update.transactions.every((tr) => !tr.selection || holdsDecorations(tr));
173
+ if (held) {
174
+ if (update.docChanged) this.selection = this.selection.map(update.changes);
175
+ } else {
176
+ this.selection = update.state.selection;
177
+ }
178
+ const rescan =
179
+ update.docChanged || update.viewportChanged || syntaxTree(update.startState) !== syntaxTree(update.state) || refresh;
180
+ if (rescan) this.matches = findVisibleLigatures(update.view, options, cache);
181
+ else if (!update.selectionSet || update.transactions.every(holdsDecorations)) return;
182
+ const key = touchedKey(this.selection, this.matches);
183
+ if (!rescan && key === this.key) return;
184
+ this.key = key;
185
+ this.decorations = decorate(this.selection, this.matches);
186
+ }
187
+ },
188
+ { decorations: (plugin) => plugin.decorations },
189
+ ),
190
+ );
191
+ };
192
+
193
+ const LIGATURE_SKIP = "code, pre, a, time, .katex, .md-formula-ok, .md-formula-error";
194
+
195
+ /**
196
+ * Applies the same display ligatures to prose inside a rendered widget (notice
197
+ * cards, table previews), so they match the editor text and the Book.
198
+ */
199
+ export const applyLigatures = (root: HTMLElement): void => {
200
+ const textNodes = (node: Node): ChildNode[] =>
201
+ Array.from(node.childNodes).flatMap((child) =>
202
+ child.nodeType !== child.TEXT_NODE ? textNodes(child) : child.parentElement?.closest(LIGATURE_SKIP) ? [] : [child],
203
+ );
204
+ for (const node of textNodes(root)) {
205
+ const text = node.textContent ?? "";
206
+ const matches = findLigatures(text);
207
+ if (matches.length === 0) continue;
208
+ const parts: Array<string | HTMLElement> = [];
209
+ let last = 0;
210
+ for (const match of matches) {
211
+ const span = root.ownerDocument.createElement("span");
212
+ span.className = "cm-ligature";
213
+ span.title = match.source;
214
+ span.textContent = match.symbol;
215
+ parts.push(text.slice(last, match.from), span);
216
+ last = match.to;
217
+ }
218
+ // One wrapper keeps the text a single item in flex containers such as table cells.
219
+ const wrapper = root.ownerDocument.createElement("span");
220
+ wrapper.append(...parts, text.slice(last));
221
+ node.replaceWith(wrapper);
222
+ }
223
+ };
@@ -0,0 +1,158 @@
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
+ type MarkdownReference,
7
+ markdownLinkReference,
8
+ markdownReferenceIcon,
9
+ markdownReferenceType,
10
+ markdownReferenceTypeLabel,
11
+ } from "@k2b/ui";
12
+ import { type CursorZoneState, cursorZoneStateField, selectionIntersectsRange } from "./_lib/cursor-zone-field";
13
+ import { makeOpener } from "./_lib/link-opener";
14
+ import { parseNoteLink } from "./lib/heading-anchors";
15
+ import { extractAttachmentId, isSafeMarkdownUrl, type MarkdownUrlOptions } from "./urls";
16
+
17
+ type LinkData = {
18
+ label: string;
19
+ url: string;
20
+ /** Resolved final href: a note's path for `note://`, the attachment's content URL for `attach://`, otherwise the URL. */
21
+ resolvedUrl: string;
22
+ /** What the link names inside Cloud, classified as Book does; `null` for a web or mail link. */
23
+ reference: MarkdownReference | null;
24
+ /** Set if the link is an `attach://<shortId>` reference to a non-image blob. */
25
+ attachmentId: string | null;
26
+ options: MarkdownUrlOptions;
27
+ };
28
+
29
+ class LinkWidget extends WidgetType {
30
+ constructor(private linkData: LinkData) {
31
+ super();
32
+ }
33
+
34
+ override toDOM(view: EditorView) {
35
+ const { attachmentId, label, reference, url } = this.linkData;
36
+ if (reference) {
37
+ // A reference renders as the shared calm pill of rendered Markdown: icon
38
+ // and text, no brackets, and a name that starts with its type. The whole
39
+ // pill acts while the editor keeps its cursor: an attachment opens its
40
+ // preview (or asks to download), anything else navigates as Book does.
41
+ // The pill is drawn by `.k2b-reference`, so hover only darkens its fill.
42
+ const type = markdownReferenceType(reference, label);
43
+ const el = document.createElement("span");
44
+ el.className = "k2b-reference";
45
+ el.dataset.reference = type;
46
+ el.title = attachmentId ? label : url;
47
+ el.setAttribute("aria-label", `${markdownReferenceTypeLabel(type)}: ${label}`);
48
+
49
+ const icon = document.createElement("i");
50
+ icon.className = `k2b-reference__icon ti ${markdownReferenceIcon(type, label)}`;
51
+ icon.setAttribute("aria-hidden", "true");
52
+
53
+ el.append(icon, label);
54
+ // `note://<shortId>` is internal and not navigable; `resolvedUrl` carries
55
+ // the full path. The shared helper uses client-side editor navigation
56
+ // when mounted and falls back to document navigation otherwise.
57
+ makeOpener(el, view, () => {
58
+ if (this.linkData.options.openLink) this.linkData.options.openLink(this.linkData.resolvedUrl, url, label);
59
+ else window.location.assign(this.linkData.resolvedUrl);
60
+ });
61
+ return el;
62
+ }
63
+
64
+ // Web and mail links read like rendered ones: prose text with a thin
65
+ // accent underline. CodeMirror handles caret placement and selection on
66
+ // web link text; the ↗ opens it. Mail link text opens the link.
67
+ const container = document.createElement("span");
68
+ container.className = "cm-link-widget";
69
+ const open = () => window.open(this.linkData.resolvedUrl, "_blank", "noopener,noreferrer");
70
+
71
+ const labelSpan = document.createElement("span");
72
+ labelSpan.className = "cm-link-label k2b-text-link";
73
+ labelSpan.textContent = label;
74
+ container.appendChild(labelSpan);
75
+
76
+ if (/^(?:mailto|tel):/i.test(url)) {
77
+ labelSpan.title = url;
78
+ makeOpener(labelSpan, view, open);
79
+ return container;
80
+ }
81
+ const iconSpan = document.createElement("i");
82
+ iconSpan.className = "cm-link-icon k2b-text-link__external ti ti-arrow-up-right";
83
+ iconSpan.title = url;
84
+ iconSpan.setAttribute("aria-label", label);
85
+ makeOpener(iconSpan, view, open);
86
+ container.appendChild(iconSpan);
87
+ return container;
88
+ }
89
+
90
+ override eq(other: WidgetType) {
91
+ // Everything else derives from the label and the URL.
92
+ return other instanceof LinkWidget && other.linkData.label === this.linkData.label && other.linkData.url === this.linkData.url;
93
+ }
94
+
95
+ override ignoreEvent(event: Event) {
96
+ // Pills, mail labels and the ↗ open on click and keep the editor's
97
+ // cursor; a press on them still starts a drag selection. A web link's
98
+ // text passes its events to CodeMirror, which then positions the cursor
99
+ // for editing.
100
+ return event.type !== "mousedown" && event.target instanceof Element && event.target.closest(".cm-link-open") !== null;
101
+ }
102
+ }
103
+
104
+ const parseLinkSyntax = (text: string, options: MarkdownUrlOptions): LinkData | null => {
105
+ const match = text.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
106
+ if (!match || !match[1] || !match[2]) return null;
107
+ const url = match[2];
108
+ const attachmentId = extractAttachmentId(url);
109
+ // Internal `note://<shortId>` links, also to a heading, render as pills rather than external links.
110
+ const note = parseNoteLink(url);
111
+ const resolvedUrl = options.resolveUrl?.("link", url) ?? url;
112
+ if (!isSafeMarkdownUrl(resolvedUrl)) return null;
113
+ // The editor has no attachment metadata, so a file's type comes from its link text.
114
+ const reference: MarkdownReference | null = attachmentId
115
+ ? { kind: "file" }
116
+ : note
117
+ ? { kind: note.anchor ? "heading" : "note" }
118
+ : markdownLinkReference(resolvedUrl);
119
+ return { label: match[1], url, resolvedUrl, reference, attachmentId, options };
120
+ };
121
+
122
+ const findLinks = (state: EditorState, options: MarkdownUrlOptions): CursorZoneState => {
123
+ const decorations: Range<Decoration>[] = [];
124
+ const ranges: { from: number; to: number }[] = [];
125
+ const cursor = state.selection.main;
126
+
127
+ syntaxTree(state).iterate({
128
+ enter: ({ type, from, to }) => {
129
+ if (type.name !== "Link") return;
130
+ ranges.push({ from, to });
131
+ if (selectionIntersectsRange(cursor, from, to)) return;
132
+
133
+ const text = state.doc.sliceString(from, to);
134
+ const linkData = parseLinkSyntax(text, options);
135
+
136
+ if (linkData) {
137
+ decorations.push(Decoration.replace({ widget: new LinkWidget(linkData) }).range(from, to));
138
+ }
139
+ },
140
+ });
141
+
142
+ return {
143
+ decorations: decorations.length > 0 ? RangeSet.of(decorations, true) : Decoration.none,
144
+ ranges,
145
+ };
146
+ };
147
+
148
+ export const linksExtension = (options: MarkdownUrlOptions = {}): Extension => {
149
+ const stateField = cursorZoneStateField((state) => findLinks(state, options));
150
+
151
+ const theme = EditorView.theme({
152
+ ".cm-link-open": {
153
+ cursor: "pointer",
154
+ },
155
+ });
156
+
157
+ return [stateField, theme];
158
+ };