@elabs-ai/components-editor 4.0.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 +21 -0
- package/README.md +149 -0
- package/dist/chunk-LBC5VJBD.js +3748 -0
- package/dist/chunk-LBC5VJBD.js.map +1 -0
- package/dist/index.css +233 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.ts +199 -0
- package/dist/index.js +362 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/monaco-environment.d.ts +2 -0
- package/dist/lib/monaco-environment.js +44 -0
- package/dist/lib/monaco-environment.js.map +1 -0
- package/dist/markdown/frontmatter.d.ts +11 -0
- package/dist/markdown/frontmatter.js +33 -0
- package/dist/markdown/frontmatter.js.map +1 -0
- package/dist/markdown/index.css +233 -0
- package/dist/markdown/index.css.map +1 -0
- package/dist/markdown/index.d.ts +1486 -0
- package/dist/markdown/index.js +4882 -0
- package/dist/markdown/index.js.map +1 -0
- package/dist/markdown/parse.d.ts +25 -0
- package/dist/markdown/parse.js +14 -0
- package/dist/markdown/parse.js.map +1 -0
- package/dist/markdown-editor-DfBZibAn.d.ts +702 -0
- package/package.json +101 -0
- package/src/ai-objects/ai-objects-integration.test.tsx +128 -0
- package/src/ai-objects/ai-objects.stories.tsx +233 -0
- package/src/ai-objects/ai-objects.test.tsx +205 -0
- package/src/ai-objects/decision-card.tsx +178 -0
- package/src/ai-objects/directives.ts +136 -0
- package/src/ai-objects/entity.tsx +181 -0
- package/src/ai-objects/index.ts +34 -0
- package/src/ai-objects/knowledge-card.tsx +143 -0
- package/src/calc-block/calc-block.stories.tsx +297 -0
- package/src/calc-block/calc-block.test.tsx +164 -0
- package/src/calc-block/calc-block.tsx +398 -0
- package/src/calc-block/calc-editor-monaco.ts +214 -0
- package/src/calc-block/calc-editor-prose.ts +128 -0
- package/src/calc-block/calc-editor.css +80 -0
- package/src/calc-block/calc-editor.stories.tsx +272 -0
- package/src/calc-block/calc-editor.test.ts +228 -0
- package/src/calc-block/calc-editor.ts +270 -0
- package/src/calc-block/calc-inline.stories.tsx +44 -0
- package/src/calc-block/calc-inline.test.tsx +44 -0
- package/src/calc-block/calc-inline.tsx +75 -0
- package/src/calc-block/index.ts +20 -0
- package/src/calc-block/types.ts +164 -0
- package/src/code-editor/code-editor.stories.tsx +218 -0
- package/src/code-editor/code-editor.test.tsx +134 -0
- package/src/code-editor/code-editor.tsx +250 -0
- package/src/code-editor/index.ts +6 -0
- package/src/code-workspace/code-workspace.stories.tsx +51 -0
- package/src/code-workspace/code-workspace.test.tsx +146 -0
- package/src/code-workspace/code-workspace.tsx +253 -0
- package/src/code-workspace/index.ts +6 -0
- package/src/copy-button/copy-button.tsx +51 -0
- package/src/copy-button/index.ts +1 -0
- package/src/css.d.ts +3 -0
- package/src/diff-editor/diff-editor.stories.tsx +44 -0
- package/src/diff-editor/diff-editor.test.tsx +68 -0
- package/src/diff-editor/diff-editor.tsx +151 -0
- package/src/diff-editor/index.ts +1 -0
- package/src/editor-context-menu/editor-context-menu.tsx +120 -0
- package/src/editor-context-menu/index.ts +1 -0
- package/src/editor-toolbar/editor-toolbar.tsx +83 -0
- package/src/editor-toolbar/index.ts +1 -0
- package/src/index.ts +48 -0
- package/src/lib/editor-completions-monaco.test.ts +206 -0
- package/src/lib/editor-completions-monaco.ts +127 -0
- package/src/lib/editor-completions.test.ts +156 -0
- package/src/lib/editor-completions.ts +153 -0
- package/src/lib/editor-content-access-prose.test.ts +411 -0
- package/src/lib/editor-content-access-prose.ts +172 -0
- package/src/lib/editor-content-access.test.ts +182 -0
- package/src/lib/editor-content-access.ts +109 -0
- package/src/lib/languages.ts +32 -0
- package/src/lib/markdown/diff.test.ts +80 -0
- package/src/lib/markdown/diff.ts +194 -0
- package/src/lib/markdown/directives.ts +342 -0
- package/src/lib/markdown/frontmatter.ts +50 -0
- package/src/lib/markdown/markdown-scale.test.ts +65 -0
- package/src/lib/markdown/markdown-scale.ts +62 -0
- package/src/lib/markdown/merge.test.ts +64 -0
- package/src/lib/markdown/merge.ts +158 -0
- package/src/lib/markdown/slugify.test.ts +156 -0
- package/src/lib/markdown/slugify.ts +47 -0
- package/src/lib/monaco-environment.ts +103 -0
- package/src/lib/monaco-theme-bridge.ts +293 -0
- package/src/lib/use-data-theme.ts +61 -0
- package/src/markdown/frontmatter.ts +14 -0
- package/src/markdown/index.ts +323 -0
- package/src/markdown/parse.test.ts +60 -0
- package/src/markdown/parse.ts +38 -0
- package/src/markdown-academic/citations.test.ts +77 -0
- package/src/markdown-academic/citations.tsx +442 -0
- package/src/markdown-academic/footnotes.tsx +235 -0
- package/src/markdown-academic/index.ts +32 -0
- package/src/markdown-academic/math.tsx +163 -0
- package/src/markdown-academic/toc.tsx +88 -0
- package/src/markdown-editor/completions/completions-menu.tsx +96 -0
- package/src/markdown-editor/completions/completions-prose.test.ts +356 -0
- package/src/markdown-editor/completions/completions-prose.ts +313 -0
- package/src/markdown-editor/completions/completions-widget.tsx +75 -0
- package/src/markdown-editor/completions/index.ts +37 -0
- package/src/markdown-editor/directive-nodes.ts +151 -0
- package/src/markdown-editor/directive-views.test.tsx +189 -0
- package/src/markdown-editor/directive-views.tsx +795 -0
- package/src/markdown-editor/exit-keymap.test.ts +88 -0
- package/src/markdown-editor/exit-keymap.ts +87 -0
- package/src/markdown-editor/index.ts +6 -0
- package/src/markdown-editor/markdown-editor.css +250 -0
- package/src/markdown-editor/markdown-editor.directives.test.tsx +105 -0
- package/src/markdown-editor/markdown-editor.fill.test.ts +34 -0
- package/src/markdown-editor/markdown-editor.paste-embed.test.tsx +297 -0
- package/src/markdown-editor/markdown-editor.stories.tsx +560 -0
- package/src/markdown-editor/markdown-editor.strictmode.test.tsx +38 -0
- package/src/markdown-editor/markdown-editor.table.test.tsx +287 -0
- package/src/markdown-editor/markdown-editor.tsx +560 -0
- package/src/markdown-editor/milkdown-react/editor.tsx +33 -0
- package/src/markdown-editor/milkdown-react/index.ts +8 -0
- package/src/markdown-editor/milkdown-react/types.ts +27 -0
- package/src/markdown-editor/milkdown-react/use-editor.ts +27 -0
- package/src/markdown-editor/milkdown-react/use-get-editor.ts +56 -0
- package/src/markdown-editor/milkdown-react/use-instance.ts +23 -0
- package/src/markdown-editor/paste-embed.ts +355 -0
- package/src/markdown-editor/slash/brand-slash-commands.test.ts +194 -0
- package/src/markdown-editor/slash/brand-slash-commands.ts +324 -0
- package/src/markdown-editor/slash/brand-slash-plugin.test.ts +254 -0
- package/src/markdown-editor/slash/brand-slash-plugin.ts +385 -0
- package/src/markdown-editor/slash/index.ts +107 -0
- package/src/markdown-editor/slash/insert-directive.test.ts +263 -0
- package/src/markdown-editor/slash/insert-directive.ts +345 -0
- package/src/markdown-editor/slash/monaco-slash-menu.test.tsx +198 -0
- package/src/markdown-editor/slash/monaco-slash-menu.tsx +356 -0
- package/src/markdown-editor/slash/shortcut-monaco.ts +69 -0
- package/src/markdown-editor/slash/shortcut.test.ts +157 -0
- package/src/markdown-editor/slash/shortcut.ts +57 -0
- package/src/markdown-editor/slash/slash-menu.stories.tsx +139 -0
- package/src/markdown-editor/slash/slash-menu.tsx +115 -0
- package/src/markdown-editor/slash/slash-scroll.test.tsx +52 -0
- package/src/markdown-editor/slash/slash-widget.tsx +97 -0
- package/src/markdown-editor/slash/source-slash-trigger.test.ts +42 -0
- package/src/markdown-editor/slash/source-slash-trigger.ts +46 -0
- package/src/markdown-editor/table-view.tsx +249 -0
- package/src/markdown-iteration/directive.tsx +92 -0
- package/src/markdown-iteration/edit-context.ts +56 -0
- package/src/markdown-iteration/index.ts +24 -0
- package/src/markdown-iteration/iteration-block.test.tsx +138 -0
- package/src/markdown-iteration/iteration-builder-dialog.stories.tsx +293 -0
- package/src/markdown-iteration/iteration-builder-dialog.tsx +312 -0
- package/src/markdown-iteration/iteration-builder.test.ts +252 -0
- package/src/markdown-iteration/iteration-builder.ts +303 -0
- package/src/markdown-iteration/iteration.test.ts +56 -0
- package/src/markdown-iteration/iteration.tsx +333 -0
- package/src/markdown-iteration/template-dialog.stories.tsx +63 -0
- package/src/markdown-iteration/template-dialog.test.tsx +104 -0
- package/src/markdown-iteration/template-dialog.tsx +116 -0
- package/src/markdown-outline/document-outline.stories.tsx +76 -0
- package/src/markdown-outline/document-outline.tsx +83 -0
- package/src/markdown-outline/index.ts +2 -0
- package/src/markdown-outline/markdown-outline.test.ts +49 -0
- package/src/markdown-outline/markdown-outline.ts +60 -0
- package/src/markdown-preview/code-fence.tsx +193 -0
- package/src/markdown-preview/index.ts +8 -0
- package/src/markdown-preview/markdown-preview-academic.stories.tsx +203 -0
- package/src/markdown-preview/markdown-preview-academic.test.tsx +257 -0
- package/src/markdown-preview/markdown-preview-iteration-calc.test.tsx +71 -0
- package/src/markdown-preview/markdown-preview-iteration-nested.test.tsx +58 -0
- package/src/markdown-preview/markdown-preview-iteration.stories.tsx +187 -0
- package/src/markdown-preview/markdown-preview-iteration.test.tsx +90 -0
- package/src/markdown-preview/markdown-preview-linking.test.tsx +61 -0
- package/src/markdown-preview/markdown-preview-transclusion.test.tsx +33 -0
- package/src/markdown-preview/markdown-preview.stories.tsx +108 -0
- package/src/markdown-preview/markdown-preview.test.tsx +411 -0
- package/src/markdown-preview/markdown-preview.tsx +1541 -0
- package/src/markdown-toolbar/index.ts +8 -0
- package/src/markdown-toolbar/markdown-commands.test.ts +84 -0
- package/src/markdown-toolbar/markdown-commands.ts +104 -0
- package/src/markdown-toolbar/markdown-toolbar.stories.tsx +56 -0
- package/src/markdown-toolbar/markdown-toolbar.tsx +263 -0
- package/src/markdown-workspace/focus-writing.test.ts +52 -0
- package/src/markdown-workspace/focus-writing.ts +38 -0
- package/src/markdown-workspace/index.ts +6 -0
- package/src/markdown-workspace/markdown-workspace.stories.tsx +688 -0
- package/src/markdown-workspace/markdown-workspace.test.tsx +326 -0
- package/src/markdown-workspace/markdown-workspace.tsx +787 -0
- package/src/mermaid-diagram/index.ts +1 -0
- package/src/mermaid-diagram/mermaid-diagram.stories.tsx +42 -0
- package/src/mermaid-diagram/mermaid-diagram.test.tsx +86 -0
- package/src/mermaid-diagram/mermaid-diagram.tsx +318 -0
- package/src/mermaid-diagram/mermaid-viewer.test.tsx +89 -0
- package/src/mermaid-diagram/mermaid-viewer.tsx +367 -0
- package/src/mermaid-diagram/real-parse.test.ts +42 -0
- package/src/mermaid-diagram/remediate.test.ts +60 -0
- package/src/mermaid-diagram/remediate.ts +124 -0
- package/src/mermaid-workspace/index.ts +1 -0
- package/src/mermaid-workspace/mermaid-workspace.stories.tsx +28 -0
- package/src/mermaid-workspace/mermaid-workspace.tsx +71 -0
- package/src/metric-block/index.ts +1 -0
- package/src/metric-block/metric-block.stories.tsx +40 -0
- package/src/metric-block/metric-block.tsx +14 -0
- package/src/monaco-workers.d.ts +10 -0
- package/src/prose/index.ts +14 -0
- package/src/prose/prose.stories.tsx +44 -0
- package/src/prose/prose.test.tsx +44 -0
- package/src/prose/prose.tsx +21 -0
- package/src/timeline/index.ts +18 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the WYSIWYG completions mirror's pure/testable logic (#283) —
|
|
3
|
+
* `nextCompletionState` (the trigger/query state machine), `buildCompletionContext`
|
|
4
|
+
* + `completionReplaceRange` (the ProseMirror-position math), and
|
|
5
|
+
* `insertCompletionItem` (the real doc edit). Same pattern as
|
|
6
|
+
* `../slash/brand-slash-plugin.test.ts`: a real headless Milkdown editor
|
|
7
|
+
* supplies real `Transaction`/`Node` objects — jsdom can't render the editor,
|
|
8
|
+
* but building transactions against a live doc needs no rendering.
|
|
9
|
+
*/
|
|
10
|
+
import { Editor, defaultValueCtx, editorViewCtx, rootCtx } from "@milkdown/kit/core";
|
|
11
|
+
import { commonmark } from "@milkdown/kit/preset/commonmark";
|
|
12
|
+
import { gfm } from "@milkdown/kit/preset/gfm";
|
|
13
|
+
import { TextSelection } from "@milkdown/kit/prose/state";
|
|
14
|
+
import type { EditorView } from "@milkdown/kit/prose/view";
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
CLOSED,
|
|
19
|
+
buildCompletionContext,
|
|
20
|
+
completionReplaceRange,
|
|
21
|
+
completionsPluginKey,
|
|
22
|
+
insertCompletionItem,
|
|
23
|
+
nextCompletionState,
|
|
24
|
+
type CompletionPluginState,
|
|
25
|
+
} from "./completions-prose";
|
|
26
|
+
|
|
27
|
+
let editorRoot: HTMLDivElement | null = null;
|
|
28
|
+
|
|
29
|
+
async function makeEditor(initial = ""): Promise<Editor> {
|
|
30
|
+
const root = document.createElement("div");
|
|
31
|
+
document.body.appendChild(root);
|
|
32
|
+
editorRoot = root;
|
|
33
|
+
return Editor.make()
|
|
34
|
+
.config((ctx) => {
|
|
35
|
+
ctx.set(rootCtx, root);
|
|
36
|
+
ctx.set(defaultValueCtx, initial);
|
|
37
|
+
})
|
|
38
|
+
.use(commonmark)
|
|
39
|
+
.use(gfm)
|
|
40
|
+
.create();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
editorRoot = null;
|
|
45
|
+
});
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
editorRoot?.remove();
|
|
48
|
+
editorRoot = null;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Simulate a real typing/editing session against a LIVE view: every
|
|
53
|
+
* transaction is built from the view's CURRENT (already-dispatched) selection
|
|
54
|
+
* and immediately dispatched before the next step — the same sequence the real
|
|
55
|
+
* plugin experiences (`nextCompletionState` runs in `apply`, right before the
|
|
56
|
+
* transaction lands). Building a transaction from a position decoupled from
|
|
57
|
+
* the actual current selection is misleading: an untouched selection doesn't
|
|
58
|
+
* remap through an edit that never touched it, so it can silently stay stale.
|
|
59
|
+
*/
|
|
60
|
+
function makeTypingSession(view: EditorView, triggerCharacters: string[]) {
|
|
61
|
+
let state: CompletionPluginState = CLOSED;
|
|
62
|
+
const run = (tr: ReturnType<typeof view.state.tr.setMeta>) => {
|
|
63
|
+
state = nextCompletionState(state, tr, triggerCharacters);
|
|
64
|
+
view.dispatch(tr);
|
|
65
|
+
return state;
|
|
66
|
+
};
|
|
67
|
+
return {
|
|
68
|
+
get state() {
|
|
69
|
+
return state;
|
|
70
|
+
},
|
|
71
|
+
type: (ch: string) => run(view.state.tr.insertText(ch, view.state.selection.from)),
|
|
72
|
+
backspace: () => {
|
|
73
|
+
const from = view.state.selection.from;
|
|
74
|
+
return run(view.state.tr.delete(from - 1, from));
|
|
75
|
+
},
|
|
76
|
+
moveCaretTo: (pos: number) =>
|
|
77
|
+
run(view.state.tr.setSelection(TextSelection.near(view.state.doc.resolve(pos)))),
|
|
78
|
+
selectRange: (from: number, to: number) =>
|
|
79
|
+
run(
|
|
80
|
+
view.state.tr.setSelection(
|
|
81
|
+
TextSelection.between(view.state.doc.resolve(from), view.state.doc.resolve(to)),
|
|
82
|
+
),
|
|
83
|
+
),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe("nextCompletionState — trigger detection (real typing sequence)", () => {
|
|
88
|
+
test("a non-trigger character leaves CLOSED unchanged", async () => {
|
|
89
|
+
const editor = await makeEditor("");
|
|
90
|
+
await editor.action((ctx) => {
|
|
91
|
+
const session = makeTypingSession(ctx.get(editorViewCtx), ["["]);
|
|
92
|
+
expect(session.type("x")).toBe(CLOSED);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("typing `[[note` tracks the LAST `[`, extends the query, and closes on whitespace", async () => {
|
|
97
|
+
const editor = await makeEditor("");
|
|
98
|
+
await editor.action((ctx) => {
|
|
99
|
+
const view = ctx.get(editorViewCtx);
|
|
100
|
+
const session = makeTypingSession(view, ["["]);
|
|
101
|
+
|
|
102
|
+
let s = session.type("[");
|
|
103
|
+
expect(s.active).toBe(true);
|
|
104
|
+
expect(s.triggerChar).toBe("[");
|
|
105
|
+
expect(s.query).toBe("");
|
|
106
|
+
expect(s.requestId).toBe(1);
|
|
107
|
+
const firstFrom = s.from;
|
|
108
|
+
|
|
109
|
+
// The SECOND "[" re-anchors `from` to ITS position (not the first).
|
|
110
|
+
s = session.type("[");
|
|
111
|
+
expect(s.active).toBe(true);
|
|
112
|
+
expect(s.from).toBeGreaterThan(firstFrom);
|
|
113
|
+
expect(s.query).toBe("");
|
|
114
|
+
expect(s.requestId).toBe(2);
|
|
115
|
+
const secondFrom = s.from;
|
|
116
|
+
|
|
117
|
+
s = session.type("n");
|
|
118
|
+
expect(s.query).toBe("n");
|
|
119
|
+
expect(s.from).toBe(secondFrom); // unchanged — still anchored at the 2nd `[`
|
|
120
|
+
expect(s.requestId).toBe(3);
|
|
121
|
+
|
|
122
|
+
s = session.type("o");
|
|
123
|
+
expect(s.query).toBe("no");
|
|
124
|
+
expect(s.requestId).toBe(4);
|
|
125
|
+
|
|
126
|
+
s = session.type(" ");
|
|
127
|
+
expect(s.active).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("backspacing the query, then past the trigger, closes without a false re-trigger", async () => {
|
|
132
|
+
const editor = await makeEditor("");
|
|
133
|
+
await editor.action((ctx) => {
|
|
134
|
+
const view = ctx.get(editorViewCtx);
|
|
135
|
+
const session = makeTypingSession(view, ["["]);
|
|
136
|
+
session.type("[");
|
|
137
|
+
session.type("[");
|
|
138
|
+
session.type("n");
|
|
139
|
+
|
|
140
|
+
// Backspacing "n" exposes the trigger "[" immediately before the new
|
|
141
|
+
// caret — this must NOT be misread as "just typed a trigger character"
|
|
142
|
+
// (the net-single-char-insert guard in `nextCompletionState`).
|
|
143
|
+
let s = session.backspace();
|
|
144
|
+
expect(s.active).toBe(true);
|
|
145
|
+
expect(s.query).toBe("");
|
|
146
|
+
|
|
147
|
+
// Backspacing the trigger `[` itself moves the caret to/before `from`.
|
|
148
|
+
s = session.backspace();
|
|
149
|
+
expect(s.active).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("moving the caret out of the tracked block (without typing) closes", async () => {
|
|
154
|
+
const editor = await makeEditor("[[no\n\nAnother paragraph");
|
|
155
|
+
await editor.action((ctx) => {
|
|
156
|
+
const view = ctx.get(editorViewCtx);
|
|
157
|
+
// Manually seed an "active" session at the 2nd `[` of the FIRST paragraph
|
|
158
|
+
// (position 2, established by the sequential-typing tests above), then
|
|
159
|
+
// move the caret into the SECOND paragraph without typing anything.
|
|
160
|
+
const seeded: CompletionPluginState = {
|
|
161
|
+
active: true,
|
|
162
|
+
from: 2,
|
|
163
|
+
triggerChar: "[",
|
|
164
|
+
query: "no",
|
|
165
|
+
items: [],
|
|
166
|
+
index: 0,
|
|
167
|
+
requestId: 1,
|
|
168
|
+
};
|
|
169
|
+
const tr = view.state.tr.setSelection(
|
|
170
|
+
TextSelection.near(view.state.doc.resolve(view.state.doc.content.size)),
|
|
171
|
+
);
|
|
172
|
+
const next = nextCompletionState(seeded, tr, ["["]);
|
|
173
|
+
expect(next.active).toBe(false);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("a non-empty (real) selection closes an active popup", async () => {
|
|
178
|
+
const editor = await makeEditor("[[no");
|
|
179
|
+
await editor.action((ctx) => {
|
|
180
|
+
const view = ctx.get(editorViewCtx);
|
|
181
|
+
const session = makeTypingSession(view, ["["]);
|
|
182
|
+
session.type("[");
|
|
183
|
+
session.type("[");
|
|
184
|
+
const active = session.state;
|
|
185
|
+
expect(active.active).toBe(true);
|
|
186
|
+
|
|
187
|
+
const tr = view.state.tr.setSelection(
|
|
188
|
+
TextSelection.between(view.state.doc.resolve(1), view.state.doc.resolve(3)),
|
|
189
|
+
);
|
|
190
|
+
const next = nextCompletionState(active, tr, ["["]);
|
|
191
|
+
expect(next.active).toBe(false);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("nextCompletionState — meta transactions", () => {
|
|
197
|
+
test('a "close" meta returns CLOSED', async () => {
|
|
198
|
+
const editor = await makeEditor("[[no");
|
|
199
|
+
await editor.action((ctx) => {
|
|
200
|
+
const view = ctx.get(editorViewCtx);
|
|
201
|
+
const active: CompletionPluginState = {
|
|
202
|
+
active: true,
|
|
203
|
+
from: 2,
|
|
204
|
+
triggerChar: "[",
|
|
205
|
+
query: "no",
|
|
206
|
+
items: [],
|
|
207
|
+
index: 0,
|
|
208
|
+
requestId: 3,
|
|
209
|
+
};
|
|
210
|
+
const tr = view.state.tr.setMeta(completionsPluginKey, "close");
|
|
211
|
+
expect(nextCompletionState(active, tr, ["["]).active).toBe(false);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('a matching-requestId "items" meta updates items; a stale one is ignored', async () => {
|
|
216
|
+
const editor = await makeEditor("[[no");
|
|
217
|
+
await editor.action((ctx) => {
|
|
218
|
+
const view = ctx.get(editorViewCtx);
|
|
219
|
+
const active: CompletionPluginState = {
|
|
220
|
+
active: true,
|
|
221
|
+
from: 2,
|
|
222
|
+
triggerChar: "[",
|
|
223
|
+
query: "no",
|
|
224
|
+
items: [],
|
|
225
|
+
index: 0,
|
|
226
|
+
requestId: 3,
|
|
227
|
+
};
|
|
228
|
+
const freshItems = [{ label: "note-one", insertText: "note-one]]" }];
|
|
229
|
+
const freshTr = view.state.tr.setMeta(completionsPluginKey, {
|
|
230
|
+
type: "items",
|
|
231
|
+
items: freshItems,
|
|
232
|
+
requestId: 3,
|
|
233
|
+
});
|
|
234
|
+
const withFresh = nextCompletionState(active, freshTr, ["["]);
|
|
235
|
+
expect(withFresh.items).toEqual(freshItems);
|
|
236
|
+
|
|
237
|
+
// A stale requestId (superseded by further typing) must NOT apply.
|
|
238
|
+
const staleTr = view.state.tr.setMeta(completionsPluginKey, {
|
|
239
|
+
type: "items",
|
|
240
|
+
items: [{ label: "stale", insertText: "stale" }],
|
|
241
|
+
requestId: 2,
|
|
242
|
+
});
|
|
243
|
+
const withStale = nextCompletionState(withFresh, staleTr, ["["]);
|
|
244
|
+
expect(withStale.items).toEqual(freshItems);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('a "nav" meta updates the highlighted index only while active', async () => {
|
|
249
|
+
const editor = await makeEditor("");
|
|
250
|
+
await editor.action((ctx) => {
|
|
251
|
+
const view = ctx.get(editorViewCtx);
|
|
252
|
+
const active: CompletionPluginState = {
|
|
253
|
+
active: true,
|
|
254
|
+
from: 0,
|
|
255
|
+
triggerChar: "[",
|
|
256
|
+
query: "",
|
|
257
|
+
items: [
|
|
258
|
+
{ label: "a", insertText: "a" },
|
|
259
|
+
{ label: "b", insertText: "b" },
|
|
260
|
+
],
|
|
261
|
+
index: 0,
|
|
262
|
+
requestId: 1,
|
|
263
|
+
};
|
|
264
|
+
const navTr = view.state.tr.setMeta(completionsPluginKey, { type: "nav", index: 1 });
|
|
265
|
+
expect(nextCompletionState(active, navTr, ["["]).index).toBe(1);
|
|
266
|
+
|
|
267
|
+
const navOnClosed = nextCompletionState(CLOSED, navTr, ["["]);
|
|
268
|
+
expect(navOnClosed).toBe(CLOSED);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
describe("buildCompletionContext + completionReplaceRange", () => {
|
|
274
|
+
test("resolves line/column against the current textblock (best-effort, see file doc)", async () => {
|
|
275
|
+
const editor = await makeEditor("[[no");
|
|
276
|
+
await editor.action((ctx) => {
|
|
277
|
+
const view = ctx.get(editorViewCtx);
|
|
278
|
+
const state: CompletionPluginState = {
|
|
279
|
+
active: true,
|
|
280
|
+
from: 2, // the SECOND '[' (parentOffset 1)
|
|
281
|
+
triggerChar: "[",
|
|
282
|
+
query: "no",
|
|
283
|
+
items: [],
|
|
284
|
+
index: 0,
|
|
285
|
+
requestId: 1,
|
|
286
|
+
};
|
|
287
|
+
const context = buildCompletionContext(view.state.doc, state);
|
|
288
|
+
expect(context).toEqual({ source: "[[no", line: 1, column: 5, lineText: "[[no" });
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("replaces only the typed query, preserving the already-typed trigger characters", async () => {
|
|
293
|
+
const editor = await makeEditor("[[no");
|
|
294
|
+
await editor.action((ctx) => {
|
|
295
|
+
const view = ctx.get(editorViewCtx);
|
|
296
|
+
const state: CompletionPluginState = {
|
|
297
|
+
active: true,
|
|
298
|
+
from: 2,
|
|
299
|
+
triggerChar: "[",
|
|
300
|
+
query: "no",
|
|
301
|
+
items: [],
|
|
302
|
+
index: 0,
|
|
303
|
+
requestId: 1,
|
|
304
|
+
};
|
|
305
|
+
const item = { label: "note-one", insertText: "note-one]]" };
|
|
306
|
+
const range = completionReplaceRange(view.state.doc, state, item);
|
|
307
|
+
// Positions 3..5 are exactly "no" (the query) — the leading "[[" survives.
|
|
308
|
+
expect(range).toEqual({ from: 3, to: 5 });
|
|
309
|
+
expect(view.state.doc.textBetween(range.from, range.to)).toBe("no");
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("honors an explicit replaceFrom over the trigger-query default", async () => {
|
|
314
|
+
const editor = await makeEditor("[[no");
|
|
315
|
+
await editor.action((ctx) => {
|
|
316
|
+
const view = ctx.get(editorViewCtx);
|
|
317
|
+
const state: CompletionPluginState = {
|
|
318
|
+
active: true,
|
|
319
|
+
from: 2,
|
|
320
|
+
triggerChar: "[",
|
|
321
|
+
query: "no",
|
|
322
|
+
items: [],
|
|
323
|
+
index: 0,
|
|
324
|
+
requestId: 1,
|
|
325
|
+
};
|
|
326
|
+
// replaceFrom: 1 → column 1 → doc position blockStart + 0 = 1 (replaces the WHOLE "[[no").
|
|
327
|
+
const item = { label: "x", insertText: "y", replaceFrom: 1 };
|
|
328
|
+
const range = completionReplaceRange(view.state.doc, state, item);
|
|
329
|
+
expect(range).toEqual({ from: 1, to: 5 });
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
describe("insertCompletionItem", () => {
|
|
335
|
+
test("replaces the typed query with insertText and closes the popup", async () => {
|
|
336
|
+
const editor = await makeEditor("[[no");
|
|
337
|
+
await editor.action((ctx) => {
|
|
338
|
+
const view = ctx.get(editorViewCtx);
|
|
339
|
+
const state: CompletionPluginState = {
|
|
340
|
+
active: true,
|
|
341
|
+
from: 2,
|
|
342
|
+
triggerChar: "[",
|
|
343
|
+
query: "no",
|
|
344
|
+
items: [],
|
|
345
|
+
index: 0,
|
|
346
|
+
requestId: 1,
|
|
347
|
+
};
|
|
348
|
+
insertCompletionItem(view, state, { label: "note-one", insertText: "note-one]]" });
|
|
349
|
+
// Assert directly against the doc (not the serialized markdown) — the
|
|
350
|
+
// markdown SERIALIZER escapes a leading "[" (`\[`) to avoid it reading as
|
|
351
|
+
// link/footnote syntax, which is correct output but would make a
|
|
352
|
+
// string-equality assertion on `serialize()` fragile here.
|
|
353
|
+
expect(view.state.doc.textContent).toBe("[[note-one]]");
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
});
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The WYSIWYG (Milkdown/ProseMirror) mirror of the completion-provider API
|
|
5
|
+
* (#283) — a self-owned raw `$prose` plugin (the `brand-slash-plugin.ts`
|
|
6
|
+
* precedent), Monaco-free.
|
|
7
|
+
*
|
|
8
|
+
* ## What this covers, and what it doesn't (read before reaching for this file)
|
|
9
|
+
*
|
|
10
|
+
* The Monaco / source-pane path (`../../lib/editor-completions-monaco.ts`) gets
|
|
11
|
+
* REAL document line/column coordinates and Monaco's own themed suggest widget
|
|
12
|
+
* for free. Milkdown's ProseMirror document is a NODE TREE, not line-oriented
|
|
13
|
+
* text, so there is no exact equivalent — this is a deliberately MINIMAL,
|
|
14
|
+
* best-effort mirror (per #283: "if full WYSIWYG parity is too large to land
|
|
15
|
+
* safely, ship... a minimal WYSIWYG trigger→insert"):
|
|
16
|
+
*
|
|
17
|
+
* - `EditorCompletionContext.source` / `.lineText` are the CURRENT TEXTBLOCK's
|
|
18
|
+
* plain text only (not the whole document, and not markdown-serialized) —
|
|
19
|
+
* good enough for a provider whose logic looks at the text around the caret
|
|
20
|
+
* (the `[[wikilink]]` case this issue targets), but NOT a byte-exact mirror
|
|
21
|
+
* of the Monaco context.
|
|
22
|
+
* - `.line` is always `1` (there is no cross-block "line number" concept).
|
|
23
|
+
* - The popup is a plain, unstyled-beyond-tokens listbox (no groups/icons —
|
|
24
|
+
* `EditorCompletionItem` doesn't carry them), NOT Monaco's native widget.
|
|
25
|
+
* - Keyboard support covers ↑/↓/Enter/Tab/Esc (feature parity with the Monaco
|
|
26
|
+
* path's expectations) but not mouse-hover-to-preview or the scroll-into-view
|
|
27
|
+
* polish `MonacoSlashMenu`/`slash-widget.tsx` add.
|
|
28
|
+
*
|
|
29
|
+
* Candidates/filtering/insertText stay 100% consumer-owned either way — this
|
|
30
|
+
* file only detects the trigger, asks the provider, and renders/inserts.
|
|
31
|
+
*/
|
|
32
|
+
import type { MilkdownPlugin } from "@milkdown/kit/ctx";
|
|
33
|
+
import type { Node as ProseNode } from "@milkdown/kit/prose/model";
|
|
34
|
+
import { Plugin, PluginKey, type Transaction } from "@milkdown/kit/prose/state";
|
|
35
|
+
import type { EditorView } from "@milkdown/kit/prose/view";
|
|
36
|
+
import { DecorationSet } from "@milkdown/kit/prose/view";
|
|
37
|
+
import { $prose } from "@milkdown/kit/utils";
|
|
38
|
+
import type { ReactWidgetViewComponent, useWidgetViewFactory } from "@prosemirror-adapter/react";
|
|
39
|
+
|
|
40
|
+
import {
|
|
41
|
+
collectCompletions,
|
|
42
|
+
resolveReplaceRange,
|
|
43
|
+
type EditorCompletionContext,
|
|
44
|
+
type EditorCompletionItem,
|
|
45
|
+
type EditorCompletionProvider,
|
|
46
|
+
} from "../../lib/editor-completions";
|
|
47
|
+
|
|
48
|
+
/** The factory `useWidgetViewFactory()` returns (borrowed, see `brand-slash-plugin.ts`). */
|
|
49
|
+
export type CompletionWidgetFactory = ReturnType<typeof useWidgetViewFactory>;
|
|
50
|
+
|
|
51
|
+
/** Plugin state. `active === false` means no popup is showing. */
|
|
52
|
+
export interface CompletionPluginState {
|
|
53
|
+
active: boolean;
|
|
54
|
+
/** Document position of the trigger character that opened/re-opened tracking. */
|
|
55
|
+
from: number;
|
|
56
|
+
/** The trigger character itself (matched against each provider's `triggerCharacters`). */
|
|
57
|
+
triggerChar: string;
|
|
58
|
+
/** Text typed since the trigger (closes on whitespace / caret leaving the block). */
|
|
59
|
+
query: string;
|
|
60
|
+
/** The latest resolved candidates (empty while a fetch is in flight). */
|
|
61
|
+
items: EditorCompletionItem[];
|
|
62
|
+
/** Index into `items` of the highlighted candidate. */
|
|
63
|
+
index: number;
|
|
64
|
+
/** Bumped on every trigger/query change; guards a stale async `provide()` reply. */
|
|
65
|
+
requestId: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const CLOSED: CompletionPluginState = {
|
|
69
|
+
active: false,
|
|
70
|
+
from: 0,
|
|
71
|
+
triggerChar: "",
|
|
72
|
+
query: "",
|
|
73
|
+
items: [],
|
|
74
|
+
index: 0,
|
|
75
|
+
requestId: 0,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export const completionsPluginKey = new PluginKey<CompletionPluginState>("brand-completions");
|
|
79
|
+
|
|
80
|
+
type CompletionMeta =
|
|
81
|
+
| "close"
|
|
82
|
+
| { type: "items"; items: EditorCompletionItem[]; requestId: number }
|
|
83
|
+
| { type: "nav"; index: number };
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Recompute state from a transaction. Exported for unit tests (the
|
|
87
|
+
* `brand-slash-plugin.ts` / `nextState` precedent) — a pure function over
|
|
88
|
+
* `(prev, transaction, triggerCharacters)`, no rendering needed to exercise it.
|
|
89
|
+
*
|
|
90
|
+
* A freshly typed trigger character ALWAYS (re)opens tracking at its position —
|
|
91
|
+
* even while already active — so `[[note` tracks the LAST `[` (matching the
|
|
92
|
+
* Monaco path's `triggerQueryStart`, which also resolves to the last
|
|
93
|
+
* occurrence): the first `[` opens, the second `[` re-anchors with an empty
|
|
94
|
+
* query, and `note` extends it from there.
|
|
95
|
+
*/
|
|
96
|
+
export function nextCompletionState(
|
|
97
|
+
prev: CompletionPluginState,
|
|
98
|
+
tr: Transaction,
|
|
99
|
+
triggerCharacters: string[],
|
|
100
|
+
): CompletionPluginState {
|
|
101
|
+
const meta = tr.getMeta(completionsPluginKey) as CompletionMeta | undefined;
|
|
102
|
+
if (meta === "close") return CLOSED;
|
|
103
|
+
if (meta && typeof meta === "object") {
|
|
104
|
+
if (meta.type === "items") {
|
|
105
|
+
return prev.active && meta.requestId === prev.requestId
|
|
106
|
+
? { ...prev, items: meta.items }
|
|
107
|
+
: prev;
|
|
108
|
+
}
|
|
109
|
+
if (meta.type === "nav") {
|
|
110
|
+
return prev.active ? { ...prev, index: meta.index } : prev;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const sel = tr.selection;
|
|
115
|
+
if (!sel.empty) return prev.active ? CLOSED : prev;
|
|
116
|
+
const pos = sel.from;
|
|
117
|
+
|
|
118
|
+
// A pure single-character INSERT nets the doc +1 char with a single step —
|
|
119
|
+
// this excludes a backspace/delete (net -1) from ever being misread as
|
|
120
|
+
// "just typed a trigger character" just because deleting happened to expose
|
|
121
|
+
// one immediately before the new caret (e.g. backspacing "n" out of "[[n"
|
|
122
|
+
// lands the caret right after the trigger "["; textBetween alone can't tell
|
|
123
|
+
// an insert from a delete apart, so it needs this net-growth guard too).
|
|
124
|
+
const isPureSingleCharInsert =
|
|
125
|
+
tr.docChanged && tr.steps.length === 1 && tr.doc.content.size === tr.before.content.size + 1;
|
|
126
|
+
|
|
127
|
+
if (isPureSingleCharInsert) {
|
|
128
|
+
const justTyped = tr.doc.textBetween(Math.max(0, pos - 1), pos);
|
|
129
|
+
if (triggerCharacters.includes(justTyped)) {
|
|
130
|
+
return {
|
|
131
|
+
active: true,
|
|
132
|
+
from: pos - 1,
|
|
133
|
+
triggerChar: justTyped,
|
|
134
|
+
query: "",
|
|
135
|
+
items: [],
|
|
136
|
+
index: 0,
|
|
137
|
+
requestId: prev.requestId + 1,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (!prev.active) return prev;
|
|
143
|
+
if (pos <= prev.from) return CLOSED;
|
|
144
|
+
|
|
145
|
+
const start = tr.doc.resolve(prev.from);
|
|
146
|
+
const $pos = tr.doc.resolve(pos);
|
|
147
|
+
if (start.parent !== $pos.parent) return CLOSED;
|
|
148
|
+
|
|
149
|
+
const query = $pos.parent.textBetween(start.parentOffset + 1, $pos.parentOffset);
|
|
150
|
+
if (/\s/.test(query)) return CLOSED;
|
|
151
|
+
if (query === prev.query) return prev;
|
|
152
|
+
return { ...prev, query, index: 0, requestId: prev.requestId + 1 };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Build the (best-effort — see file doc) context handed to `provider.provide()`. */
|
|
156
|
+
export function buildCompletionContext(
|
|
157
|
+
doc: ProseNode,
|
|
158
|
+
state: CompletionPluginState,
|
|
159
|
+
): EditorCompletionContext {
|
|
160
|
+
const resolved = doc.resolve(state.from);
|
|
161
|
+
const blockStart = resolved.pos - resolved.parentOffset;
|
|
162
|
+
const lineText = resolved.parent.textContent;
|
|
163
|
+
const caretPos = state.from + 1 + state.query.length;
|
|
164
|
+
const column = caretPos - blockStart + 1;
|
|
165
|
+
return { source: lineText, line: 1, column, lineText };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Resolve the `[from, to)` doc-position range an item's `insertText` replaces,
|
|
170
|
+
* reusing the SAME pure range math the Monaco path uses (`resolveReplaceRange`)
|
|
171
|
+
* against this textblock's plain text treated as "line 1".
|
|
172
|
+
*/
|
|
173
|
+
export function completionReplaceRange(
|
|
174
|
+
doc: ProseNode,
|
|
175
|
+
state: CompletionPluginState,
|
|
176
|
+
item: EditorCompletionItem,
|
|
177
|
+
): { from: number; to: number } {
|
|
178
|
+
const resolved = doc.resolve(state.from);
|
|
179
|
+
const blockStart = resolved.pos - resolved.parentOffset;
|
|
180
|
+
const lineText = resolved.parent.textContent;
|
|
181
|
+
const caretPos = state.from + 1 + state.query.length;
|
|
182
|
+
const column = caretPos - blockStart + 1;
|
|
183
|
+
const range = resolveReplaceRange(item, { lineNumber: 1, column }, lineText, [state.triggerChar]);
|
|
184
|
+
return {
|
|
185
|
+
from: blockStart + (range.startColumn - 1),
|
|
186
|
+
to: blockStart + (range.endColumn - 1),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Insert `item` (replacing the resolved range), close the popup, refocus. */
|
|
191
|
+
export function insertCompletionItem(
|
|
192
|
+
view: EditorView,
|
|
193
|
+
state: CompletionPluginState,
|
|
194
|
+
item: EditorCompletionItem,
|
|
195
|
+
): void {
|
|
196
|
+
const { from, to } = completionReplaceRange(view.state.doc, state, item);
|
|
197
|
+
view.dispatch(
|
|
198
|
+
view.state.tr.insertText(item.insertText, from, to).setMeta(completionsPluginKey, "close"),
|
|
199
|
+
);
|
|
200
|
+
view.focus();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Options for {@link completionsProsePlugin}. */
|
|
204
|
+
export interface CompletionsProsePluginOptions {
|
|
205
|
+
/** The widget factory from `useWidgetViewFactory()` (must run under the adapter provider). */
|
|
206
|
+
widgetFactory: CompletionWidgetFactory;
|
|
207
|
+
/** The React component rendered as the widget (built by `createCompletionWidget`). */
|
|
208
|
+
widgetComponent: ReactWidgetViewComponent;
|
|
209
|
+
/** Read live on every transaction / fetch — a rebuilt array is picked up without a rebuild. */
|
|
210
|
+
getProviders: () => EditorCompletionProvider[] | undefined;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Build the completions `$prose` plugin. Returns a Milkdown plugin to `.use()`. */
|
|
214
|
+
export function completionsProsePlugin(options: CompletionsProsePluginOptions): MilkdownPlugin {
|
|
215
|
+
const { widgetFactory, widgetComponent, getProviders } = options;
|
|
216
|
+
|
|
217
|
+
return $prose(() => {
|
|
218
|
+
return new Plugin<CompletionPluginState>({
|
|
219
|
+
key: completionsPluginKey,
|
|
220
|
+
state: {
|
|
221
|
+
init: () => CLOSED,
|
|
222
|
+
apply: (tr, value) => {
|
|
223
|
+
const providers = getProviders() ?? [];
|
|
224
|
+
const triggerCharacters = Array.from(
|
|
225
|
+
new Set(providers.flatMap((p) => p.triggerCharacters ?? [])),
|
|
226
|
+
);
|
|
227
|
+
return nextCompletionState(value, tr, triggerCharacters);
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
props: {
|
|
231
|
+
decorations: (state) => {
|
|
232
|
+
const s = completionsPluginKey.getState(state);
|
|
233
|
+
if (!s?.active) return DecorationSet.empty;
|
|
234
|
+
const factory = widgetFactory({ component: widgetComponent, as: "span" });
|
|
235
|
+
const anchor = s.from + 1 + s.query.length; // right at the caret
|
|
236
|
+
const decoration = factory(anchor, {
|
|
237
|
+
side: 1,
|
|
238
|
+
ignoreSelection: true,
|
|
239
|
+
key: `brand-completions:${String(s.from)}:${s.query}:${String(s.items.length)}:${String(s.index)}`,
|
|
240
|
+
});
|
|
241
|
+
return DecorationSet.create(state.doc, [decoration]);
|
|
242
|
+
},
|
|
243
|
+
handleKeyDown: (view, event) => {
|
|
244
|
+
const s = completionsPluginKey.getState(view.state);
|
|
245
|
+
if (!s?.active) return false;
|
|
246
|
+
|
|
247
|
+
if (event.key === "Escape") {
|
|
248
|
+
view.dispatch(view.state.tr.setMeta(completionsPluginKey, "close"));
|
|
249
|
+
event.preventDefault();
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
if (s.items.length === 0) return false;
|
|
253
|
+
|
|
254
|
+
if (event.key === "ArrowDown") {
|
|
255
|
+
const index = (s.index + 1) % s.items.length;
|
|
256
|
+
view.dispatch(view.state.tr.setMeta(completionsPluginKey, { type: "nav", index }));
|
|
257
|
+
event.preventDefault();
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
if (event.key === "ArrowUp") {
|
|
261
|
+
const index = (s.index - 1 + s.items.length) % s.items.length;
|
|
262
|
+
view.dispatch(view.state.tr.setMeta(completionsPluginKey, { type: "nav", index }));
|
|
263
|
+
event.preventDefault();
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
if (event.key === "Enter" || event.key === "Tab") {
|
|
267
|
+
const item = s.items[Math.min(s.index, s.items.length - 1)];
|
|
268
|
+
if (item) {
|
|
269
|
+
insertCompletionItem(view, s, item);
|
|
270
|
+
event.preventDefault();
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return false;
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
view: () => ({
|
|
278
|
+
update: (view, prevEditorState) => {
|
|
279
|
+
const state = completionsPluginKey.getState(view.state);
|
|
280
|
+
const prev = completionsPluginKey.getState(prevEditorState);
|
|
281
|
+
if (!state?.active) return;
|
|
282
|
+
if (prev?.active && prev.requestId === state.requestId) return;
|
|
283
|
+
|
|
284
|
+
const providers = (getProviders() ?? []).filter(
|
|
285
|
+
(p) => !p.triggerCharacters || p.triggerCharacters.includes(state.triggerChar),
|
|
286
|
+
);
|
|
287
|
+
if (providers.length === 0) return;
|
|
288
|
+
|
|
289
|
+
const ctx = buildCompletionContext(view.state.doc, state);
|
|
290
|
+
const requestId = state.requestId;
|
|
291
|
+
collectCompletions(providers, ctx)
|
|
292
|
+
.then((matches) => {
|
|
293
|
+
if (view.isDestroyed) return;
|
|
294
|
+
const current = completionsPluginKey.getState(view.state);
|
|
295
|
+
if (!current?.active || current.requestId !== requestId) return; // stale — superseded
|
|
296
|
+
view.dispatch(
|
|
297
|
+
view.state.tr.setMeta(completionsPluginKey, {
|
|
298
|
+
type: "items",
|
|
299
|
+
items: matches.map((m) => m.item),
|
|
300
|
+
requestId,
|
|
301
|
+
}),
|
|
302
|
+
);
|
|
303
|
+
})
|
|
304
|
+
.catch(() => {
|
|
305
|
+
// collectCompletions already degrades a throwing/rejecting provider to
|
|
306
|
+
// "no items" internally; this catch only guards an unexpected failure
|
|
307
|
+
// in the .then() itself (e.g. a destroyed view) — never surface it.
|
|
308
|
+
});
|
|
309
|
+
},
|
|
310
|
+
}),
|
|
311
|
+
});
|
|
312
|
+
}) as unknown as MilkdownPlugin;
|
|
313
|
+
}
|