@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
package/src/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { ariaShortcut, isApple, markdownActions, shortcutLabel } from "./actions";
|
|
2
|
+
export { BubbleMenu, type BubbleMenuProps } from "./BubbleMenu";
|
|
3
|
+
export { Editor, type EditorProps } from "./Editor";
|
|
4
|
+
export { asUndoStep, createEditor, type EditorAction, type EditorHandle } from "./editor-state";
|
|
5
|
+
export { editorMessages } from "./messages";
|
|
6
|
+
export { SlashMenu, type SlashMenuProps } from "./SlashMenu";
|
|
7
|
+
export { type SlashAction, type SlashMenuOptions, slashCompletionSource, slashMenu } from "./slash-menu";
|
|
8
|
+
export {
|
|
9
|
+
Toolbar,
|
|
10
|
+
ToolbarButton,
|
|
11
|
+
type ToolbarCustom,
|
|
12
|
+
type ToolbarGroup,
|
|
13
|
+
type ToolbarItem,
|
|
14
|
+
type ToolbarMenu,
|
|
15
|
+
type ToolbarProps,
|
|
16
|
+
} from "./Toolbar";
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor-zone StateField — shared by editor decoration pipelines
|
|
3
|
+
* that rebuild on doc changes OR when the cursor crosses a "zone"
|
|
4
|
+
* boundary (entered, left, or moved between zones).
|
|
5
|
+
*
|
|
6
|
+
* # Two modes
|
|
7
|
+
*
|
|
8
|
+
* The field supports two operating modes:
|
|
9
|
+
*
|
|
10
|
+
* - **Default mode** (no `incremental` option): rebuild on every
|
|
11
|
+
* doc change and every newly delivered syntax tree, rebuild on
|
|
12
|
+
* selection changes that cross a range boundary. Used by
|
|
13
|
+
* image/link extensions whose `build` already walks the syntax
|
|
14
|
+
* tree incrementally — a full call is cheap.
|
|
15
|
+
*
|
|
16
|
+
* - **Incremental mode** (with `incremental.changesMightAffectSyntax`):
|
|
17
|
+
* on doc change, skip the rebuild when the doc has no relevant
|
|
18
|
+
* marker syntax AND the change doesn't introduce any, OR when
|
|
19
|
+
* the existing ranges are unaffected by the change. A newly
|
|
20
|
+
* delivered syntax tree always rebuilds, also when it arrives
|
|
21
|
+
* with a doc change that carries it past its old end. Used by
|
|
22
|
+
* extensions whose `build` does a full `doc.toString() +
|
|
23
|
+
* regex.matchAll()` pass (katex, tag-pill) — those
|
|
24
|
+
* rebuilds are expensive enough to be worth gating.
|
|
25
|
+
*
|
|
26
|
+
* Each caller (images, links, tag-pill, info-blocks, katex) tracks
|
|
27
|
+
* the source-byte ranges where editing should reveal raw markdown
|
|
28
|
+
* source instead of the rendered widget. Without the cursor-zone
|
|
29
|
+
* gate, every plain cursor move through prose would re-walk the
|
|
30
|
+
* syntax tree / re-scan the doc text — visibly laggy on long notes.
|
|
31
|
+
*
|
|
32
|
+
* # State shape
|
|
33
|
+
*
|
|
34
|
+
* Callers return `{ decorations, ranges, hasSyntax? }` from `build`.
|
|
35
|
+
* `hasSyntax` only matters in incremental mode; default mode
|
|
36
|
+
* ignores it.
|
|
37
|
+
*
|
|
38
|
+
* NOTE: `tables.ts` does NOT use this helper — its state carries
|
|
39
|
+
* an extra cached `blockWidgetDecorations` field that doesn't fit
|
|
40
|
+
* the standard shape.
|
|
41
|
+
*/
|
|
42
|
+
import { forceParsing, syntaxTree } from "@codemirror/language";
|
|
43
|
+
import type { EditorState, Extension, SelectionRange, Transaction } from "@codemirror/state";
|
|
44
|
+
import { Prec, StateEffect, StateField } from "@codemirror/state";
|
|
45
|
+
import type { DecorationSet } from "@codemirror/view";
|
|
46
|
+
import { Decoration, EditorView, keymap, ViewPlugin } from "@codemirror/view";
|
|
47
|
+
|
|
48
|
+
export type CursorZoneRange = { from: number; to: number };
|
|
49
|
+
|
|
50
|
+
export type CursorZoneState = {
|
|
51
|
+
decorations: DecorationSet;
|
|
52
|
+
/** Optional subset of replacement decorations that should behave
|
|
53
|
+
* atomically for cursor movement. Use this for block widgets that
|
|
54
|
+
* hide source text; it prevents CM from resolving mouse/vertical
|
|
55
|
+
* navigation into the replaced source range. */
|
|
56
|
+
atomicDecorations?: DecorationSet;
|
|
57
|
+
/** Source-byte ranges where the cursor "activates" the source
|
|
58
|
+
* (i.e. hides the widget so the user can edit raw markdown). */
|
|
59
|
+
ranges: CursorZoneRange[];
|
|
60
|
+
/** Incremental mode only: `true` if the doc currently contains
|
|
61
|
+
* any relevant marker syntax. Lets the field skip the rebuild
|
|
62
|
+
* when both this AND the per-change predicate return falsy. */
|
|
63
|
+
hasSyntax?: boolean;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type IncrementalOptions = {
|
|
67
|
+
/** Cheap predicate run on every doc-change transaction. Returns
|
|
68
|
+
* `true` if the inserted text OR a small window around the
|
|
69
|
+
* change might introduce / remove relevant marker syntax.
|
|
70
|
+
*
|
|
71
|
+
* Bias: when in doubt, return `true` — a false positive only
|
|
72
|
+
* costs a rebuild (back to baseline). A false negative leaves
|
|
73
|
+
* decorations stale, so the predicate MUST be conservative. */
|
|
74
|
+
changesMightAffectSyntax: (tr: Transaction) => boolean;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const selectionIntersectsRange = (selection: SelectionRange, from: number, to: number): boolean => {
|
|
78
|
+
if (selection.empty) return selection.from >= from && selection.to <= to;
|
|
79
|
+
return selection.from < to && selection.to > from;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const isPointerSelectionTransaction = (tr: Transaction): boolean =>
|
|
83
|
+
!tr.docChanged && !!tr.selection && tr.isUserEvent("select.pointer");
|
|
84
|
+
|
|
85
|
+
/** True when a transaction changed the document or delivered a new syntax tree.
|
|
86
|
+
* CodeMirror parses a long note in steps and hands each further part of the tree
|
|
87
|
+
* over in a transaction without a document change, so a decoration field that
|
|
88
|
+
* reads the tree must rebuild on both. Selection and focus transactions keep the
|
|
89
|
+
* same tree and stay cheap. */
|
|
90
|
+
export const treeOrDocChanged = (tr: Transaction): boolean => tr.docChanged || syntaxTree(tr.startState) !== syntaxTree(tr.state);
|
|
91
|
+
|
|
92
|
+
export const refreshMarkdownDecorationsEffect = StateEffect.define<void>();
|
|
93
|
+
|
|
94
|
+
export const pointerSelectionHoldEffect = StateEffect.define<boolean>();
|
|
95
|
+
|
|
96
|
+
/** True while a mouse or pen button is down in the editor. Revealing Markdown then would move the text under the
|
|
97
|
+
* pointer mid-drag, so selection-driven decorations wait for the refresh after release. */
|
|
98
|
+
export const pointerSelectionHeld = StateField.define<boolean>({
|
|
99
|
+
create: () => false,
|
|
100
|
+
update(value, tr) {
|
|
101
|
+
for (const effect of tr.effects) {
|
|
102
|
+
if (effect.is(refreshMarkdownDecorationsEffect)) return false;
|
|
103
|
+
if (effect.is(pointerSelectionHoldEffect)) value = effect.value;
|
|
104
|
+
}
|
|
105
|
+
return value;
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
export const holdsDecorations = (tr: Transaction): boolean =>
|
|
110
|
+
tr.startState.field(pointerSelectionHeld, false) === true &&
|
|
111
|
+
tr.state.field(pointerSelectionHeld, false) === true &&
|
|
112
|
+
isPointerSelectionTransaction(tr);
|
|
113
|
+
|
|
114
|
+
export const initialMarkdownDecorationRefreshExtension = (): Extension =>
|
|
115
|
+
ViewPlugin.fromClass(
|
|
116
|
+
class {
|
|
117
|
+
private raf: number | null = null;
|
|
118
|
+
private attempts = 0;
|
|
119
|
+
|
|
120
|
+
constructor(private view: EditorView) {
|
|
121
|
+
this.schedule();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private schedule() {
|
|
125
|
+
this.raf = window.requestAnimationFrame(() => {
|
|
126
|
+
this.raf = null;
|
|
127
|
+
this.attempts += 1;
|
|
128
|
+
// Markdown parsing is viewport/background-driven. Force the parse
|
|
129
|
+
// to the end after mount so syntax-tree based widgets don't stay raw
|
|
130
|
+
// until the parser reaches them. forceParsing hands a newer tree over
|
|
131
|
+
// in a transaction, and every tree-based field rebuilds on it.
|
|
132
|
+
const parsed = forceParsing(this.view, this.view.state.doc.length, 50);
|
|
133
|
+
if (!parsed && this.attempts < 4) this.schedule();
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
destroy() {
|
|
138
|
+
if (this.raf !== null) window.cancelAnimationFrame(this.raf);
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
export const pointerSelectionMarkdownRefreshExtension = (): Extension => [
|
|
144
|
+
pointerSelectionHeld,
|
|
145
|
+
ViewPlugin.fromClass(
|
|
146
|
+
class {
|
|
147
|
+
private timeout: number | null = null;
|
|
148
|
+
|
|
149
|
+
constructor(private view: EditorView) {
|
|
150
|
+
view.contentDOM.addEventListener("pointerdown", this.onPointerDown);
|
|
151
|
+
document.addEventListener("pointerup", this.onPointerDone);
|
|
152
|
+
document.addEventListener("pointercancel", this.onPointerDone);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private onPointerDown = (event: PointerEvent) => {
|
|
156
|
+
// Native touch selection must keep working on phones, without a hold.
|
|
157
|
+
if (event.pointerType === "touch" || event.button !== 0) return;
|
|
158
|
+
if (this.timeout !== null) window.clearTimeout(this.timeout);
|
|
159
|
+
this.timeout = null;
|
|
160
|
+
this.view.dispatch({ effects: pointerSelectionHoldEffect.of(true) });
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
private onPointerDone = () => {
|
|
164
|
+
if (!this.view.state.field(pointerSelectionHeld) || this.timeout !== null) return;
|
|
165
|
+
this.timeout = window.setTimeout(() => {
|
|
166
|
+
this.timeout = null;
|
|
167
|
+
this.view.dispatch({ effects: refreshMarkdownDecorationsEffect.of() });
|
|
168
|
+
}, 0);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
destroy() {
|
|
172
|
+
this.view.contentDOM.removeEventListener("pointerdown", this.onPointerDown);
|
|
173
|
+
document.removeEventListener("pointerup", this.onPointerDone);
|
|
174
|
+
document.removeEventListener("pointercancel", this.onPointerDone);
|
|
175
|
+
if (this.timeout !== null) window.clearTimeout(this.timeout);
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
),
|
|
179
|
+
];
|
|
180
|
+
|
|
181
|
+
export const blockWidgetLineNavigationExtension = <T>(
|
|
182
|
+
field: StateField<T>,
|
|
183
|
+
getBlockDecorations: (value: T) => DecorationSet | undefined,
|
|
184
|
+
): Extension => {
|
|
185
|
+
const findWidgetRange = (set: DecorationSet | undefined, from: number, to: number): CursorZoneRange | null => {
|
|
186
|
+
let found: CursorZoneRange | null = null;
|
|
187
|
+
set?.between(from, to, (decoFrom, decoTo) => {
|
|
188
|
+
found = { from: decoFrom, to: decoTo };
|
|
189
|
+
return false;
|
|
190
|
+
});
|
|
191
|
+
return found;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const navigateLine = (view: EditorView, dir: -1 | 1, extend: boolean): boolean => {
|
|
195
|
+
const value = view.state.field(field, false);
|
|
196
|
+
if (!value) return false;
|
|
197
|
+
const blockDecorations = getBlockDecorations(value);
|
|
198
|
+
if (!blockDecorations || blockDecorations === Decoration.none) return false;
|
|
199
|
+
|
|
200
|
+
const sel = view.state.selection.main;
|
|
201
|
+
const caretLine = view.state.doc.lineAt(sel.head);
|
|
202
|
+
const caretCol = sel.head - caretLine.from;
|
|
203
|
+
let targetLineNumber = caretLine.number + dir;
|
|
204
|
+
let skippedWidget = false;
|
|
205
|
+
|
|
206
|
+
while (targetLineNumber >= 1 && targetLineNumber <= view.state.doc.lines) {
|
|
207
|
+
const line = view.state.doc.line(targetLineNumber);
|
|
208
|
+
const widget = findWidgetRange(blockDecorations, line.from, line.to);
|
|
209
|
+
if (!widget) break;
|
|
210
|
+
skippedWidget = true;
|
|
211
|
+
const widgetStartLine = view.state.doc.lineAt(widget.from).number;
|
|
212
|
+
const widgetEndLine = view.state.doc.lineAt(widget.to).number;
|
|
213
|
+
targetLineNumber = dir < 0 ? widgetStartLine - 1 : widgetEndLine + 1;
|
|
214
|
+
}
|
|
215
|
+
if (!skippedWidget) return false;
|
|
216
|
+
|
|
217
|
+
const target =
|
|
218
|
+
targetLineNumber < 1
|
|
219
|
+
? 0
|
|
220
|
+
: targetLineNumber > view.state.doc.lines
|
|
221
|
+
? view.state.doc.length
|
|
222
|
+
: view.state.doc.line(targetLineNumber).from + Math.min(caretCol, view.state.doc.line(targetLineNumber).length);
|
|
223
|
+
|
|
224
|
+
view.dispatch({
|
|
225
|
+
selection: extend ? { anchor: sel.anchor, head: target } : { anchor: target },
|
|
226
|
+
scrollIntoView: true,
|
|
227
|
+
userEvent: "select",
|
|
228
|
+
});
|
|
229
|
+
return true;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
return Prec.highest(
|
|
233
|
+
keymap.of([
|
|
234
|
+
{ key: "ArrowUp", run: (view) => navigateLine(view, -1, false) },
|
|
235
|
+
{ key: "Shift-ArrowUp", run: (view) => navigateLine(view, -1, true) },
|
|
236
|
+
{ key: "ArrowDown", run: (view) => navigateLine(view, 1, false) },
|
|
237
|
+
{ key: "Shift-ArrowDown", run: (view) => navigateLine(view, 1, true) },
|
|
238
|
+
]),
|
|
239
|
+
);
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
/** Identity of the range the cursor currently sits inside, or
|
|
243
|
+
* `null` if it sits outside all ranges. A cursor-only transaction
|
|
244
|
+
* needs a rebuild only when this answer changes. */
|
|
245
|
+
const cursorKey = (state: EditorState, ranges: CursorZoneRange[]): string | null => {
|
|
246
|
+
if (ranges.length === 0) return null;
|
|
247
|
+
const cursor = state.selection.main;
|
|
248
|
+
const hits: number[] = [];
|
|
249
|
+
for (const r of ranges) {
|
|
250
|
+
if (selectionIntersectsRange(cursor, r.from, r.to)) hits.push(r.from);
|
|
251
|
+
}
|
|
252
|
+
return hits.length > 0 ? hits.sort((a, b) => a - b).join(",") : null;
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const intersectsAnyRange = (ranges: CursorZoneRange[], from: number, to: number): boolean =>
|
|
256
|
+
ranges.some((r) => (from >= r.from && from < r.to) || (to > r.from && to <= r.to) || (from <= r.from && to >= r.to));
|
|
257
|
+
|
|
258
|
+
/** True if any of the transaction's changed regions intersects (or
|
|
259
|
+
* touches the boundary of) one of `ranges`. Pre-change positions
|
|
260
|
+
* are used because `ranges` haven't been mapped through `tr` yet. */
|
|
261
|
+
const changesIntersectRanges = (tr: Transaction, ranges: CursorZoneRange[]): boolean => {
|
|
262
|
+
if (ranges.length === 0) return false;
|
|
263
|
+
let intersects = false;
|
|
264
|
+
tr.changes.iterChangedRanges((fromA, toA) => {
|
|
265
|
+
if (intersects) return;
|
|
266
|
+
intersects = intersectsAnyRange(ranges, fromA, toA) || ranges.some((r) => fromA === r.from || fromA === r.to);
|
|
267
|
+
});
|
|
268
|
+
return intersects;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
/** Map pre-change ranges through a transaction's changes so they
|
|
272
|
+
* reflect the post-change document. `from` biases right, `to`
|
|
273
|
+
* biases left — keeps the range tight around the original span. */
|
|
274
|
+
const mapRanges = (tr: Transaction, ranges: CursorZoneRange[]): CursorZoneRange[] =>
|
|
275
|
+
ranges
|
|
276
|
+
.map((r) => ({
|
|
277
|
+
from: tr.changes.mapPos(r.from, 1),
|
|
278
|
+
to: tr.changes.mapPos(r.to, -1),
|
|
279
|
+
}))
|
|
280
|
+
.filter((r) => r.from < r.to);
|
|
281
|
+
|
|
282
|
+
/** True when a doc change also carried the syntax tree past the end of the previous,
|
|
283
|
+
* partial tree. CodeMirror parses up to the viewport while it applies an edit, so an
|
|
284
|
+
* edit after a jump into the unparsed end of a long note can bring new code or math
|
|
285
|
+
* nodes that the incremental skip checks can't see. */
|
|
286
|
+
const treeGrewPastOldEnd = (tr: Transaction): boolean =>
|
|
287
|
+
syntaxTree(tr.state).length > tr.changes.mapPos(syntaxTree(tr.startState).length, 1);
|
|
288
|
+
|
|
289
|
+
export const cursorZoneStateField = (
|
|
290
|
+
build: (state: EditorState) => CursorZoneState,
|
|
291
|
+
incremental?: IncrementalOptions,
|
|
292
|
+
): StateField<CursorZoneState> => {
|
|
293
|
+
return StateField.define<CursorZoneState>({
|
|
294
|
+
create: build,
|
|
295
|
+
update(value, tr) {
|
|
296
|
+
if (tr.effects.some((effect) => effect.is(refreshMarkdownDecorationsEffect))) {
|
|
297
|
+
return build(tr.state);
|
|
298
|
+
}
|
|
299
|
+
if (tr.docChanged) {
|
|
300
|
+
if (incremental && !treeGrewPastOldEnd(tr)) {
|
|
301
|
+
const decorations = value.decorations.map(tr.changes);
|
|
302
|
+
const atomicDecorations = value.atomicDecorations?.map(tr.changes);
|
|
303
|
+
const mightAffect = incremental.changesMightAffectSyntax(tr);
|
|
304
|
+
// No syntax anywhere AND change doesn't introduce any →
|
|
305
|
+
// nothing to scan, just keep an empty state.
|
|
306
|
+
if (!value.hasSyntax && !mightAffect) {
|
|
307
|
+
return { decorations, atomicDecorations, ranges: [], hasSyntax: false };
|
|
308
|
+
}
|
|
309
|
+
// Have syntax, change misses the ranges AND introduces
|
|
310
|
+
// no new markers → keep current decorations, just remap
|
|
311
|
+
// range positions through the change.
|
|
312
|
+
if (value.hasSyntax && !changesIntersectRanges(tr, value.ranges) && !mightAffect) {
|
|
313
|
+
return {
|
|
314
|
+
decorations,
|
|
315
|
+
atomicDecorations,
|
|
316
|
+
ranges: mapRanges(tr, value.ranges),
|
|
317
|
+
hasSyntax: true,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return build(tr.state);
|
|
322
|
+
}
|
|
323
|
+
if (treeOrDocChanged(tr)) return build(tr.state);
|
|
324
|
+
if (holdsDecorations(tr)) return value;
|
|
325
|
+
if (!tr.selection) return value;
|
|
326
|
+
const oldKey = cursorKey(tr.startState, value.ranges);
|
|
327
|
+
const newKey = cursorKey(tr.state, value.ranges);
|
|
328
|
+
if (oldKey === newKey) return value;
|
|
329
|
+
return build(tr.state);
|
|
330
|
+
},
|
|
331
|
+
provide(field) {
|
|
332
|
+
return [
|
|
333
|
+
EditorView.decorations.from(field, (v) => v.decorations),
|
|
334
|
+
EditorView.atomicRanges.of((view) => view.state.field(field).atomicDecorations ?? Decoration.none),
|
|
335
|
+
];
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Text syntax shared by editor decorations and the server preview.
|
|
2
|
+
export const TAG_REGEX = /(^|\s)#([a-zA-Z][\w-]*(?:\/[\w-]+)*)/g;
|
|
3
|
+
export const MARK_REGEX = /==(?!=)([^\s=][^=]*?[^\s=]|[^\s=])==(?!=)/g;
|
|
4
|
+
export const SUB_REGEX = /(?<!~)~(?!~)([^~\s]+)~(?!~)/g;
|
|
5
|
+
export const SUP_REGEX = /\^([^\^\s]+)\^/g;
|
|
6
|
+
|
|
7
|
+
// CodeMirror highlightSpecialChars defaults (tabs keep their native tab-size).
|
|
8
|
+
export const SPECIAL_CHAR_REGEX =
|
|
9
|
+
/[\u0000-\u0008\u000a-\u001f\u007f-\u009f\u00ad\u061c\u200b\u200e\u200f\u2028\u2029\u202d\u202e\u2066\u2067\u2069\ufeff\ufff9-\ufffc]/gu;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { EditorSelection } from "@codemirror/state";
|
|
2
|
+
import type { EditorView } from "@codemirror/view";
|
|
3
|
+
|
|
4
|
+
/** A pointer that moved further than this between press and click dragged a selection. */
|
|
5
|
+
const CLICK_SLOP_PX = 4;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The element opens its link on click and, where the editor is read-only, from the keyboard as a link.
|
|
9
|
+
*
|
|
10
|
+
* A mouse or pen press reaches CodeMirror, so a drag that starts on the element selects text. The pointer hold keeps
|
|
11
|
+
* the element rendered until release, so a click still lands on it; the click then restores the selection from before
|
|
12
|
+
* the press and opens the link. A touch press keeps the native tap: it moves no caret, and the tap opens the link.
|
|
13
|
+
* The widget passes `mousedown` to CodeMirror and ignores every other event.
|
|
14
|
+
*/
|
|
15
|
+
export const makeOpener = (el: HTMLElement, view: EditorView, open: () => void) => {
|
|
16
|
+
el.classList.add("cm-link-open");
|
|
17
|
+
el.setAttribute("role", "link");
|
|
18
|
+
if (view.state.readOnly) el.tabIndex = 0;
|
|
19
|
+
let touch = false;
|
|
20
|
+
let press: { x: number; y: number; selection: EditorSelection } | null = null;
|
|
21
|
+
el.onpointerdown = (e) => {
|
|
22
|
+
touch = e.pointerType === "touch";
|
|
23
|
+
};
|
|
24
|
+
el.onmousedown = (e) => {
|
|
25
|
+
press = null;
|
|
26
|
+
if (touch) {
|
|
27
|
+
e.preventDefault();
|
|
28
|
+
e.stopPropagation();
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
press = { x: e.clientX, y: e.clientY, selection: view.state.selection };
|
|
32
|
+
};
|
|
33
|
+
el.onclick = (e) => {
|
|
34
|
+
e.preventDefault();
|
|
35
|
+
e.stopPropagation();
|
|
36
|
+
const start = press;
|
|
37
|
+
press = null;
|
|
38
|
+
if (start) {
|
|
39
|
+
if (Math.hypot(e.clientX - start.x, e.clientY - start.y) > CLICK_SLOP_PX) return;
|
|
40
|
+
view.dispatch({ selection: start.selection });
|
|
41
|
+
}
|
|
42
|
+
open();
|
|
43
|
+
};
|
|
44
|
+
el.onkeydown = (e) => {
|
|
45
|
+
if (e.key !== "Enter") return;
|
|
46
|
+
e.preventDefault();
|
|
47
|
+
e.stopPropagation();
|
|
48
|
+
open();
|
|
49
|
+
};
|
|
50
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Line } from "@codemirror/state";
|
|
2
|
+
import type { SyntaxNode, Tree } from "@lezer/common";
|
|
3
|
+
import type { ExcludedSpan } from "./list-structure";
|
|
4
|
+
|
|
5
|
+
export const LIST_ITEM = /^( *)([-*+]|\d{1,9}[.)])( +|$)/;
|
|
6
|
+
const CODE = new Set(["FencedCode", "CodeBlock"]);
|
|
7
|
+
|
|
8
|
+
export type ListItemLine = { indent: number; content: number; marker: string };
|
|
9
|
+
|
|
10
|
+
export const leadingWhitespace = (text: string) => text.length - text.trimStart().length;
|
|
11
|
+
|
|
12
|
+
export const inCode = (tree: Tree, pos: number): boolean => {
|
|
13
|
+
for (let node: SyntaxNode | null = tree.resolveInner(pos, 1); node; node = node.parent) {
|
|
14
|
+
if (CODE.has(node.name)) return true;
|
|
15
|
+
}
|
|
16
|
+
return false;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A line that starts with a list marker outside code, data and directives. The line text decides, not the parse:
|
|
21
|
+
* an empty item right after Tab reads as paragraph text until someone types, and Shift+Tab
|
|
22
|
+
* must still lift it.
|
|
23
|
+
*/
|
|
24
|
+
export const listItemLine = (tree: Tree, line: Line, excluded: () => readonly ExcludedSpan[]): ListItemLine | null => {
|
|
25
|
+
const match = LIST_ITEM.exec(line.text);
|
|
26
|
+
if (!match || /^([-*_])(?: *\1){2,} *$/.test(line.text.trimStart())) return null;
|
|
27
|
+
const indent = match[1]!.length;
|
|
28
|
+
if (inCode(tree, line.from + indent) || excluded().some((span) => line.from + indent >= span.from && line.from + indent < span.to))
|
|
29
|
+
return null;
|
|
30
|
+
const marker = match[2]!.length;
|
|
31
|
+
const spaces = match[3]!.length;
|
|
32
|
+
const rest = line.text.length > match[0].length;
|
|
33
|
+
return { indent, marker: match[2]!, content: indent + marker + (rest && spaces >= 1 && spaces <= 4 ? spaces : 1) };
|
|
34
|
+
};
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The list items of a note as Markdown parses them: real nesting depth, the
|
|
3
|
+
* number Book shows, and the source ranges of markers and content. Editor
|
|
4
|
+
* display, renumbering, moving items and list-aware paste share this model.
|
|
5
|
+
*/
|
|
6
|
+
import { syntaxTree } from "@codemirror/language";
|
|
7
|
+
import { type ChangeSet, type ChangeSpec, type EditorState, Facet } from "@codemirror/state";
|
|
8
|
+
import type { Tree } from "@lezer/common";
|
|
9
|
+
|
|
10
|
+
export type ListItemKind = "bullet" | "ordered" | "task";
|
|
11
|
+
export type ListItem = {
|
|
12
|
+
/** 1-based marker line and its start position. */
|
|
13
|
+
line: number;
|
|
14
|
+
from: number;
|
|
15
|
+
/** End of the ListItem node, including its descendants. */
|
|
16
|
+
to: number;
|
|
17
|
+
depth: number;
|
|
18
|
+
orderedDepth: number;
|
|
19
|
+
kind: ListItemKind;
|
|
20
|
+
ordered: boolean;
|
|
21
|
+
start: number | null;
|
|
22
|
+
ordinal: number | null;
|
|
23
|
+
checked: boolean;
|
|
24
|
+
markerFrom: number;
|
|
25
|
+
markerTo: number;
|
|
26
|
+
taskFrom: number | null;
|
|
27
|
+
taskTo: number | null;
|
|
28
|
+
contentFrom: number;
|
|
29
|
+
parent: number;
|
|
30
|
+
/** Source identity of the containing list, including separate lists of the same kind. */
|
|
31
|
+
listFrom: number;
|
|
32
|
+
/** Index one past the last descendant. */
|
|
33
|
+
subtreeEnd: number;
|
|
34
|
+
};
|
|
35
|
+
export type ListStructure = { items: readonly ListItem[] };
|
|
36
|
+
export type ExcludedSpan = { from: number; to: number };
|
|
37
|
+
|
|
38
|
+
export type MarkdownRangeOptions = {
|
|
39
|
+
/** Source ranges that must keep literal text, such as data or query directives. */
|
|
40
|
+
excludedRanges?: (markdown: string) => readonly ExcludedSpan[];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Shared by list display and commands, including commands used without rich list display. */
|
|
44
|
+
export const listExcludedRanges = Facet.define<NonNullable<MarkdownRangeOptions["excludedRanges"]>>({
|
|
45
|
+
combine: (values) => [...new Set(values)],
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/** Full source scan; callers cache these spans independently of parser progress. */
|
|
49
|
+
export const excludedSpans = (state: EditorState): ExcludedSpan[] =>
|
|
50
|
+
state.facet(listExcludedRanges).flatMap((ranges) => ranges(state.doc.toString()).map(({ from, to }) => ({ from, to })));
|
|
51
|
+
|
|
52
|
+
/** Items outside quotes, data blocks and directives. Optional ranges cover whole top-level list blocks; exclusions are read only when a list exists. */
|
|
53
|
+
export const listStructure = (
|
|
54
|
+
state: EditorState,
|
|
55
|
+
excludedSpans: () => readonly ExcludedSpan[],
|
|
56
|
+
range?: ExcludedSpan,
|
|
57
|
+
tree: Tree = syntaxTree(state),
|
|
58
|
+
): ListStructure => {
|
|
59
|
+
const items: ListItem[] = [];
|
|
60
|
+
const parents: Array<{ from: number; index: number }> = [];
|
|
61
|
+
const lists: Array<{ ordered: boolean; start: number | null; index: number }> = [];
|
|
62
|
+
tree.iterate({
|
|
63
|
+
from: range?.from,
|
|
64
|
+
to: range?.to,
|
|
65
|
+
enter(ref) {
|
|
66
|
+
if (ref.name === "Blockquote") return false;
|
|
67
|
+
const node = ref.node;
|
|
68
|
+
if (ref.name === "BulletList" || ref.name === "OrderedList") {
|
|
69
|
+
if (range && (ref.to <= range.from || ref.from >= range.to)) return false;
|
|
70
|
+
const ordered = ref.name === "OrderedList";
|
|
71
|
+
const mark = node.getChild("ListItem")?.getChild("ListMark");
|
|
72
|
+
lists.push({ ordered, start: ordered && mark ? Number.parseInt(state.sliceDoc(mark.from, mark.to), 10) : null, index: 0 });
|
|
73
|
+
}
|
|
74
|
+
if (ref.name !== "ListItem" || !["BulletList", "OrderedList"].includes(node.parent?.name ?? "")) return;
|
|
75
|
+
const list = lists.at(-1);
|
|
76
|
+
const mark = node.getChild("ListMark");
|
|
77
|
+
if (!list || !mark) return false;
|
|
78
|
+
const ordinal = list.start === null ? null : list.start + list.index;
|
|
79
|
+
list.index++;
|
|
80
|
+
const line = state.doc.lineAt(mark.from);
|
|
81
|
+
if (items.at(-1)?.line === line.number || excludedSpans().some((span) => mark.from >= span.from && mark.from < span.to)) return false;
|
|
82
|
+
const task = node.getChild("Task")?.getChild("TaskMarker");
|
|
83
|
+
let contentFrom = task?.to ?? mark.to;
|
|
84
|
+
while (contentFrom < line.to && /[ \t]/.test(state.sliceDoc(contentFrom, contentFrom + 1))) contentFrom++;
|
|
85
|
+
const parent = parents.at(-1)?.index ?? -1;
|
|
86
|
+
const ancestor = items[parent];
|
|
87
|
+
const index = items.length;
|
|
88
|
+
items.push({
|
|
89
|
+
line: line.number,
|
|
90
|
+
from: line.from,
|
|
91
|
+
to: node.to,
|
|
92
|
+
depth: parents.length,
|
|
93
|
+
orderedDepth: ancestor ? ancestor.orderedDepth + Number(ancestor.ordered) : 0,
|
|
94
|
+
kind: task ? "task" : list.ordered ? "ordered" : "bullet",
|
|
95
|
+
ordered: list.ordered,
|
|
96
|
+
start: list.start,
|
|
97
|
+
ordinal,
|
|
98
|
+
checked: task ? state.sliceDoc(task.from, task.to) !== "[ ]" : false,
|
|
99
|
+
markerFrom: mark.from,
|
|
100
|
+
markerTo: mark.to,
|
|
101
|
+
taskFrom: task?.from ?? null,
|
|
102
|
+
taskTo: task?.to ?? null,
|
|
103
|
+
contentFrom,
|
|
104
|
+
parent,
|
|
105
|
+
listFrom: node.parent!.from,
|
|
106
|
+
subtreeEnd: index + 1,
|
|
107
|
+
});
|
|
108
|
+
parents.push({ from: node.from, index });
|
|
109
|
+
},
|
|
110
|
+
leave(ref) {
|
|
111
|
+
if (ref.name === "BulletList" || ref.name === "OrderedList") lists.pop();
|
|
112
|
+
if (ref.name === "ListItem" && parents.at(-1)?.from === ref.from) {
|
|
113
|
+
const parent = parents.pop();
|
|
114
|
+
if (parent) items[parent.index]!.subtreeEnd = items.length;
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
return { items };
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/** Rewrite markers and shift continuation text and children by the width delta. Source-only items also cover empty, not-yet-parsed items. */
|
|
122
|
+
export const markerChanges = (
|
|
123
|
+
state: EditorState,
|
|
124
|
+
structure: { items: readonly Pick<ListItem, "line" | "to" | "markerFrom" | "markerTo">[] },
|
|
125
|
+
markers: ReadonlyMap<number, string>,
|
|
126
|
+
): ChangeSet => {
|
|
127
|
+
const changes: ChangeSpec[] = [];
|
|
128
|
+
const deltas = new Map<number, number>();
|
|
129
|
+
for (const [index, marker] of markers) {
|
|
130
|
+
const item = structure.items[index]!;
|
|
131
|
+
const old = state.sliceDoc(item.markerFrom, item.markerTo);
|
|
132
|
+
if (old === marker) continue;
|
|
133
|
+
let prefix = 0;
|
|
134
|
+
while (prefix < old.length && prefix < marker.length && old[prefix] === marker[prefix]) prefix++;
|
|
135
|
+
let suffix = 0;
|
|
136
|
+
while (
|
|
137
|
+
suffix < old.length - prefix &&
|
|
138
|
+
suffix < marker.length - prefix &&
|
|
139
|
+
old[old.length - suffix - 1] === marker[marker.length - suffix - 1]
|
|
140
|
+
)
|
|
141
|
+
suffix++;
|
|
142
|
+
changes.push({ from: item.markerFrom + prefix, to: item.markerTo - suffix, insert: marker.slice(prefix, marker.length - suffix) });
|
|
143
|
+
const delta = marker.length - (item.markerTo - item.markerFrom);
|
|
144
|
+
if (!delta) continue;
|
|
145
|
+
for (let number = item.line + 1; number <= state.doc.lineAt(item.to).number; number++) {
|
|
146
|
+
const line = state.doc.line(number);
|
|
147
|
+
if (line.text.trim()) deltas.set(number, (deltas.get(number) ?? 0) + delta);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
for (const [number, delta] of deltas) {
|
|
151
|
+
const line = state.doc.line(number);
|
|
152
|
+
if (delta > 0) changes.push({ from: line.from, insert: " ".repeat(delta) });
|
|
153
|
+
else if (delta < 0) changes.push({ from: line.from, to: line.from + Math.min(-delta, /^ */.exec(line.text)![0].length) });
|
|
154
|
+
}
|
|
155
|
+
return state.changes(changes);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/** Repair only the specified ordered lists, using their supplied starts and preserving delimiters. */
|
|
159
|
+
export const renumberChanges = (state: EditorState, structure: ListStructure, starts: ReadonlyMap<number, number>): ChangeSet => {
|
|
160
|
+
const markers = new Map<number, string>();
|
|
161
|
+
structure.items.forEach((item, index) => {
|
|
162
|
+
const start = starts.get(item.listFrom);
|
|
163
|
+
if (!item.ordered || start === undefined || item.ordinal === null || item.start === null) return;
|
|
164
|
+
const written = state.sliceDoc(item.markerFrom, item.markerTo);
|
|
165
|
+
const marker = `${item.ordinal - item.start + start}${written.at(-1)}`;
|
|
166
|
+
if (written !== marker) markers.set(index, marker);
|
|
167
|
+
});
|
|
168
|
+
return markerChanges(state, structure, markers);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
export const siblingIndex = (structure: ListStructure, index: number, direction: 1 | -1): number => {
|
|
172
|
+
const item = structure.items[index]!;
|
|
173
|
+
for (let next = direction > 0 ? item.subtreeEnd : index - 1; next >= 0 && next < structure.items.length; next += direction) {
|
|
174
|
+
const candidate = structure.items[next]!;
|
|
175
|
+
if (candidate.depth < item.depth) break;
|
|
176
|
+
if (candidate.depth === item.depth) return candidate.parent === item.parent && candidate.listFrom === item.listFrom ? next : -1;
|
|
177
|
+
}
|
|
178
|
+
return -1;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/** Binary search for the item whose marker is on this line. */
|
|
182
|
+
export const listItemAtLine = (structure: ListStructure, line: number): number => {
|
|
183
|
+
let low = 0;
|
|
184
|
+
let high = structure.items.length - 1;
|
|
185
|
+
while (low <= high) {
|
|
186
|
+
const middle = (low + high) >>> 1;
|
|
187
|
+
const candidate = structure.items[middle]!;
|
|
188
|
+
if (candidate.line === line) return middle;
|
|
189
|
+
if (candidate.line < line) low = middle + 1;
|
|
190
|
+
else high = middle - 1;
|
|
191
|
+
}
|
|
192
|
+
return -1;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const romanDigits: readonly (readonly [number, string])[] = [
|
|
196
|
+
[1000, "m"],
|
|
197
|
+
[900, "cm"],
|
|
198
|
+
[500, "d"],
|
|
199
|
+
[400, "cd"],
|
|
200
|
+
[100, "c"],
|
|
201
|
+
[90, "xc"],
|
|
202
|
+
[50, "l"],
|
|
203
|
+
[40, "xl"],
|
|
204
|
+
[10, "x"],
|
|
205
|
+
[9, "ix"],
|
|
206
|
+
[5, "v"],
|
|
207
|
+
[4, "iv"],
|
|
208
|
+
[1, "i"],
|
|
209
|
+
];
|
|
210
|
+
|
|
211
|
+
/** Labels cycle through 1., a., i. for six ordered levels; deeper levels keep roman like Book and the PDF, with CSS's decimal fallback. */
|
|
212
|
+
export const orderedLabel = (ordinal: number, orderedDepth: number): string => {
|
|
213
|
+
const style = Math.min(orderedDepth, 5) % 3;
|
|
214
|
+
let label = "";
|
|
215
|
+
if (style === 1 && ordinal >= 1) {
|
|
216
|
+
for (let n = ordinal; n > 0; n = Math.floor((n - 1) / 26)) label = String.fromCharCode(97 + ((n - 1) % 26)) + label;
|
|
217
|
+
} else if (style === 2 && ordinal >= 1 && ordinal <= 3999) {
|
|
218
|
+
let n = ordinal;
|
|
219
|
+
for (const [value, digit] of romanDigits) {
|
|
220
|
+
while (n >= value) {
|
|
221
|
+
label += digit;
|
|
222
|
+
n -= value;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
} else label = String(ordinal);
|
|
226
|
+
return `${label}.`;
|
|
227
|
+
};
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
/** KaTeX source syntax, shared by the math widgets and every decoration that must leave math alone. */
|
|
2
|
+
export const blockMathPattern = (): RegExp => /\$\$([^$]+)\$\$|\\\[(.*?)\\\]/gs;
|
|
3
|
+
export const inlineMathPattern = (): RegExp => /(?<!\$)\$(?!\$)([^\n$]+)\$(?!\$)|\\\(([^)\n]*?)\\\)/g;
|