@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.
- package/LICENSE +661 -0
- package/README.md +31 -2
- package/package.json +118 -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,290 @@
|
|
|
1
|
+
import type { EditorState, Extension, Range, Transaction } from "@codemirror/state";
|
|
2
|
+
import { StateField } from "@codemirror/state";
|
|
3
|
+
import { Decoration, type DecorationSet, EditorView, ViewPlugin, WidgetType } from "@codemirror/view";
|
|
4
|
+
import { treeOrDocChanged } from "./_lib/cursor-zone-field";
|
|
5
|
+
import {
|
|
6
|
+
type ExcludedSpan,
|
|
7
|
+
excludedSpans,
|
|
8
|
+
type ListItem,
|
|
9
|
+
type ListItemKind,
|
|
10
|
+
type ListStructure,
|
|
11
|
+
listExcludedRanges,
|
|
12
|
+
listItemAtLine,
|
|
13
|
+
listStructure,
|
|
14
|
+
type MarkdownRangeOptions,
|
|
15
|
+
orderedLabel,
|
|
16
|
+
} from "./_lib/list-structure";
|
|
17
|
+
|
|
18
|
+
class ListGutterWidget extends WidgetType {
|
|
19
|
+
constructor(
|
|
20
|
+
private readonly depth: number,
|
|
21
|
+
private readonly kind: ListItemKind,
|
|
22
|
+
private readonly label: string,
|
|
23
|
+
private readonly checked: boolean,
|
|
24
|
+
) {
|
|
25
|
+
super();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
override eq(other: WidgetType) {
|
|
29
|
+
return (
|
|
30
|
+
other instanceof ListGutterWidget &&
|
|
31
|
+
this.depth === other.depth &&
|
|
32
|
+
this.kind === other.kind &&
|
|
33
|
+
this.label === other.label &&
|
|
34
|
+
this.checked === other.checked
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
override toDOM() {
|
|
39
|
+
const gutter = document.createElement("span");
|
|
40
|
+
gutter.className = "custom-list-gutter";
|
|
41
|
+
gutter.setAttribute("aria-hidden", "true");
|
|
42
|
+
for (let level = 0; level < this.depth; level++) {
|
|
43
|
+
const guide = document.createElement("span");
|
|
44
|
+
guide.className = "custom-list-guide";
|
|
45
|
+
guide.style.setProperty("--list-level", String(level));
|
|
46
|
+
gutter.append(guide);
|
|
47
|
+
}
|
|
48
|
+
// As in Book, a numbered checklist item shows its number and then its box.
|
|
49
|
+
if (this.label) gutter.append(this.marker("ordered", this.label));
|
|
50
|
+
if (this.kind === "task") {
|
|
51
|
+
const input = document.createElement("input");
|
|
52
|
+
input.type = "checkbox";
|
|
53
|
+
input.className = "custom-list-task-marker";
|
|
54
|
+
input.tabIndex = -1;
|
|
55
|
+
input.setAttribute("aria-hidden", "true");
|
|
56
|
+
input.checked = this.checked;
|
|
57
|
+
gutter.append(this.marker("task", input));
|
|
58
|
+
} else if (!this.label) {
|
|
59
|
+
const dot = document.createElement("span");
|
|
60
|
+
dot.className = `custom-list-dot custom-list-dot-${this.depth % 3}`;
|
|
61
|
+
gutter.append(this.marker("bullet", dot));
|
|
62
|
+
}
|
|
63
|
+
return gutter;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private marker(kind: ListItemKind, content: string | Node) {
|
|
67
|
+
const marker = document.createElement("span");
|
|
68
|
+
marker.className = `custom-list-marker custom-list-marker-${kind}`;
|
|
69
|
+
marker.append(content);
|
|
70
|
+
return marker;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
override ignoreEvent(event: Event) {
|
|
74
|
+
return this.kind === "task" ? false : super.ignoreEvent(event);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const decorated = (state: EditorState, item: ListItem): boolean =>
|
|
79
|
+
item.taskTo !== null
|
|
80
|
+
? state.sliceDoc(item.taskTo, item.taskTo + 1) === " "
|
|
81
|
+
: /[ \t]/.test(state.sliceDoc(item.markerTo, item.markerTo + 1));
|
|
82
|
+
|
|
83
|
+
const selectionParent = (state: EditorState, structure: ListStructure): number => {
|
|
84
|
+
const index = listItemAtLine(structure, state.doc.lineAt(state.selection.main.head).number);
|
|
85
|
+
return structure.items[index]?.parent ?? -1;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const activeGuides = (state: EditorState, structure: ListStructure, parent: number): DecorationSet => {
|
|
89
|
+
const item = structure.items[parent];
|
|
90
|
+
if (!item) return Decoration.none;
|
|
91
|
+
const ranges: Range<Decoration>[] = [];
|
|
92
|
+
const decoration = Decoration.line({ attributes: { style: `--list-active-level: ${item.depth}` } });
|
|
93
|
+
for (let index = parent + 1; index < item.subtreeEnd; index++) {
|
|
94
|
+
const child = structure.items[index]!;
|
|
95
|
+
if (decorated(state, child)) ranges.push(decoration.range(child.from));
|
|
96
|
+
}
|
|
97
|
+
return Decoration.set(ranges, true);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
type ListState = {
|
|
101
|
+
excluded: readonly ExcludedSpan[] | undefined;
|
|
102
|
+
structure: ListStructure;
|
|
103
|
+
decorations: DecorationSet;
|
|
104
|
+
atomicRanges: DecorationSet;
|
|
105
|
+
active: DecorationSet;
|
|
106
|
+
parent: number;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const build = (state: EditorState, cached?: readonly ExcludedSpan[]): ListState => {
|
|
110
|
+
let excluded = cached;
|
|
111
|
+
// Notebook blocks are outside Markdown's grammar. Read their source only when lists exist.
|
|
112
|
+
const structure = listStructure(state, () => (excluded ??= excludedSpans(state)));
|
|
113
|
+
const ranges: Range<Decoration>[] = [];
|
|
114
|
+
const atomic: Range<Decoration>[] = [];
|
|
115
|
+
for (const item of structure.items) {
|
|
116
|
+
if (!decorated(state, item)) continue;
|
|
117
|
+
ranges.push(
|
|
118
|
+
Decoration.line({
|
|
119
|
+
attributes: {
|
|
120
|
+
class: `custom-list custom-list-${item.kind}${item.checked ? " custom-list-task-checked" : ""}`,
|
|
121
|
+
style: `--list-depth: ${item.depth}${item.ordered && item.kind === "task" ? "; --list-markers: 2" : ""}`,
|
|
122
|
+
},
|
|
123
|
+
}).range(item.from),
|
|
124
|
+
);
|
|
125
|
+
const label = item.ordinal === null ? "" : orderedLabel(item.ordinal, item.orderedDepth);
|
|
126
|
+
const gutter = Decoration.replace({ widget: new ListGutterWidget(item.depth, item.kind, label, item.checked) }).range(
|
|
127
|
+
item.from,
|
|
128
|
+
item.contentFrom,
|
|
129
|
+
);
|
|
130
|
+
ranges.push(gutter);
|
|
131
|
+
atomic.push(gutter);
|
|
132
|
+
}
|
|
133
|
+
const parent = selectionParent(state, structure);
|
|
134
|
+
return {
|
|
135
|
+
excluded,
|
|
136
|
+
structure,
|
|
137
|
+
decorations: Decoration.set(ranges, true),
|
|
138
|
+
atomicRanges: Decoration.set(atomic, true),
|
|
139
|
+
active: activeGuides(state, structure, parent),
|
|
140
|
+
parent,
|
|
141
|
+
};
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const linePrefix = (line: string) => line.match(/^\s*(?:(?:[-+*]|\d+[.)]|#{1,6})(?:\s+|$))*(?:[=*_~-]+\s*$)?/)?.[0] ?? "";
|
|
145
|
+
|
|
146
|
+
const exclusionSyntax = (line: string) => /:::|`{3}|~{3}|[@#<>|\r]/.test(line) || /^\s*(?:[-*_]\s*){3,}$/.test(line);
|
|
147
|
+
|
|
148
|
+
const exclusionsChanged = (tr: Transaction): boolean => {
|
|
149
|
+
let changed = false;
|
|
150
|
+
tr.changes.iterChangedRanges((fromA, toA, fromB, toB) => {
|
|
151
|
+
if (changed) return;
|
|
152
|
+
const before = tr.startState.doc.lineAt(fromA);
|
|
153
|
+
const after = tr.newDoc.lineAt(fromB);
|
|
154
|
+
// Splitting/joining lines changes container boundaries and named-block lookahead.
|
|
155
|
+
if (tr.startState.doc.lineAt(toA).number !== before.number || tr.newDoc.lineAt(toB).number !== after.number) {
|
|
156
|
+
changed = true;
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// literalMarkdownLines also consumes lists, quotes, HTML and indented code.
|
|
160
|
+
// Their boundaries can change even when an edit is far from a directive.
|
|
161
|
+
const beforePrefix = linePrefix(before.text);
|
|
162
|
+
const afterPrefix = linePrefix(after.text);
|
|
163
|
+
changed =
|
|
164
|
+
exclusionSyntax(before.text) ||
|
|
165
|
+
exclusionSyntax(after.text) ||
|
|
166
|
+
beforePrefix !== afterPrefix ||
|
|
167
|
+
(before.text.slice(beforePrefix.length).trim().length === 0) !== (after.text.slice(afterPrefix.length).trim().length === 0);
|
|
168
|
+
});
|
|
169
|
+
return changed;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export const listStateField = StateField.define<ListState>({
|
|
173
|
+
create: build,
|
|
174
|
+
update(value, tr) {
|
|
175
|
+
if (tr.startState.facet(listExcludedRanges) !== tr.state.facet(listExcludedRanges)) return build(tr.state);
|
|
176
|
+
if (tr.docChanged) {
|
|
177
|
+
// Include inserted text at either edge, including EOF of an unclosed directive.
|
|
178
|
+
const excluded = value.excluded?.map((span) => ({
|
|
179
|
+
from: tr.changes.mapPos(span.from, -1),
|
|
180
|
+
to: tr.changes.mapPos(span.to, 1),
|
|
181
|
+
}));
|
|
182
|
+
return build(tr.state, exclusionsChanged(tr) ? undefined : excluded);
|
|
183
|
+
}
|
|
184
|
+
// Parser progress changes the list tree, but not the source-scanned spans.
|
|
185
|
+
if (treeOrDocChanged(tr)) return build(tr.state, value.excluded);
|
|
186
|
+
const parent = selectionParent(tr.state, value.structure);
|
|
187
|
+
return {
|
|
188
|
+
...value,
|
|
189
|
+
decorations: value.decorations.map(tr.changes),
|
|
190
|
+
atomicRanges: value.atomicRanges.map(tr.changes),
|
|
191
|
+
active: parent === value.parent ? value.active.map(tr.changes) : activeGuides(tr.state, value.structure, parent),
|
|
192
|
+
parent,
|
|
193
|
+
};
|
|
194
|
+
},
|
|
195
|
+
provide(field) {
|
|
196
|
+
return [
|
|
197
|
+
EditorView.decorations.from(field, (value) => value.decorations),
|
|
198
|
+
EditorView.decorations.from(field, (value) => value.active),
|
|
199
|
+
EditorView.atomicRanges.of((view) => view.state.field(field).atomicRanges),
|
|
200
|
+
];
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const taskPress = ViewPlugin.define(() => ({}), {
|
|
205
|
+
eventHandlers: {
|
|
206
|
+
mousedown(event, view) {
|
|
207
|
+
if (!(event.target instanceof Element)) return;
|
|
208
|
+
const checkbox = event.target.closest(".custom-list-task-marker");
|
|
209
|
+
if (!checkbox || checkbox.getAttribute("type") !== "checkbox") return;
|
|
210
|
+
if (view.state.readOnly) return true;
|
|
211
|
+
const line = view.state.doc.lineAt(view.posAtDOM(checkbox));
|
|
212
|
+
const structure = view.state.field(listStateField).structure;
|
|
213
|
+
const item = structure.items[listItemAtLine(structure, line.number)];
|
|
214
|
+
if (item && item.taskFrom !== null && item.taskTo !== null) {
|
|
215
|
+
view.dispatch({ changes: { from: item.taskFrom, to: item.taskTo, insert: item.checked ? "[ ]" : "[x]" } });
|
|
216
|
+
}
|
|
217
|
+
return true;
|
|
218
|
+
},
|
|
219
|
+
// Cancelling mousedown does not stop the box's own toggle on click, so a read-only note keeps the box as it is.
|
|
220
|
+
click(event, view) {
|
|
221
|
+
if (!view.state.readOnly || !(event.target instanceof Element)) return;
|
|
222
|
+
if (!event.target.closest(".custom-list-task-marker")) return;
|
|
223
|
+
event.preventDefault();
|
|
224
|
+
return true;
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const listTheme = EditorView.theme({
|
|
230
|
+
".custom-list": {
|
|
231
|
+
"--list-step": "1.625rem",
|
|
232
|
+
position: "relative",
|
|
233
|
+
paddingLeft: "calc(var(--list-step) * (var(--list-depth) + var(--list-markers, 1))) !important",
|
|
234
|
+
textIndent: "calc(var(--list-step) * (var(--list-depth) + var(--list-markers, 1)) * -1)",
|
|
235
|
+
},
|
|
236
|
+
"@media (max-width: 640px)": { ".custom-list": { "--list-step": "1.375rem" } },
|
|
237
|
+
".custom-list *": { textIndent: "0" },
|
|
238
|
+
".custom-list-gutter": {
|
|
239
|
+
display: "inline-flex",
|
|
240
|
+
justifyContent: "flex-end",
|
|
241
|
+
verticalAlign: "top",
|
|
242
|
+
height: "1lh",
|
|
243
|
+
width: "calc(var(--list-step) * (var(--list-depth) + var(--list-markers, 1)))",
|
|
244
|
+
userSelect: "none",
|
|
245
|
+
},
|
|
246
|
+
".custom-list-guide": {
|
|
247
|
+
position: "absolute",
|
|
248
|
+
top: "0",
|
|
249
|
+
bottom: "0",
|
|
250
|
+
width: "1px",
|
|
251
|
+
left: "calc(var(--list-step) * var(--list-level) + var(--list-step) / 2 - 0.5px)",
|
|
252
|
+
background: "var(--k2b-text, currentColor)",
|
|
253
|
+
pointerEvents: "none",
|
|
254
|
+
opacity:
|
|
255
|
+
"calc(0.14 + 0.24 * clamp(0, 1 - (var(--list-level) - var(--list-active-level, -9)) * (var(--list-level) - var(--list-active-level, -9)), 1))",
|
|
256
|
+
},
|
|
257
|
+
".custom-list-marker": {
|
|
258
|
+
flex: "none",
|
|
259
|
+
boxSizing: "border-box",
|
|
260
|
+
width: "var(--list-step)",
|
|
261
|
+
height: "1lh",
|
|
262
|
+
display: "flex",
|
|
263
|
+
alignItems: "center",
|
|
264
|
+
justifyContent: "center",
|
|
265
|
+
color: "var(--k2b-text-muted)",
|
|
266
|
+
},
|
|
267
|
+
".custom-list-marker-ordered": {
|
|
268
|
+
justifyContent: "flex-end",
|
|
269
|
+
paddingRight: "0.375rem",
|
|
270
|
+
fontVariantNumeric: "tabular-nums",
|
|
271
|
+
color: "var(--k2b-text-secondary)",
|
|
272
|
+
whiteSpace: "nowrap",
|
|
273
|
+
},
|
|
274
|
+
".custom-list-dot": { boxSizing: "border-box", width: "0.375rem", height: "0.375rem", borderRadius: "50%", background: "currentColor" },
|
|
275
|
+
".custom-list-dot-1": { width: "0.4rem", height: "0.4rem", background: "none", border: "1.5px solid currentColor" },
|
|
276
|
+
".custom-list-dot-2": { width: "0.35rem", height: "0.35rem", borderRadius: "1px" },
|
|
277
|
+
".custom-list-task-marker": { width: "1rem", height: "1rem", margin: "0", accentColor: "var(--k2b-action-solid)" },
|
|
278
|
+
".custom-list-task-checked": {
|
|
279
|
+
color: "var(--k2b-placeholder-color)",
|
|
280
|
+
textDecoration: "line-through",
|
|
281
|
+
textDecorationColor: "color-mix(in srgb, currentColor 60%, transparent)",
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
export const listsExtension = (options: MarkdownRangeOptions = {}): Extension => [
|
|
286
|
+
options.excludedRanges ? listExcludedRanges.of(options.excludedRanges) : [],
|
|
287
|
+
listStateField,
|
|
288
|
+
taskPress,
|
|
289
|
+
listTheme,
|
|
290
|
+
];
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mark / Highlight extension for CodeMirror.
|
|
3
|
+
*
|
|
4
|
+
* Detects `==text==` spans and decorates them with a yellow textmarker
|
|
5
|
+
* background. The lezer markdown parser does NOT recognise this syntax
|
|
6
|
+
* (it's not in CommonMark / GFM), so we scan the doc with a regex and
|
|
7
|
+
* apply `Decoration.mark` for each match. For typical note sizes the
|
|
8
|
+
* full-doc scan is microseconds — well within tick budget.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { EditorState, Extension, Range, Transaction } from "@codemirror/state";
|
|
12
|
+
import { RangeSet, StateField } from "@codemirror/state";
|
|
13
|
+
import type { DecorationSet } from "@codemirror/view";
|
|
14
|
+
import { Decoration, EditorView } from "@codemirror/view";
|
|
15
|
+
import { MARK_REGEX } from "./_lib/inline-syntax";
|
|
16
|
+
|
|
17
|
+
const markDecoration = Decoration.mark({ class: "cm-mark-highlight" });
|
|
18
|
+
|
|
19
|
+
type MarkState = {
|
|
20
|
+
decorations: DecorationSet;
|
|
21
|
+
ranges: Array<{ from: number; to: number }>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const findMarks = (state: EditorState): MarkState => {
|
|
25
|
+
const decorations: Range<Decoration>[] = [];
|
|
26
|
+
const ranges: Array<{ from: number; to: number }> = [];
|
|
27
|
+
const text = state.doc.toString();
|
|
28
|
+
for (const match of text.matchAll(MARK_REGEX)) {
|
|
29
|
+
if (match.index === undefined) continue;
|
|
30
|
+
const from = match.index;
|
|
31
|
+
const to = from + match[0].length;
|
|
32
|
+
decorations.push(markDecoration.range(from, to));
|
|
33
|
+
ranges.push({ from, to });
|
|
34
|
+
}
|
|
35
|
+
return { decorations: RangeSet.of(decorations, true), ranges };
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const intersectsRanges = (ranges: Array<{ from: number; to: number }>, from: number, to: number): boolean =>
|
|
39
|
+
ranges.some((range) => from <= range.to && to >= range.from);
|
|
40
|
+
|
|
41
|
+
const changesMightAffectMarks = (tr: Transaction, ranges: Array<{ from: number; to: number }>): boolean => {
|
|
42
|
+
let might = false;
|
|
43
|
+
tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
|
|
44
|
+
if (might) return;
|
|
45
|
+
if (intersectsRanges(ranges, fromA, toA)) {
|
|
46
|
+
might = true;
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (inserted.toString().includes("=")) {
|
|
50
|
+
might = true;
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const from = Math.max(0, fromB - 2);
|
|
54
|
+
const to = Math.min(tr.state.doc.length, toB + 2);
|
|
55
|
+
might = tr.state.doc.sliceString(from, to).includes("=");
|
|
56
|
+
});
|
|
57
|
+
return might;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const mapRanges = (tr: Transaction, ranges: Array<{ from: number; to: number }>): Array<{ from: number; to: number }> =>
|
|
61
|
+
ranges
|
|
62
|
+
.map((range) => ({
|
|
63
|
+
from: tr.changes.mapPos(range.from, 1),
|
|
64
|
+
to: tr.changes.mapPos(range.to, -1),
|
|
65
|
+
}))
|
|
66
|
+
.filter((range) => range.from < range.to);
|
|
67
|
+
|
|
68
|
+
export const markExtension = (): Extension => {
|
|
69
|
+
const stateField = StateField.define<MarkState>({
|
|
70
|
+
create(state) {
|
|
71
|
+
return findMarks(state);
|
|
72
|
+
},
|
|
73
|
+
update(value, tr) {
|
|
74
|
+
if (tr.docChanged) {
|
|
75
|
+
if (changesMightAffectMarks(tr, value.ranges)) return findMarks(tr.state);
|
|
76
|
+
return { decorations: value.decorations.map(tr.changes), ranges: mapRanges(tr, value.ranges) };
|
|
77
|
+
}
|
|
78
|
+
return value;
|
|
79
|
+
},
|
|
80
|
+
provide(field) {
|
|
81
|
+
return EditorView.decorations.from(field, (value) => value.decorations);
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Match the renderer's `<mark>` styling so edit-mode and read-mode look
|
|
86
|
+
// the same. Neon-yellow textmarker — saturated yellow-300 with dark text
|
|
87
|
+
// for the punchy real-highlighter feel (the previous yellow-100 read as
|
|
88
|
+
// muted cream / sand).
|
|
89
|
+
const theme = EditorView.theme({
|
|
90
|
+
".cm-mark-highlight": {
|
|
91
|
+
backgroundColor: "rgb(253 224 71)", // yellow-300
|
|
92
|
+
color: "rgb(24 24 27)", // zinc-900
|
|
93
|
+
borderRadius: "2px",
|
|
94
|
+
padding: "0 2px",
|
|
95
|
+
},
|
|
96
|
+
".dark .cm-mark-highlight": {
|
|
97
|
+
backgroundColor: "rgba(250 204 21 / 0.4)", // yellow-400 @ 40%
|
|
98
|
+
color: "rgb(254 252 232)", // yellow-50
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
return [stateField, theme];
|
|
103
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Code languages load on first use, so hosts that never show a fenced block of a language never download its parser.
|
|
2
|
+
import { markdown as markdownExt, markdownLanguage } from "@codemirror/lang-markdown";
|
|
3
|
+
import { LanguageDescription } from "@codemirror/language";
|
|
4
|
+
import type { Extension } from "@codemirror/state";
|
|
5
|
+
import { sizedImageSyntax } from "./image-syntax";
|
|
6
|
+
|
|
7
|
+
const codeLanguages = [
|
|
8
|
+
LanguageDescription.of({
|
|
9
|
+
name: "JavaScript",
|
|
10
|
+
alias: ["js", "jsx", "ts", "tsx", "typescript"],
|
|
11
|
+
load: async () => (await import("@codemirror/lang-javascript")).javascript({ jsx: true, typescript: true }),
|
|
12
|
+
}),
|
|
13
|
+
LanguageDescription.of({
|
|
14
|
+
name: "Python",
|
|
15
|
+
alias: ["py"],
|
|
16
|
+
load: async () => (await import("@codemirror/lang-python")).python(),
|
|
17
|
+
}),
|
|
18
|
+
LanguageDescription.of({
|
|
19
|
+
name: "SQL",
|
|
20
|
+
load: async () => (await import("@codemirror/lang-sql")).sql(),
|
|
21
|
+
}),
|
|
22
|
+
LanguageDescription.of({
|
|
23
|
+
name: "HTML",
|
|
24
|
+
alias: ["htm"],
|
|
25
|
+
load: async () => {
|
|
26
|
+
const { html } = await import("@codemirror/lang-html");
|
|
27
|
+
return html();
|
|
28
|
+
},
|
|
29
|
+
}),
|
|
30
|
+
LanguageDescription.of({
|
|
31
|
+
name: "CSS",
|
|
32
|
+
load: async () => (await import("@codemirror/lang-css")).css(),
|
|
33
|
+
}),
|
|
34
|
+
LanguageDescription.of({
|
|
35
|
+
name: "JSON",
|
|
36
|
+
load: async () => (await import("@codemirror/lang-json")).json(),
|
|
37
|
+
}),
|
|
38
|
+
LanguageDescription.of({
|
|
39
|
+
name: "Go",
|
|
40
|
+
alias: ["golang"],
|
|
41
|
+
load: async () => (await import("@codemirror/lang-go")).go(),
|
|
42
|
+
}),
|
|
43
|
+
LanguageDescription.of({
|
|
44
|
+
name: "Java",
|
|
45
|
+
load: async () => (await import("@codemirror/lang-java")).java(),
|
|
46
|
+
}),
|
|
47
|
+
LanguageDescription.of({
|
|
48
|
+
name: "XML",
|
|
49
|
+
alias: ["svg"],
|
|
50
|
+
load: async () => (await import("@codemirror/lang-xml")).xml(),
|
|
51
|
+
}),
|
|
52
|
+
LanguageDescription.of({
|
|
53
|
+
name: "YAML",
|
|
54
|
+
alias: ["yml"],
|
|
55
|
+
load: async () => (await import("@codemirror/lang-yaml")).yaml(),
|
|
56
|
+
}),
|
|
57
|
+
LanguageDescription.of({
|
|
58
|
+
name: "Rust",
|
|
59
|
+
alias: ["rs"],
|
|
60
|
+
load: async () => (await import("@codemirror/lang-rust")).rust(),
|
|
61
|
+
}),
|
|
62
|
+
LanguageDescription.of({
|
|
63
|
+
name: "C++",
|
|
64
|
+
alias: ["cpp", "c", "h", "hpp"],
|
|
65
|
+
load: async () => (await import("@codemirror/lang-cpp")).cpp(),
|
|
66
|
+
}),
|
|
67
|
+
LanguageDescription.of({
|
|
68
|
+
name: "PHP",
|
|
69
|
+
load: async () => (await import("@codemirror/lang-php")).php(),
|
|
70
|
+
}),
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
export const markdownExtension: () => Extension = () =>
|
|
74
|
+
markdownExt({
|
|
75
|
+
base: markdownLanguage,
|
|
76
|
+
codeLanguages,
|
|
77
|
+
extensions: [sizedImageSyntax],
|
|
78
|
+
});
|