@dxos/ui-editor 0.0.0 → 0.8.4-main.05e74ebcff
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 +102 -5
- package/README.md +1 -1
- package/dist/lib/browser/index.mjs +8657 -0
- package/dist/lib/browser/index.mjs.map +7 -0
- package/dist/lib/browser/meta.json +1 -0
- package/dist/lib/browser/types/index.mjs +33 -0
- package/dist/lib/browser/types/index.mjs.map +7 -0
- package/dist/lib/node-esm/index.mjs +8659 -0
- package/dist/lib/node-esm/index.mjs.map +7 -0
- package/dist/lib/node-esm/meta.json +1 -0
- package/dist/lib/node-esm/types/index.mjs +35 -0
- package/dist/lib/node-esm/types/index.mjs.map +7 -0
- package/dist/types/src/defaults.d.ts +6 -0
- package/dist/types/src/defaults.d.ts.map +1 -0
- package/dist/types/src/extensions/annotations.d.ts +9 -0
- package/dist/types/src/extensions/annotations.d.ts.map +1 -0
- package/dist/types/src/extensions/autocomplete/autocomplete.d.ts +17 -0
- package/dist/types/src/extensions/autocomplete/autocomplete.d.ts.map +1 -0
- package/dist/types/src/extensions/autocomplete/index.d.ts +5 -0
- package/dist/types/src/extensions/autocomplete/index.d.ts.map +1 -0
- package/dist/types/src/extensions/autocomplete/match.d.ts +13 -0
- package/dist/types/src/extensions/autocomplete/match.d.ts.map +1 -0
- package/dist/types/src/extensions/autocomplete/placeholder.d.ts +23 -0
- package/dist/types/src/extensions/autocomplete/placeholder.d.ts.map +1 -0
- package/dist/types/src/extensions/autocomplete/typeahead.d.ts +10 -0
- package/dist/types/src/extensions/autocomplete/typeahead.d.ts.map +1 -0
- package/dist/types/src/extensions/automerge/automerge.d.ts +4 -0
- package/dist/types/src/extensions/automerge/automerge.d.ts.map +1 -0
- package/dist/types/src/extensions/automerge/automerge.test.d.ts +2 -0
- package/dist/types/src/extensions/automerge/automerge.test.d.ts.map +1 -0
- package/dist/types/src/extensions/automerge/cursor.d.ts +4 -0
- package/dist/types/src/extensions/automerge/cursor.d.ts.map +1 -0
- package/dist/types/src/extensions/automerge/defs.d.ts +17 -0
- package/dist/types/src/extensions/automerge/defs.d.ts.map +1 -0
- package/dist/types/src/extensions/automerge/index.d.ts +2 -0
- package/dist/types/src/extensions/automerge/index.d.ts.map +1 -0
- package/dist/types/src/extensions/automerge/sync.d.ts +17 -0
- package/dist/types/src/extensions/automerge/sync.d.ts.map +1 -0
- package/dist/types/src/extensions/automerge/update-automerge.d.ts +6 -0
- package/dist/types/src/extensions/automerge/update-automerge.d.ts.map +1 -0
- package/dist/types/src/extensions/automerge/update-codemirror.d.ts +5 -0
- package/dist/types/src/extensions/automerge/update-codemirror.d.ts.map +1 -0
- package/dist/types/src/extensions/awareness/awareness-provider.d.ts +31 -0
- package/dist/types/src/extensions/awareness/awareness-provider.d.ts.map +1 -0
- package/dist/types/src/extensions/awareness/awareness.d.ts +46 -0
- package/dist/types/src/extensions/awareness/awareness.d.ts.map +1 -0
- package/dist/types/src/extensions/awareness/index.d.ts +3 -0
- package/dist/types/src/extensions/awareness/index.d.ts.map +1 -0
- package/dist/types/src/extensions/blast.d.ts +25 -0
- package/dist/types/src/extensions/blast.d.ts.map +1 -0
- package/dist/types/src/extensions/blocks.d.ts +2 -0
- package/dist/types/src/extensions/blocks.d.ts.map +1 -0
- package/dist/types/src/extensions/bookmarks.d.ts +12 -0
- package/dist/types/src/extensions/bookmarks.d.ts.map +1 -0
- package/dist/types/src/extensions/comments.d.ts +90 -0
- package/dist/types/src/extensions/comments.d.ts.map +1 -0
- package/dist/types/src/extensions/debug.d.ts +3 -0
- package/dist/types/src/extensions/debug.d.ts.map +1 -0
- package/dist/types/src/extensions/dnd.d.ts +9 -0
- package/dist/types/src/extensions/dnd.d.ts.map +1 -0
- package/dist/types/src/extensions/factories.d.ts +88 -0
- package/dist/types/src/extensions/factories.d.ts.map +1 -0
- package/dist/types/src/extensions/factories.test.d.ts +2 -0
- package/dist/types/src/extensions/factories.test.d.ts.map +1 -0
- package/dist/types/src/extensions/focus.d.ts +7 -0
- package/dist/types/src/extensions/focus.d.ts.map +1 -0
- package/dist/types/src/extensions/folding.d.ts +6 -0
- package/dist/types/src/extensions/folding.d.ts.map +1 -0
- package/dist/types/src/extensions/hashtag.d.ts +3 -0
- package/dist/types/src/extensions/hashtag.d.ts.map +1 -0
- package/dist/types/src/extensions/index.d.ts +30 -0
- package/dist/types/src/extensions/index.d.ts.map +1 -0
- package/dist/types/src/extensions/json.d.ts +7 -0
- package/dist/types/src/extensions/json.d.ts.map +1 -0
- package/dist/types/src/extensions/listener.d.ts +13 -0
- package/dist/types/src/extensions/listener.d.ts.map +1 -0
- package/dist/types/src/extensions/markdown/action.d.ts +12 -0
- package/dist/types/src/extensions/markdown/action.d.ts.map +1 -0
- package/dist/types/src/extensions/markdown/bundle.d.ts +25 -0
- package/dist/types/src/extensions/markdown/bundle.d.ts.map +1 -0
- package/dist/types/src/extensions/markdown/changes.d.ts +10 -0
- package/dist/types/src/extensions/markdown/changes.d.ts.map +1 -0
- package/dist/types/src/extensions/markdown/changes.test.d.ts +2 -0
- package/dist/types/src/extensions/markdown/changes.test.d.ts.map +1 -0
- package/dist/types/src/extensions/markdown/debug.d.ts +11 -0
- package/dist/types/src/extensions/markdown/debug.d.ts.map +1 -0
- package/dist/types/src/extensions/markdown/decorate.d.ts +25 -0
- package/dist/types/src/extensions/markdown/decorate.d.ts.map +1 -0
- package/dist/types/src/extensions/markdown/formatting.d.ts +63 -0
- package/dist/types/src/extensions/markdown/formatting.d.ts.map +1 -0
- package/dist/types/src/extensions/markdown/formatting.test.d.ts +3 -0
- package/dist/types/src/extensions/markdown/formatting.test.d.ts.map +1 -0
- package/dist/types/src/extensions/markdown/highlight.d.ts +37 -0
- package/dist/types/src/extensions/markdown/highlight.d.ts.map +1 -0
- package/dist/types/src/extensions/markdown/image.d.ts +7 -0
- package/dist/types/src/extensions/markdown/image.d.ts.map +1 -0
- package/dist/types/src/extensions/markdown/index.d.ts +10 -0
- package/dist/types/src/extensions/markdown/index.d.ts.map +1 -0
- package/dist/types/src/extensions/markdown/link.d.ts +7 -0
- package/dist/types/src/extensions/markdown/link.d.ts.map +1 -0
- package/dist/types/src/extensions/markdown/parser.test.d.ts +2 -0
- package/dist/types/src/extensions/markdown/parser.test.d.ts.map +1 -0
- package/dist/types/src/extensions/markdown/styles.d.ts +4 -0
- package/dist/types/src/extensions/markdown/styles.d.ts.map +1 -0
- package/dist/types/src/extensions/markdown/table.d.ts +8 -0
- package/dist/types/src/extensions/markdown/table.d.ts.map +1 -0
- package/dist/types/src/extensions/mention.d.ts +7 -0
- package/dist/types/src/extensions/mention.d.ts.map +1 -0
- package/dist/types/src/extensions/modal.d.ts +7 -0
- package/dist/types/src/extensions/modal.d.ts.map +1 -0
- package/dist/types/src/extensions/modes.d.ts +10 -0
- package/dist/types/src/extensions/modes.d.ts.map +1 -0
- package/dist/types/src/extensions/outliner/commands.d.ts +10 -0
- package/dist/types/src/extensions/outliner/commands.d.ts.map +1 -0
- package/dist/types/src/extensions/outliner/editor.d.ts +5 -0
- package/dist/types/src/extensions/outliner/editor.d.ts.map +1 -0
- package/dist/types/src/extensions/outliner/editor.test.d.ts +2 -0
- package/dist/types/src/extensions/outliner/editor.test.d.ts.map +1 -0
- package/dist/types/src/extensions/outliner/index.d.ts +4 -0
- package/dist/types/src/extensions/outliner/index.d.ts.map +1 -0
- package/dist/types/src/extensions/outliner/menu.d.ts +8 -0
- package/dist/types/src/extensions/outliner/menu.d.ts.map +1 -0
- package/dist/types/src/extensions/outliner/outliner.d.ts +11 -0
- package/dist/types/src/extensions/outliner/outliner.d.ts.map +1 -0
- package/dist/types/src/extensions/outliner/outliner.test.d.ts +2 -0
- package/dist/types/src/extensions/outliner/outliner.test.d.ts.map +1 -0
- package/dist/types/src/extensions/outliner/selection.d.ts +12 -0
- package/dist/types/src/extensions/outliner/selection.d.ts.map +1 -0
- package/dist/types/src/extensions/outliner/tree.d.ts +79 -0
- package/dist/types/src/extensions/outliner/tree.d.ts.map +1 -0
- package/dist/types/src/extensions/outliner/tree.test.d.ts +2 -0
- package/dist/types/src/extensions/outliner/tree.test.d.ts.map +1 -0
- package/dist/types/src/extensions/preview/index.d.ts +2 -0
- package/dist/types/src/extensions/preview/index.d.ts.map +1 -0
- package/dist/types/src/extensions/preview/preview.d.ts +34 -0
- package/dist/types/src/extensions/preview/preview.d.ts.map +1 -0
- package/dist/types/src/extensions/replacer.d.ts +21 -0
- package/dist/types/src/extensions/replacer.d.ts.map +1 -0
- package/dist/types/src/extensions/replacer.test.d.ts +2 -0
- package/dist/types/src/extensions/replacer.test.d.ts.map +1 -0
- package/dist/types/src/extensions/scrolling/auto-scroll.d.ts +18 -0
- package/dist/types/src/extensions/scrolling/auto-scroll.d.ts.map +1 -0
- package/dist/types/src/extensions/scrolling/crawler.d.ts +75 -0
- package/dist/types/src/extensions/scrolling/crawler.d.ts.map +1 -0
- package/dist/types/src/extensions/scrolling/index.d.ts +5 -0
- package/dist/types/src/extensions/scrolling/index.d.ts.map +1 -0
- package/dist/types/src/extensions/scrolling/scroll-past-end.d.ts +3 -0
- package/dist/types/src/extensions/scrolling/scroll-past-end.d.ts.map +1 -0
- package/dist/types/src/extensions/scrolling/scroller.d.ts +16 -0
- package/dist/types/src/extensions/scrolling/scroller.d.ts.map +1 -0
- package/dist/types/src/extensions/selection.d.ts +24 -0
- package/dist/types/src/extensions/selection.d.ts.map +1 -0
- package/dist/types/src/extensions/snippets.d.ts +10 -0
- package/dist/types/src/extensions/snippets.d.ts.map +1 -0
- package/dist/types/src/extensions/state.d.ts +2 -0
- package/dist/types/src/extensions/state.d.ts.map +1 -0
- package/dist/types/src/extensions/submit.d.ts +10 -0
- package/dist/types/src/extensions/submit.d.ts.map +1 -0
- package/dist/types/src/extensions/tags/extended-markdown.d.ts +10 -0
- package/dist/types/src/extensions/tags/extended-markdown.d.ts.map +1 -0
- package/dist/types/src/extensions/tags/extended-markdown.test.d.ts +2 -0
- package/dist/types/src/extensions/tags/extended-markdown.test.d.ts.map +1 -0
- package/dist/types/src/extensions/tags/fader.d.ts +12 -0
- package/dist/types/src/extensions/tags/fader.d.ts.map +1 -0
- package/dist/types/src/extensions/tags/index.d.ts +7 -0
- package/dist/types/src/extensions/tags/index.d.ts.map +1 -0
- package/dist/types/src/extensions/tags/typewriter.d.ts +43 -0
- package/dist/types/src/extensions/tags/typewriter.d.ts.map +1 -0
- package/dist/types/src/extensions/tags/typewriter.test.d.ts +2 -0
- package/dist/types/src/extensions/tags/typewriter.test.d.ts.map +1 -0
- package/dist/types/src/extensions/tags/xml-block-decoration.d.ts +31 -0
- package/dist/types/src/extensions/tags/xml-block-decoration.d.ts.map +1 -0
- package/dist/types/src/extensions/tags/xml-formatting.d.ts +24 -0
- package/dist/types/src/extensions/tags/xml-formatting.d.ts.map +1 -0
- package/dist/types/src/extensions/tags/xml-tags.d.ts +117 -0
- package/dist/types/src/extensions/tags/xml-tags.d.ts.map +1 -0
- package/dist/types/src/extensions/tags/xml-util.d.ts +10 -0
- package/dist/types/src/extensions/tags/xml-util.d.ts.map +1 -0
- package/dist/types/src/extensions/tags/xml-util.test.d.ts +2 -0
- package/dist/types/src/extensions/tags/xml-util.test.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +8 -0
- package/dist/types/src/index.d.ts.map +1 -0
- package/dist/types/src/styles/index.d.ts +2 -0
- package/dist/types/src/styles/index.d.ts.map +1 -0
- package/dist/types/src/styles/theme.d.ts +58 -0
- package/dist/types/src/styles/theme.d.ts.map +1 -0
- package/dist/types/src/types/index.d.ts +2 -0
- package/dist/types/src/types/index.d.ts.map +1 -0
- package/dist/types/src/types/types.d.ts +21 -0
- package/dist/types/src/types/types.d.ts.map +1 -0
- package/dist/types/src/util/cursor.d.ts +31 -0
- package/dist/types/src/util/cursor.d.ts.map +1 -0
- package/dist/types/src/util/debug.d.ts +17 -0
- package/dist/types/src/util/debug.d.ts.map +1 -0
- package/dist/types/src/util/decorations.d.ts +4 -0
- package/dist/types/src/util/decorations.d.ts.map +1 -0
- package/dist/types/src/util/dom.d.ts +10 -0
- package/dist/types/src/util/dom.d.ts.map +1 -0
- package/dist/types/src/util/facet.d.ts +3 -0
- package/dist/types/src/util/facet.d.ts.map +1 -0
- package/dist/types/src/util/index.d.ts +7 -0
- package/dist/types/src/util/index.d.ts.map +1 -0
- package/dist/types/src/util/util.d.ts +8 -0
- package/dist/types/src/util/util.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/package.json +43 -44
- package/src/defaults.ts +33 -20
- package/src/extensions/annotations.ts +1 -1
- package/src/extensions/autocomplete/placeholder.ts +37 -18
- package/src/extensions/automerge/automerge.test.tsx +37 -11
- package/src/extensions/automerge/automerge.ts +5 -7
- package/src/extensions/blocks.ts +5 -5
- package/src/extensions/comments.ts +5 -6
- package/src/extensions/dnd.ts +2 -2
- package/src/extensions/factories.test.ts +88 -0
- package/src/extensions/factories.ts +32 -15
- package/src/extensions/folding.ts +5 -22
- package/src/extensions/index.ts +2 -3
- package/src/extensions/markdown/action.ts +0 -1
- package/src/extensions/markdown/bundle.ts +23 -9
- package/src/extensions/markdown/decorate.ts +15 -12
- package/src/extensions/markdown/formatting.ts +5 -10
- package/src/extensions/markdown/highlight.ts +15 -7
- package/src/extensions/markdown/link.ts +27 -33
- package/src/extensions/markdown/parser.test.ts +0 -1
- package/src/extensions/markdown/styles.ts +42 -9
- package/src/extensions/markdown/table.ts +24 -2
- package/src/extensions/outliner/outliner.test.ts +0 -1
- package/src/extensions/outliner/outliner.ts +3 -4
- package/src/extensions/outliner/tree.test.ts +0 -1
- package/src/extensions/preview/preview.ts +62 -15
- package/src/extensions/scrolling/auto-scroll.ts +244 -0
- package/src/extensions/scrolling/crawler.ts +263 -0
- package/src/extensions/scrolling/index.ts +8 -0
- package/src/extensions/scrolling/scroll-past-end.ts +32 -0
- package/src/extensions/scrolling/scroller.ts +27 -0
- package/src/extensions/selection.ts +1 -1
- package/src/extensions/snippets.ts +67 -0
- package/src/extensions/tags/extended-markdown.test.ts +120 -2
- package/src/extensions/tags/extended-markdown.ts +80 -1
- package/src/extensions/tags/fader.ts +195 -0
- package/src/extensions/tags/index.ts +4 -1
- package/src/extensions/tags/testing/text.md +36 -0
- package/src/extensions/tags/testing/text.txt +35 -0
- package/src/extensions/tags/typewriter.test.ts +65 -0
- package/src/extensions/tags/typewriter.ts +594 -0
- package/src/extensions/tags/xml-block-decoration.ts +123 -0
- package/src/extensions/tags/xml-formatting.ts +125 -0
- package/src/extensions/tags/xml-tags.ts +186 -35
- package/src/extensions/tags/xml-util.test.ts +199 -24
- package/src/extensions/tags/xml-util.ts +62 -5
- package/src/index.ts +0 -1
- package/src/styles/index.ts +0 -2
- package/src/styles/theme.ts +125 -33
- package/src/types/types.ts +10 -2
- package/src/typings.d.ts +8 -0
- package/src/util/cursor.ts +1 -2
- package/src/extensions/autoscroll.ts +0 -165
- package/src/extensions/scrolling.ts +0 -189
- package/src/extensions/tags/streamer.ts +0 -243
- package/src/extensions/typewriter.ts +0 -68
- package/src/styles/markdown.ts +0 -26
- package/src/styles/tokens.ts +0 -17
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
Annotation,
|
|
7
|
+
ChangeSet,
|
|
8
|
+
EditorState,
|
|
9
|
+
type Extension,
|
|
10
|
+
StateEffect,
|
|
11
|
+
StateField,
|
|
12
|
+
type Transaction,
|
|
13
|
+
} from '@codemirror/state';
|
|
14
|
+
import { Decoration, type DecorationSet, EditorView, ViewPlugin, type ViewUpdate, WidgetType } from '@codemirror/view';
|
|
15
|
+
|
|
16
|
+
import { Domino } from '@dxos/ui';
|
|
17
|
+
|
|
18
|
+
/** Annotate a transaction to bypass the typewriter buffer (content appears immediately). */
|
|
19
|
+
export const typewriterBypass = Annotation.define<boolean>();
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Public state effect signalling whether typewriter's drip queue is currently draining.
|
|
23
|
+
* Other extensions can subscribe to coordinate behaviour with the typewriter (e.g. hide
|
|
24
|
+
* a block-widget footer while text is being dripped to avoid scroll-measure conflicts).
|
|
25
|
+
*
|
|
26
|
+
* Dispatched at queue transitions:
|
|
27
|
+
* - `true` — empty → non-empty (drain rAF/interval is starting).
|
|
28
|
+
* - `false` — non-empty → empty (last char written, drain stopped).
|
|
29
|
+
*/
|
|
30
|
+
export const typewriterDrainingEffect = StateEffect.define<boolean>();
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Buffer state. The pending text is `text.slice(head)` — but `head` is advanced
|
|
34
|
+
* without slicing on every drip to avoid O(n²) string copying. The prefix is
|
|
35
|
+
* compacted lazily once it exceeds half the string or `COMPACT_HEAD_THRESHOLD`.
|
|
36
|
+
*/
|
|
37
|
+
type BufferState = { text: string; head: number; insertAt: number };
|
|
38
|
+
|
|
39
|
+
/** How long to show the cursor after motion has stopped. */
|
|
40
|
+
const CURSOR_LINGER = 3_000;
|
|
41
|
+
|
|
42
|
+
/** Per-frame time budget for draining the buffer. Stays well under a 16 ms frame. */
|
|
43
|
+
const FRAME_BUDGET_MS = 4;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Visible characters of plain text emitted per animation frame. At 60 fps a value of 1
|
|
47
|
+
* gives ~60 char/s — fast enough to keep up with most streams while still reading as a
|
|
48
|
+
* smooth character-by-character drip. Atomic structures (XML elements, markdown links)
|
|
49
|
+
* always flush whole regardless of this cap.
|
|
50
|
+
*/
|
|
51
|
+
const CHARS_PER_FRAME = 5;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* When the pending buffer exceeds this many characters, abandon typewriter pacing
|
|
55
|
+
* and flush the entire buffer in one transaction. The visual benefit of typewriter
|
|
56
|
+
* has negative value past human-reading rates.
|
|
57
|
+
*/
|
|
58
|
+
const FLUSH_THRESHOLD = 2_000;
|
|
59
|
+
|
|
60
|
+
/** Compaction trigger for the head-offset rope. */
|
|
61
|
+
const COMPACT_HEAD_THRESHOLD = 4_096;
|
|
62
|
+
|
|
63
|
+
export type TypewriterOptions = {
|
|
64
|
+
/** Show a blinking cursor at the insertion point while streaming. */
|
|
65
|
+
cursor?: boolean;
|
|
66
|
+
/** Tag names whose inner content should be streamed (not buffered until close). */
|
|
67
|
+
streamingTags?: Set<string>;
|
|
68
|
+
/** Hard cap before falling back to single-flush mode. Defaults to 2 000 chars. */
|
|
69
|
+
flushThreshold?: number;
|
|
70
|
+
/** Per-frame time budget for draining (ms). Defaults to 20 ms. */
|
|
71
|
+
frameBudgetMs?: number;
|
|
72
|
+
/**
|
|
73
|
+
* Maximum visible characters of plain text emitted per animation frame. Atomic
|
|
74
|
+
* structures (XML elements, markdown links) always flush whole and are not subject
|
|
75
|
+
* to this cap. Default 1 — ~60 char/s at 60 fps. Raise for faster typewriter speed.
|
|
76
|
+
*/
|
|
77
|
+
charsPerFrame?: number;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Intercepts appended text and inserts it as time-budgeted batches per animation frame,
|
|
82
|
+
* while flushing XML tags, markdown links, and images atomically for smooth typewriter-style
|
|
83
|
+
* streaming. Falls back to bulk-flush when the buffer grows past `flushThreshold` to keep
|
|
84
|
+
* the editor responsive on long runs.
|
|
85
|
+
*/
|
|
86
|
+
export const typewriter = (options: TypewriterOptions = {}): Extension => {
|
|
87
|
+
const streamingTags = options.streamingTags ?? new Set<string>();
|
|
88
|
+
const flushThreshold = options.flushThreshold ?? FLUSH_THRESHOLD;
|
|
89
|
+
const frameBudgetMs = options.frameBudgetMs ?? FRAME_BUDGET_MS;
|
|
90
|
+
const charsPerFrame = options.charsPerFrame ?? CHARS_PER_FRAME;
|
|
91
|
+
|
|
92
|
+
// Effect to suppress a transaction from being applied (replaced by buffered insert).
|
|
93
|
+
const suppressAppend = StateEffect.define<{ from: number; text: string }>();
|
|
94
|
+
// Effect to insert text from the buffer (single batch per frame).
|
|
95
|
+
const insertChunk = StateEffect.define<{ from: number; text: string }>();
|
|
96
|
+
|
|
97
|
+
// State field that holds the pending buffer of text to drip into the document.
|
|
98
|
+
const bufferField = StateField.define<BufferState>({
|
|
99
|
+
create: () => ({ text: '', head: 0, insertAt: 0 }),
|
|
100
|
+
update: (value, tr) => {
|
|
101
|
+
let { text, head, insertAt } = value;
|
|
102
|
+
for (const effect of tr.effects) {
|
|
103
|
+
if (effect.is(suppressAppend)) {
|
|
104
|
+
// If pending was empty, anchor insertAt at the new append point.
|
|
105
|
+
if (text.length === head) {
|
|
106
|
+
insertAt = effect.value.from;
|
|
107
|
+
}
|
|
108
|
+
text += effect.value.text;
|
|
109
|
+
}
|
|
110
|
+
if (effect.is(insertChunk)) {
|
|
111
|
+
head += effect.value.text.length;
|
|
112
|
+
insertAt = effect.value.from + effect.value.text.length;
|
|
113
|
+
// Compact lazily so the prefix doesn't grow without bound.
|
|
114
|
+
if (head >= COMPACT_HEAD_THRESHOLD || (head > 0 && head * 2 >= text.length)) {
|
|
115
|
+
text = text.slice(head);
|
|
116
|
+
head = 0;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Reset buffer when document is cleared or fully replaced.
|
|
122
|
+
if (tr.docChanged) {
|
|
123
|
+
let isReset = tr.state.doc.length === 0;
|
|
124
|
+
if (!isReset && tr.startState.doc.length > 0) {
|
|
125
|
+
tr.changes.iterChanges((fromA, toA) => {
|
|
126
|
+
if (fromA === 0 && toA === tr.startState.doc.length) {
|
|
127
|
+
isReset = true;
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
if (isReset) {
|
|
132
|
+
return { text: '', head: 0, insertAt: 0 };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Map insertion position through document changes not caused by us.
|
|
136
|
+
if (!tr.effects.some((effect) => effect.is(insertChunk))) {
|
|
137
|
+
insertAt = tr.changes.mapPos(Math.min(insertAt, tr.startState.doc.length));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { text, head, insertAt };
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Transaction filter: intercept appends at end-of-document and buffer them.
|
|
146
|
+
const filter = EditorState.transactionFilter.of((tr: Transaction) => {
|
|
147
|
+
if (!tr.docChanged) {
|
|
148
|
+
return tr;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Allow bypassed and drip insertions through.
|
|
152
|
+
if (tr.annotation(typewriterBypass) || tr.effects.some((effect) => effect.is(insertChunk))) {
|
|
153
|
+
return tr;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Collect appended text at the end of the document.
|
|
157
|
+
let appendedText = '';
|
|
158
|
+
let appendFrom = -1;
|
|
159
|
+
let isAppendOnly = true;
|
|
160
|
+
|
|
161
|
+
tr.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => {
|
|
162
|
+
if (toA === tr.startState.doc.length && fromA === toA && inserted.length > 0) {
|
|
163
|
+
appendedText += inserted.sliceString(0);
|
|
164
|
+
if (appendFrom === -1) {
|
|
165
|
+
appendFrom = fromA;
|
|
166
|
+
}
|
|
167
|
+
} else {
|
|
168
|
+
isAppendOnly = false;
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
if (!isAppendOnly || appendedText.length === 0) {
|
|
173
|
+
return tr;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Suppress the original insert; buffer the text instead.
|
|
177
|
+
return {
|
|
178
|
+
changes: ChangeSet.empty(tr.startState.doc.length),
|
|
179
|
+
effects: suppressAppend.of({ from: appendFrom, text: appendedText }),
|
|
180
|
+
};
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// View plugin that drains the buffer once per animation frame, emitting as many atomic
|
|
184
|
+
// chunks as fit within `frameBudgetMs`. Falls back to a single dispatch when the buffer
|
|
185
|
+
// grows past `flushThreshold`.
|
|
186
|
+
const drainPlugin = ViewPlugin.fromClass(
|
|
187
|
+
class {
|
|
188
|
+
_raf: number | undefined;
|
|
189
|
+
_activeStreamTag: string | null = null;
|
|
190
|
+
|
|
191
|
+
constructor(private view: EditorView) {
|
|
192
|
+
// Note: do NOT eagerly start the drain here. The buffer is empty at construction,
|
|
193
|
+
// and any synchronous `view.dispatch` inside the constructor is rejected by
|
|
194
|
+
// CodeMirror because we're inside the initial update flow.
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
update(update: ViewUpdate) {
|
|
198
|
+
const { text, head } = update.state.field(bufferField);
|
|
199
|
+
const pending = text.length - head;
|
|
200
|
+
if (pending === 0) {
|
|
201
|
+
this._activeStreamTag = null;
|
|
202
|
+
}
|
|
203
|
+
if (pending > 0 && this._raf === undefined) {
|
|
204
|
+
this._start();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
_start() {
|
|
209
|
+
// Announce drain start. Deferred via microtask because `_start` runs from inside
|
|
210
|
+
// `update()`, where synchronous `view.dispatch` is disallowed.
|
|
211
|
+
queueMicrotask(() => {
|
|
212
|
+
this.view.dispatch({
|
|
213
|
+
effects: typewriterDrainingEffect.of(true),
|
|
214
|
+
annotations: typewriterBypass.of(true),
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
this._raf = requestAnimationFrame(this._tick);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
_tick = () => {
|
|
221
|
+
const { text, head, insertAt } = this.view.state.field(bufferField);
|
|
222
|
+
const pending = text.length - head;
|
|
223
|
+
|
|
224
|
+
if (pending === 0) {
|
|
225
|
+
this.view.dispatch({
|
|
226
|
+
effects: typewriterDrainingEffect.of(false),
|
|
227
|
+
annotations: typewriterBypass.of(true),
|
|
228
|
+
});
|
|
229
|
+
this._raf = undefined;
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Backpressure: flush everything in one shot when the buffer is too large to
|
|
234
|
+
// pace at typewriter rates. Streaming-tag context is dropped — anything still
|
|
235
|
+
// buffered (including a closing tag, if present) lands as one chunk.
|
|
236
|
+
if (pending > flushThreshold) {
|
|
237
|
+
const chunk = text.slice(head);
|
|
238
|
+
this._activeStreamTag = null;
|
|
239
|
+
this.view.dispatch({
|
|
240
|
+
changes: { from: insertAt, insert: chunk },
|
|
241
|
+
effects: insertChunk.of({ from: insertAt, text: chunk }),
|
|
242
|
+
});
|
|
243
|
+
this._raf = requestAnimationFrame(this._tick);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Time-budgeted batch: accumulate atomic chunks until we run out of budget,
|
|
248
|
+
// hit an incomplete structure, exhaust the buffer, or hit the per-frame
|
|
249
|
+
// visible-character cap. Atomic chunks (count > 1) are always emitted whole;
|
|
250
|
+
// the cap only throttles plain-text drips so the typewriter cadence stays
|
|
251
|
+
// visible at the per-frame level.
|
|
252
|
+
const startTime = performance.now();
|
|
253
|
+
let pos = head;
|
|
254
|
+
let activeTag = this._activeStreamTag;
|
|
255
|
+
let charsEmitted = 0;
|
|
256
|
+
while (pos < text.length && performance.now() - startTime < frameBudgetMs) {
|
|
257
|
+
const result = flushable(text, pos, streamingTags, activeTag);
|
|
258
|
+
if (result.count === 0) {
|
|
259
|
+
// Head is inside an incomplete structure — wait for more data.
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
// Stop after the per-frame cap, but only once we've already emitted at
|
|
263
|
+
// least one chunk this frame — otherwise a multi-char atomic structure
|
|
264
|
+
// (e.g. a markdown link) could starve forever on a small cap.
|
|
265
|
+
if (charsEmitted > 0 && charsEmitted + result.count > charsPerFrame) {
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
if (result.enterTag) {
|
|
269
|
+
activeTag = result.enterTag;
|
|
270
|
+
}
|
|
271
|
+
if (result.exitTag) {
|
|
272
|
+
activeTag = null;
|
|
273
|
+
}
|
|
274
|
+
pos += result.count;
|
|
275
|
+
charsEmitted += result.count;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const totalCount = pos - head;
|
|
279
|
+
if (totalCount > 0) {
|
|
280
|
+
const chunk = text.slice(head, head + totalCount);
|
|
281
|
+
this._activeStreamTag = activeTag;
|
|
282
|
+
this.view.dispatch({
|
|
283
|
+
changes: { from: insertAt, insert: chunk },
|
|
284
|
+
effects: insertChunk.of({ from: insertAt, text: chunk }),
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Continue draining next frame; if pending is 0 after the dispatch, the next
|
|
289
|
+
// tick will land on the early-out above and stop the loop.
|
|
290
|
+
this._raf = requestAnimationFrame(this._tick);
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
destroy() {
|
|
294
|
+
if (this._raf !== undefined) {
|
|
295
|
+
cancelAnimationFrame(this._raf);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
return [bufferField, filter, drainPlugin, options.cursor && typewriterCursor(bufferField)].filter(
|
|
302
|
+
Boolean,
|
|
303
|
+
) as Extension[];
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
//
|
|
307
|
+
// Cursor
|
|
308
|
+
//
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Blinking cursor widget at the insertion point while the buffer is draining.
|
|
312
|
+
* Lingers for 2s after the buffer empties before being removed.
|
|
313
|
+
*/
|
|
314
|
+
const typewriterCursor = (bufferField: StateField<BufferState>): Extension => {
|
|
315
|
+
const hideCursor = StateEffect.define();
|
|
316
|
+
|
|
317
|
+
const visibilityField = StateField.define<{
|
|
318
|
+
visible: boolean;
|
|
319
|
+
insertAt: number;
|
|
320
|
+
/** Position after the last non-whitespace character was inserted. */
|
|
321
|
+
lastNonWsAt: number;
|
|
322
|
+
}>({
|
|
323
|
+
create: () => ({ visible: false, insertAt: 0, lastNonWsAt: 0 }),
|
|
324
|
+
update: (value, tr) => {
|
|
325
|
+
const { text, head, insertAt } = tr.state.field(bufferField);
|
|
326
|
+
const pending = text.length - head;
|
|
327
|
+
if (pending > 0) {
|
|
328
|
+
// Track the last position where a non-whitespace character was inserted.
|
|
329
|
+
let lastNonWsAt = tr.changes.mapPos(Math.min(value.lastNonWsAt, tr.startState.doc.length));
|
|
330
|
+
if (tr.docChanged) {
|
|
331
|
+
tr.changes.iterChanges((_fromA, _toA, _fromB, _toB, inserted) => {
|
|
332
|
+
const chunk = inserted.sliceString(0);
|
|
333
|
+
if (chunk.trim().length > 0) {
|
|
334
|
+
lastNonWsAt = _fromB + chunk.length;
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
return { visible: true, insertAt, lastNonWsAt };
|
|
339
|
+
}
|
|
340
|
+
for (const effect of tr.effects) {
|
|
341
|
+
if (effect.is(hideCursor)) {
|
|
342
|
+
return { ...value, visible: false };
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return value;
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
const decorationField = StateField.define<DecorationSet>({
|
|
350
|
+
create: () => Decoration.none,
|
|
351
|
+
update: (_decorations, tr) => {
|
|
352
|
+
const { visible, insertAt, lastNonWsAt } = tr.state.field(visibilityField);
|
|
353
|
+
if (!visible) {
|
|
354
|
+
return Decoration.none;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const { text, head } = tr.state.field(bufferField);
|
|
358
|
+
// While draining, show cursor at the insertion point.
|
|
359
|
+
// When lingering (buffer empty), show at last non-whitespace position.
|
|
360
|
+
const cursorAt = text.length > head ? insertAt : lastNonWsAt;
|
|
361
|
+
const pos = Math.min(cursorAt, tr.state.doc.length);
|
|
362
|
+
return Decoration.set([
|
|
363
|
+
Decoration.widget({
|
|
364
|
+
widget: new CursorWidget(),
|
|
365
|
+
side: 1,
|
|
366
|
+
}).range(pos),
|
|
367
|
+
]);
|
|
368
|
+
},
|
|
369
|
+
provide: (field) => EditorView.decorations.from(field),
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
const timerPlugin = ViewPlugin.fromClass(
|
|
373
|
+
class {
|
|
374
|
+
_timer: ReturnType<typeof setTimeout> | undefined;
|
|
375
|
+
|
|
376
|
+
constructor(private view: EditorView) {}
|
|
377
|
+
|
|
378
|
+
update(update: ViewUpdate) {
|
|
379
|
+
const { text, head } = update.state.field(bufferField);
|
|
380
|
+
const { visible } = update.state.field(visibilityField);
|
|
381
|
+
const pending = text.length - head;
|
|
382
|
+
|
|
383
|
+
if (pending > 0) {
|
|
384
|
+
clearTimeout(this._timer);
|
|
385
|
+
this._timer = undefined;
|
|
386
|
+
} else if (visible && this._timer === undefined) {
|
|
387
|
+
this._timer = setTimeout(() => {
|
|
388
|
+
this.view.dispatch({ effects: hideCursor.of(null) });
|
|
389
|
+
this._timer = undefined;
|
|
390
|
+
}, CURSOR_LINGER);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
destroy() {
|
|
395
|
+
clearTimeout(this._timer);
|
|
396
|
+
}
|
|
397
|
+
},
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
return [visibilityField, decorationField, timerPlugin];
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* U+2217 Asterisk
|
|
405
|
+
* U+25CF Ballot Box
|
|
406
|
+
*/
|
|
407
|
+
class CursorWidget extends WidgetType {
|
|
408
|
+
// All instances are interchangeable — let CM reuse the existing DOM across drips so
|
|
409
|
+
// the blink animation isn't restarted on every transaction.
|
|
410
|
+
override eq(other: WidgetType): boolean {
|
|
411
|
+
return other instanceof CursorWidget;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
toDOM() {
|
|
415
|
+
const inner = Domino.of('span').text('∗').style({ animation: 'blink 1s infinite', animationDelay: '250ms' });
|
|
416
|
+
return Domino.of('span').style({ opacity: '0.8' }).append(inner).root;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Matches the local name of an opening tag after `<` (not `</`).
|
|
422
|
+
* Custom elements use hyphens; `\w+` alone incorrectly stops at `-` (e.g. `dom` from `dom-widget`).
|
|
423
|
+
*/
|
|
424
|
+
const OPENING_TAG_NAME = /^<([a-zA-Z][\w-]*)/;
|
|
425
|
+
|
|
426
|
+
/** Tag names are short — bound the slice we hand to the regex matcher. */
|
|
427
|
+
const TAG_NAME_PROBE = 64;
|
|
428
|
+
|
|
429
|
+
/** Escapes a string for safe embedding in RegExp source (tag names from the document). */
|
|
430
|
+
const escapeRegExpSource = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
431
|
+
|
|
432
|
+
type FlushResult = {
|
|
433
|
+
count: number;
|
|
434
|
+
/** Tag name to enter streaming mode for. */
|
|
435
|
+
enterTag?: string;
|
|
436
|
+
/** Whether to exit streaming mode. */
|
|
437
|
+
exitTag?: boolean;
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Scans the buffer starting at `start` and returns the number of characters that can be
|
|
442
|
+
* flushed. Returns 0 if the head of the buffer is inside an incomplete structure (XML
|
|
443
|
+
* element, markdown link, or image) that should be flushed atomically. Returns > 1 when
|
|
444
|
+
* a complete structure is at the head and should be emitted in one batch.
|
|
445
|
+
*
|
|
446
|
+
* When `activeStreamTag` is set, we're inside a streaming tag: inner content drips one
|
|
447
|
+
* character at a time, and the closing tag is flushed atomically.
|
|
448
|
+
*/
|
|
449
|
+
const flushable = (
|
|
450
|
+
buffer: string,
|
|
451
|
+
start: number,
|
|
452
|
+
streamingTags: Set<string>,
|
|
453
|
+
activeStreamTag: string | null,
|
|
454
|
+
): FlushResult => {
|
|
455
|
+
if (start >= buffer.length) {
|
|
456
|
+
return { count: 0 };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Inside a streaming tag: drip content, flush closing tag atomically.
|
|
460
|
+
if (activeStreamTag) {
|
|
461
|
+
const closeTag = `</${activeStreamTag}>`;
|
|
462
|
+
if (buffer.startsWith(closeTag, start)) {
|
|
463
|
+
return { count: closeTag.length, exitTag: true };
|
|
464
|
+
}
|
|
465
|
+
// Nested XML element — buffer atomically.
|
|
466
|
+
if (buffer[start] === '<') {
|
|
467
|
+
return { count: xmlElementLength(buffer, start) };
|
|
468
|
+
}
|
|
469
|
+
// Drip inner content one character at a time.
|
|
470
|
+
return { count: 1 };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const ch = buffer[start];
|
|
474
|
+
|
|
475
|
+
// XML element.
|
|
476
|
+
if (ch === '<') {
|
|
477
|
+
// Tag-name match against a bounded slice (tag names are short).
|
|
478
|
+
const probe = buffer.slice(start, start + TAG_NAME_PROBE);
|
|
479
|
+
const nameMatch = probe.match(OPENING_TAG_NAME);
|
|
480
|
+
if (nameMatch && streamingTags.has(nameMatch[1])) {
|
|
481
|
+
const close = buffer.indexOf('>', start);
|
|
482
|
+
if (close === -1) {
|
|
483
|
+
return { count: 0 }; // Opening tag incomplete.
|
|
484
|
+
}
|
|
485
|
+
// Self-closing streaming tag — flush atomically, no streaming mode.
|
|
486
|
+
if (buffer[close - 1] === '/') {
|
|
487
|
+
return { count: close + 1 - start };
|
|
488
|
+
}
|
|
489
|
+
// Flush opening tag and enter streaming mode.
|
|
490
|
+
return { count: close + 1 - start, enterTag: nameMatch[1] };
|
|
491
|
+
}
|
|
492
|
+
// Non-streaming XML: buffer the entire element.
|
|
493
|
+
return { count: xmlElementLength(buffer, start) };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Image:  — starts with '!'.
|
|
497
|
+
if (ch === '!' && buffer.length > start + 1 && buffer[start + 1] === '[') {
|
|
498
|
+
return { count: linkLength(buffer, start, start + 1) };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Link: [text](url).
|
|
502
|
+
if (ch === '[') {
|
|
503
|
+
return { count: linkLength(buffer, start, start) };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return { count: 1 };
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Returns the length of a complete XML element starting at `start`, or 0 if the element
|
|
511
|
+
* is incomplete. Handles self-closing tags, closing tags, and opening tags with nested
|
|
512
|
+
* content. E.g., `<foo>content<bar />more</foo>` returns the full length.
|
|
513
|
+
*/
|
|
514
|
+
export const xmlElementLength = (buffer: string, start = 0): number => {
|
|
515
|
+
const close = buffer.indexOf('>', start);
|
|
516
|
+
if (close === -1) {
|
|
517
|
+
return 0; // Tag not closed yet.
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Self-closing tag: <foo />.
|
|
521
|
+
if (buffer[close - 1] === '/') {
|
|
522
|
+
return close + 1 - start;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Closing tag: </foo>.
|
|
526
|
+
if (buffer[start + 1] === '/') {
|
|
527
|
+
return close + 1 - start;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Opening tag: extract the tag name and find its matching closing tag.
|
|
531
|
+
const probe = buffer.slice(start, start + TAG_NAME_PROBE);
|
|
532
|
+
const nameMatch = probe.match(OPENING_TAG_NAME);
|
|
533
|
+
if (!nameMatch) {
|
|
534
|
+
// Not a valid tag (e.g., `< ` or `<123`); emit one character.
|
|
535
|
+
return 1;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const tagName = nameMatch[1];
|
|
539
|
+
let depth = 0;
|
|
540
|
+
|
|
541
|
+
// Walk through all tags in the buffer tracking nesting depth, starting at `start`.
|
|
542
|
+
const tagPattern = new RegExp(`<(/?)${escapeRegExpSource(tagName)}(\\s[^>]*)?>`, 'g');
|
|
543
|
+
tagPattern.lastIndex = start;
|
|
544
|
+
let match: RegExpExecArray | null;
|
|
545
|
+
while ((match = tagPattern.exec(buffer)) !== null) {
|
|
546
|
+
const isSelfClosing = match[0].endsWith('/>');
|
|
547
|
+
const isClosing = match[1] === '/';
|
|
548
|
+
|
|
549
|
+
if (isSelfClosing) {
|
|
550
|
+
// Self-closing doesn't change depth, but if depth is 0 this is the root.
|
|
551
|
+
if (depth === 0) {
|
|
552
|
+
return match.index + match[0].length - start;
|
|
553
|
+
}
|
|
554
|
+
} else if (isClosing) {
|
|
555
|
+
depth--;
|
|
556
|
+
if (depth === 0) {
|
|
557
|
+
return match.index + match[0].length - start;
|
|
558
|
+
}
|
|
559
|
+
} else {
|
|
560
|
+
depth++;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Unbalanced — still waiting for closing tag.
|
|
565
|
+
return 0;
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Returns the length (from `start`) of a complete markdown link/image whose `[` is at
|
|
570
|
+
* `bracketAt`, or 0 if the structure is incomplete. Returns 1 if the bracket is not part
|
|
571
|
+
* of a link (no following `(`).
|
|
572
|
+
*/
|
|
573
|
+
const linkLength = (buffer: string, start: number, bracketAt: number): number => {
|
|
574
|
+
const bracketClose = buffer.indexOf(']', bracketAt + 1);
|
|
575
|
+
if (bracketClose === -1) {
|
|
576
|
+
return 0;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Must be followed by '(' for a standard link.
|
|
580
|
+
if (bracketClose + 1 >= buffer.length) {
|
|
581
|
+
return 0;
|
|
582
|
+
}
|
|
583
|
+
if (buffer[bracketClose + 1] !== '(') {
|
|
584
|
+
// Not a link — just a bracket; emit one character.
|
|
585
|
+
return 1;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const parenClose = buffer.indexOf(')', bracketClose + 2);
|
|
589
|
+
if (parenClose === -1) {
|
|
590
|
+
return 0;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
return parenClose + 1 - start;
|
|
594
|
+
};
|