@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,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `MonacoSlashMenu`'s run-only command selection (#299) —
|
|
3
|
+
* `runInSource` appears alongside `snippet` in the source-pane menu, wins over
|
|
4
|
+
* `snippet` when both select paths fire (Enter/Tab AND click), strips the
|
|
5
|
+
* typed `/query` trigger first, and a snippet-only command is unaffected.
|
|
6
|
+
*
|
|
7
|
+
* Monaco can't render in jsdom, so `monaco-editor` is mocked (the
|
|
8
|
+
* `markdown-workspace.test.tsx` precedent) and a hand-built editor object
|
|
9
|
+
* stands in for `MonacoCodeEditor` (the `editor-content-access.test.ts`
|
|
10
|
+
* precedent) — `MonacoSlashMenu` only calls a handful of methods on it.
|
|
11
|
+
*/
|
|
12
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
13
|
+
import type { IRange } from "monaco-editor";
|
|
14
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
15
|
+
|
|
16
|
+
vi.mock("monaco-editor", () => ({
|
|
17
|
+
Selection: class {},
|
|
18
|
+
Range: class {},
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
import { MonacoSlashMenu } from "./monaco-slash-menu";
|
|
22
|
+
import type { SlashCommand } from "./brand-slash-commands";
|
|
23
|
+
|
|
24
|
+
function makeEditor() {
|
|
25
|
+
const domNode = document.createElement("div");
|
|
26
|
+
return {
|
|
27
|
+
getPosition: vi.fn(() => ({ lineNumber: 1, column: 5 })),
|
|
28
|
+
getScrolledVisiblePosition: vi.fn(() => ({ top: 20, left: 10, height: 20 })),
|
|
29
|
+
getDomNode: vi.fn(() => domNode),
|
|
30
|
+
onDidScrollChange: vi.fn(() => ({ dispose: vi.fn() })),
|
|
31
|
+
onDidChangeCursorPosition: vi.fn(() => ({ dispose: vi.fn() })),
|
|
32
|
+
onDidBlurEditorText: vi.fn(() => ({ dispose: vi.fn() })),
|
|
33
|
+
executeEdits: vi.fn(),
|
|
34
|
+
getModel: vi.fn(() => ({ getValue: vi.fn(() => ""), getValueInRange: vi.fn(() => "") })),
|
|
35
|
+
getSelection: vi.fn(() => null),
|
|
36
|
+
focus: vi.fn(),
|
|
37
|
+
domNode,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const triggerRange: IRange = {
|
|
42
|
+
startLineNumber: 1,
|
|
43
|
+
startColumn: 1,
|
|
44
|
+
endLineNumber: 1,
|
|
45
|
+
endColumn: 2,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
vi.clearAllMocks();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("MonacoSlashMenu — run-only commands (#299)", () => {
|
|
53
|
+
it("renders a run-only command (no snippet) alongside snippet commands", () => {
|
|
54
|
+
const editor = makeEditor();
|
|
55
|
+
const runOnly: SlashCommand = {
|
|
56
|
+
id: "ask-ai",
|
|
57
|
+
label: "Ask AI",
|
|
58
|
+
run: () => {},
|
|
59
|
+
runInSource: vi.fn(),
|
|
60
|
+
};
|
|
61
|
+
render(
|
|
62
|
+
<MonacoSlashMenu
|
|
63
|
+
editor={editor as never}
|
|
64
|
+
commands={[runOnly]}
|
|
65
|
+
open
|
|
66
|
+
onOpenChange={vi.fn()}
|
|
67
|
+
triggerRange={triggerRange}
|
|
68
|
+
/>,
|
|
69
|
+
);
|
|
70
|
+
expect(screen.getByText("Ask AI")).toBeInTheDocument();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("Enter strips the typed trigger, calls runInSource with editor+range+content, closes, and refocuses", () => {
|
|
74
|
+
const editor = makeEditor();
|
|
75
|
+
const runInSource = vi.fn();
|
|
76
|
+
const runOnly: SlashCommand = { id: "ask-ai", label: "Ask AI", run: () => {}, runInSource };
|
|
77
|
+
const onOpenChange = vi.fn();
|
|
78
|
+
render(
|
|
79
|
+
<MonacoSlashMenu
|
|
80
|
+
editor={editor as never}
|
|
81
|
+
commands={[runOnly]}
|
|
82
|
+
open
|
|
83
|
+
onOpenChange={onOpenChange}
|
|
84
|
+
triggerRange={triggerRange}
|
|
85
|
+
/>,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
fireEvent.keyDown(editor.domNode, { key: "Enter" });
|
|
89
|
+
|
|
90
|
+
// The `/query` trigger is stripped BEFORE runInSource fires.
|
|
91
|
+
expect(editor.executeEdits).toHaveBeenCalledWith("brand-slash-typed", [
|
|
92
|
+
{ range: triggerRange, text: "" },
|
|
93
|
+
]);
|
|
94
|
+
expect(runInSource).toHaveBeenCalledTimes(1);
|
|
95
|
+
const ctx = runInSource.mock.calls[0]?.[0];
|
|
96
|
+
expect(ctx?.editor).toBe(editor);
|
|
97
|
+
expect(ctx?.range).toBe(triggerRange);
|
|
98
|
+
expect(typeof ctx?.content.getText).toBe("function");
|
|
99
|
+
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
100
|
+
expect(editor.focus).toHaveBeenCalledTimes(1);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("clicking the option (the mouse path) calls runInSource identically to Enter", () => {
|
|
104
|
+
const editor = makeEditor();
|
|
105
|
+
const runInSource = vi.fn();
|
|
106
|
+
const runOnly: SlashCommand = { id: "ask-ai", label: "Ask AI", run: () => {}, runInSource };
|
|
107
|
+
render(
|
|
108
|
+
<MonacoSlashMenu
|
|
109
|
+
editor={editor as never}
|
|
110
|
+
commands={[runOnly]}
|
|
111
|
+
open
|
|
112
|
+
onOpenChange={vi.fn()}
|
|
113
|
+
triggerRange={triggerRange}
|
|
114
|
+
/>,
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
fireEvent.mouseDown(screen.getByText("Ask AI"));
|
|
118
|
+
|
|
119
|
+
expect(editor.executeEdits).toHaveBeenCalledWith("brand-slash-typed", [
|
|
120
|
+
{ range: triggerRange, text: "" },
|
|
121
|
+
]);
|
|
122
|
+
expect(runInSource).toHaveBeenCalledTimes(1);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("on the hotkey path (no triggerRange), runInSource still fires with range: null and nothing is stripped", () => {
|
|
126
|
+
const editor = makeEditor();
|
|
127
|
+
const runInSource = vi.fn();
|
|
128
|
+
const runOnly: SlashCommand = { id: "ask-ai", label: "Ask AI", run: () => {}, runInSource };
|
|
129
|
+
render(
|
|
130
|
+
<MonacoSlashMenu
|
|
131
|
+
editor={editor as never}
|
|
132
|
+
commands={[runOnly]}
|
|
133
|
+
open
|
|
134
|
+
onOpenChange={vi.fn()}
|
|
135
|
+
triggerRange={null}
|
|
136
|
+
/>,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
fireEvent.keyDown(editor.domNode, { key: "Enter" });
|
|
140
|
+
|
|
141
|
+
expect(editor.executeEdits).not.toHaveBeenCalled();
|
|
142
|
+
expect(runInSource).toHaveBeenCalledTimes(1);
|
|
143
|
+
expect(runInSource.mock.calls[0]?.[0]?.range).toBeNull();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("a snippet-only command is unaffected — Enter still inserts the snippet via executeEdits", () => {
|
|
147
|
+
const editor = makeEditor();
|
|
148
|
+
const snippetOnly: SlashCommand = {
|
|
149
|
+
id: "brand-card",
|
|
150
|
+
label: "Card",
|
|
151
|
+
snippet: ":::card\ntext\n:::",
|
|
152
|
+
run: () => {},
|
|
153
|
+
};
|
|
154
|
+
render(
|
|
155
|
+
<MonacoSlashMenu
|
|
156
|
+
editor={editor as never}
|
|
157
|
+
commands={[snippetOnly]}
|
|
158
|
+
open
|
|
159
|
+
onOpenChange={vi.fn()}
|
|
160
|
+
triggerRange={triggerRange}
|
|
161
|
+
/>,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
fireEvent.keyDown(editor.domNode, { key: "Enter" });
|
|
165
|
+
|
|
166
|
+
expect(editor.executeEdits).toHaveBeenCalledWith("brand-slash-typed", [
|
|
167
|
+
{ range: triggerRange, text: ":::card\ntext\n:::", forceMoveMarkers: true },
|
|
168
|
+
]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("runInSource wins over snippet when a command carries both", () => {
|
|
172
|
+
const editor = makeEditor();
|
|
173
|
+
const runInSource = vi.fn();
|
|
174
|
+
const both: SlashCommand = {
|
|
175
|
+
id: "both",
|
|
176
|
+
label: "Both",
|
|
177
|
+
snippet: "should not be inserted",
|
|
178
|
+
run: () => {},
|
|
179
|
+
runInSource,
|
|
180
|
+
};
|
|
181
|
+
render(
|
|
182
|
+
<MonacoSlashMenu
|
|
183
|
+
editor={editor as never}
|
|
184
|
+
commands={[both]}
|
|
185
|
+
open
|
|
186
|
+
onOpenChange={vi.fn()}
|
|
187
|
+
triggerRange={triggerRange}
|
|
188
|
+
/>,
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
fireEvent.keyDown(editor.domNode, { key: "Enter" });
|
|
192
|
+
|
|
193
|
+
expect(runInSource).toHaveBeenCalledTimes(1);
|
|
194
|
+
expect(editor.executeEdits).toHaveBeenCalledWith("brand-slash-typed", [
|
|
195
|
+
{ range: triggerRange, text: "" },
|
|
196
|
+
]);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MonacoSlashMenu — a caret-anchored slash command popup for the Monaco source
|
|
5
|
+
* pane (#271).
|
|
6
|
+
*
|
|
7
|
+
* The workspace owns the open/closed state (via a `CodeEditor` action keybinding
|
|
8
|
+
* registered through `CodeEditor.actions`). This component is the POSITIONING
|
|
9
|
+
* CONTROLLER + KEYBOARD HANDLER that wraps the shared `SlashMenu` body — it does
|
|
10
|
+
* NOT re-implement the listbox (no duplication, tokens only, same look as the
|
|
11
|
+
* WYSIWYG widget).
|
|
12
|
+
*
|
|
13
|
+
* a11y: the `SlashMenu` already provides `role="listbox"` / `role="option"` /
|
|
14
|
+
* `aria-selected`; this wrapper wires `aria-activedescendant` on the editor's
|
|
15
|
+
* textarea so AT can follow the active row. Esc closes and refocuses the editor.
|
|
16
|
+
*/
|
|
17
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
18
|
+
import type { IRange } from "monaco-editor";
|
|
19
|
+
import { useEffect, useRef, useState } from "react";
|
|
20
|
+
|
|
21
|
+
import type { MonacoCodeEditor } from "../../code-editor";
|
|
22
|
+
import { monacoContentAccess } from "../../lib/editor-content-access";
|
|
23
|
+
import { insertDirective } from "../../markdown-toolbar/markdown-commands";
|
|
24
|
+
import { filterSlashCommands, type SlashCommand } from "./brand-slash-commands";
|
|
25
|
+
import { SlashMenu, slashOptionId } from "./slash-menu";
|
|
26
|
+
|
|
27
|
+
export interface MonacoSlashMenuProps {
|
|
28
|
+
/** The live Monaco editor instance (source pane). */
|
|
29
|
+
editor: MonacoCodeEditor;
|
|
30
|
+
/**
|
|
31
|
+
* Commands to show in the source pane — filtered by the workspace to
|
|
32
|
+
* `snippet != null || typeof runInSource === "function"` (#299): a command
|
|
33
|
+
* needs a text snippet, a source-pane handler, or both to appear here (a
|
|
34
|
+
* run-only WYSIWYG command with neither is Milkdown-only and stays excluded).
|
|
35
|
+
*/
|
|
36
|
+
commands: SlashCommand[];
|
|
37
|
+
/** Controlled open state — the workspace toggles it via the Layer-1 action. */
|
|
38
|
+
open: boolean;
|
|
39
|
+
onOpenChange: (open: boolean) => void;
|
|
40
|
+
/**
|
|
41
|
+
* Insert the selected snippet at the Monaco caret + refocus.
|
|
42
|
+
* Defaults to `insertDirective` from `markdown-commands.ts`.
|
|
43
|
+
*/
|
|
44
|
+
onInsert?: (editor: MonacoCodeEditor, snippet: string) => void;
|
|
45
|
+
/**
|
|
46
|
+
* When the menu was opened by typing `/` (not the hotkey), the model range of
|
|
47
|
+
* that `/`. On select the `/` is REPLACED by the inserted block; on cancel
|
|
48
|
+
* (Escape / Backspace past the trigger) it is removed. `null`/omitted = the
|
|
49
|
+
* hotkey path, which inserts via `onInsert`/`insertDirective` and leaves the
|
|
50
|
+
* document otherwise untouched.
|
|
51
|
+
*/
|
|
52
|
+
triggerRange?: IRange | null;
|
|
53
|
+
/** Merged onto the positioned popup container (the inline `position:fixed` anchor stays). */
|
|
54
|
+
className?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const ID_PREFIX = "brand-monaco-slash";
|
|
58
|
+
const LISTBOX_ID = `${ID_PREFIX}-listbox`;
|
|
59
|
+
|
|
60
|
+
/** Fixed/absolute coords of the popup anchor. */
|
|
61
|
+
interface Coords {
|
|
62
|
+
top: number;
|
|
63
|
+
left: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getCaretCoords(editor: MonacoCodeEditor): Coords | null {
|
|
67
|
+
const pos = editor.getPosition();
|
|
68
|
+
if (!pos) return null;
|
|
69
|
+
const scrolled = editor.getScrolledVisiblePosition(pos);
|
|
70
|
+
if (!scrolled) return null;
|
|
71
|
+
const domNode = editor.getDomNode();
|
|
72
|
+
if (!domNode) return null;
|
|
73
|
+
const rect = domNode.getBoundingClientRect();
|
|
74
|
+
return {
|
|
75
|
+
top: rect.top + scrolled.top + (scrolled.height ?? 20),
|
|
76
|
+
left: rect.left + scrolled.left,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function MonacoSlashMenu({
|
|
81
|
+
editor,
|
|
82
|
+
commands,
|
|
83
|
+
open,
|
|
84
|
+
onOpenChange,
|
|
85
|
+
onInsert,
|
|
86
|
+
triggerRange,
|
|
87
|
+
className,
|
|
88
|
+
}: MonacoSlashMenuProps) {
|
|
89
|
+
const [query, setQuery] = useState("");
|
|
90
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
91
|
+
const [coords, setCoords] = useState<Coords | null>(null);
|
|
92
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
93
|
+
|
|
94
|
+
// Insert the chosen snippet. Typed-`/` mode REPLACES the `/` (triggerRange) with
|
|
95
|
+
// the block; hotkey mode inserts via insertDirective at the caret/line. Always
|
|
96
|
+
// refocus the editor and close.
|
|
97
|
+
const commitSnippet = (snippet: string) => {
|
|
98
|
+
if (triggerRange) {
|
|
99
|
+
editor.executeEdits("brand-slash-typed", [
|
|
100
|
+
{ range: triggerRange, text: snippet, forceMoveMarkers: true },
|
|
101
|
+
]);
|
|
102
|
+
} else {
|
|
103
|
+
(onInsert ?? insertDirective)(editor, snippet);
|
|
104
|
+
}
|
|
105
|
+
onOpenChange(false);
|
|
106
|
+
editor.focus();
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// Run a source-pane handler (#299): strip the typed `/query` trigger FIRST
|
|
110
|
+
// (the same edit `cancel` uses, so the doc is clean whether the handler
|
|
111
|
+
// opens a dialog, schedules async work, or inserts nothing), then call it
|
|
112
|
+
// with the live editor + the (now-stale) trigger range + content access.
|
|
113
|
+
const commitRunInSource = (command: SlashCommand) => {
|
|
114
|
+
if (triggerRange) {
|
|
115
|
+
editor.executeEdits("brand-slash-typed", [{ range: triggerRange, text: "" }]);
|
|
116
|
+
}
|
|
117
|
+
command.runInSource?.({
|
|
118
|
+
editor,
|
|
119
|
+
range: triggerRange ?? null,
|
|
120
|
+
content: monacoContentAccess(editor),
|
|
121
|
+
});
|
|
122
|
+
onOpenChange(false);
|
|
123
|
+
editor.focus();
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// Select a command: `runInSource` (when present) wins over `snippet` — it's
|
|
127
|
+
// the more capable handler and is the ONLY option for a run-only command.
|
|
128
|
+
const selectCommand = (command: SlashCommand) => {
|
|
129
|
+
if (typeof command.runInSource === "function") {
|
|
130
|
+
commitRunInSource(command);
|
|
131
|
+
} else if (command.snippet) {
|
|
132
|
+
commitSnippet(command.snippet);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// Dismiss without inserting. In typed-`/` mode the stray `/` is removed so the
|
|
137
|
+
// document is left exactly as it was before the trigger.
|
|
138
|
+
const cancel = () => {
|
|
139
|
+
if (triggerRange) {
|
|
140
|
+
editor.executeEdits("brand-slash-cancel", [{ range: triggerRange, text: "" }]);
|
|
141
|
+
}
|
|
142
|
+
onOpenChange(false);
|
|
143
|
+
editor.focus();
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// Latest select/cancel via refs so the capture-phase keydown listener can stay
|
|
147
|
+
// attached across renders (its effect deps don't need these closures).
|
|
148
|
+
const selectRef = useRef(selectCommand);
|
|
149
|
+
selectRef.current = selectCommand;
|
|
150
|
+
const cancelRef = useRef(cancel);
|
|
151
|
+
cancelRef.current = cancel;
|
|
152
|
+
|
|
153
|
+
// Reset query + active index whenever the menu opens.
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
if (open) {
|
|
156
|
+
setQuery("");
|
|
157
|
+
setActiveIndex(0);
|
|
158
|
+
}
|
|
159
|
+
}, [open]);
|
|
160
|
+
|
|
161
|
+
// Compute and track caret position.
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
if (!open) return;
|
|
164
|
+
|
|
165
|
+
const update = () => {
|
|
166
|
+
setCoords(getCaretCoords(editor));
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
update();
|
|
170
|
+
|
|
171
|
+
const scrollSub = editor.onDidScrollChange(update);
|
|
172
|
+
const cursorSub = editor.onDidChangeCursorPosition(update);
|
|
173
|
+
|
|
174
|
+
const onResize = () => update();
|
|
175
|
+
window.addEventListener("resize", onResize);
|
|
176
|
+
|
|
177
|
+
return () => {
|
|
178
|
+
scrollSub.dispose();
|
|
179
|
+
cursorSub.dispose();
|
|
180
|
+
window.removeEventListener("resize", onResize);
|
|
181
|
+
};
|
|
182
|
+
}, [open, editor]);
|
|
183
|
+
|
|
184
|
+
// Close on editor blur.
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
if (!open) return;
|
|
187
|
+
const blurSub = editor.onDidBlurEditorText(() => {
|
|
188
|
+
// Small delay — if focus moved to the menu itself (mousedown), don't close.
|
|
189
|
+
setTimeout(() => {
|
|
190
|
+
if (!menuRef.current?.contains(document.activeElement)) {
|
|
191
|
+
onOpenChange(false);
|
|
192
|
+
}
|
|
193
|
+
}, 100);
|
|
194
|
+
});
|
|
195
|
+
return () => blurSub.dispose();
|
|
196
|
+
}, [open, editor, onOpenChange]);
|
|
197
|
+
|
|
198
|
+
// Wire keyboard navigation into the editor while the menu is open.
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
if (!open) return;
|
|
201
|
+
|
|
202
|
+
const keydown = (e: KeyboardEvent) => {
|
|
203
|
+
const filtered = filterSlashCommands(commands, query);
|
|
204
|
+
|
|
205
|
+
if (e.key === "Escape") {
|
|
206
|
+
e.preventDefault();
|
|
207
|
+
e.stopPropagation();
|
|
208
|
+
cancelRef.current();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (e.key === "ArrowDown") {
|
|
212
|
+
e.preventDefault();
|
|
213
|
+
e.stopPropagation();
|
|
214
|
+
setActiveIndex((i) => (i + 1) % Math.max(filtered.length, 1));
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (e.key === "ArrowUp") {
|
|
218
|
+
e.preventDefault();
|
|
219
|
+
e.stopPropagation();
|
|
220
|
+
setActiveIndex(
|
|
221
|
+
(i) => (i - 1 + Math.max(filtered.length, 1)) % Math.max(filtered.length, 1),
|
|
222
|
+
);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
// Enter AND Tab select the active command (the cmdk / Notion command-menu
|
|
226
|
+
// convention) — kept identical to the WYSIWYG slash menu so the cross-pane
|
|
227
|
+
// shortcut behaves the same in both panes (the goal of #271). Tab does NOT
|
|
228
|
+
// move browser focus here by design; Esc dismisses without inserting.
|
|
229
|
+
if (e.key === "Enter" || e.key === "Tab") {
|
|
230
|
+
// Always swallow while the popup is open — even with no match — so the key
|
|
231
|
+
// never leaks a newline/tab into the document behind the popup.
|
|
232
|
+
e.preventDefault();
|
|
233
|
+
e.stopPropagation();
|
|
234
|
+
const command = filtered[Math.min(activeIndex, filtered.length - 1)];
|
|
235
|
+
if (command) selectRef.current(command);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
// Printable characters update the query — intercept so they filter the
|
|
239
|
+
// menu instead of being typed into the Monaco document (the menu was
|
|
240
|
+
// opened by shortcut, so there is no `/query` run in the doc to absorb them).
|
|
241
|
+
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
242
|
+
e.preventDefault();
|
|
243
|
+
e.stopPropagation();
|
|
244
|
+
setQuery((q) => q + e.key);
|
|
245
|
+
setActiveIndex(0);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
// Backspace trims the query (and closes when empty) — intercept so it
|
|
249
|
+
// never deletes document text behind the popup.
|
|
250
|
+
if (e.key === "Backspace") {
|
|
251
|
+
e.preventDefault();
|
|
252
|
+
e.stopPropagation();
|
|
253
|
+
if (query.length === 0) {
|
|
254
|
+
// Backspacing past the trigger dismisses (and removes the typed `/`).
|
|
255
|
+
cancelRef.current();
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
setQuery((q) => q.slice(0, -1));
|
|
259
|
+
setActiveIndex(0);
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
// Capture phase — intercept before Monaco's own key handlers.
|
|
264
|
+
const domNode = editor.getDomNode();
|
|
265
|
+
domNode?.addEventListener("keydown", keydown, true);
|
|
266
|
+
return () => domNode?.removeEventListener("keydown", keydown, true);
|
|
267
|
+
}, [open, editor, commands, query, activeIndex]);
|
|
268
|
+
|
|
269
|
+
// Keep the highlighted option scrolled into view as ↑/↓ moves it — the listbox
|
|
270
|
+
// is overflow-y-auto, so without this the active row can move off-screen while
|
|
271
|
+
// the keyboard selection advances (the same fix slash-widget.tsx applies).
|
|
272
|
+
useEffect(() => {
|
|
273
|
+
if (!open) return;
|
|
274
|
+
const filtered = filterSlashCommands(commands, query);
|
|
275
|
+
const active = filtered[Math.min(activeIndex, Math.max(filtered.length - 1, 0))];
|
|
276
|
+
if (!active) return;
|
|
277
|
+
const el = menuRef.current?.querySelector<HTMLElement>(
|
|
278
|
+
`#${CSS.escape(slashOptionId(ID_PREFIX, active.id))}`,
|
|
279
|
+
);
|
|
280
|
+
el?.scrollIntoView({ block: "nearest" });
|
|
281
|
+
}, [open, commands, query, activeIndex]);
|
|
282
|
+
|
|
283
|
+
// Mirror the listbox relationship onto the Monaco textbox while open, so AT is
|
|
284
|
+
// told a popup appeared (aria-expanded), where it is (aria-controls), and which
|
|
285
|
+
// option is active (aria-activedescendant) — the same wiring the WYSIWYG path
|
|
286
|
+
// applies in slash-widget.tsx. All three are cleaned up on close.
|
|
287
|
+
useEffect(() => {
|
|
288
|
+
if (!open) return;
|
|
289
|
+
const filtered = filterSlashCommands(commands, query);
|
|
290
|
+
const activeCommand = filtered[Math.min(activeIndex, Math.max(filtered.length - 1, 0))];
|
|
291
|
+
const textarea = editor.getDomNode()?.querySelector("textarea");
|
|
292
|
+
if (!textarea) return;
|
|
293
|
+
textarea.setAttribute("aria-expanded", "true");
|
|
294
|
+
textarea.setAttribute("aria-controls", LISTBOX_ID);
|
|
295
|
+
if (activeCommand) {
|
|
296
|
+
textarea.setAttribute("aria-activedescendant", slashOptionId(ID_PREFIX, activeCommand.id));
|
|
297
|
+
} else {
|
|
298
|
+
textarea.removeAttribute("aria-activedescendant");
|
|
299
|
+
}
|
|
300
|
+
return () => {
|
|
301
|
+
textarea.removeAttribute("aria-expanded");
|
|
302
|
+
textarea.removeAttribute("aria-controls");
|
|
303
|
+
textarea.removeAttribute("aria-activedescendant");
|
|
304
|
+
};
|
|
305
|
+
}, [open, editor, commands, query, activeIndex]);
|
|
306
|
+
|
|
307
|
+
if (!open || !coords) return null;
|
|
308
|
+
|
|
309
|
+
const filtered = filterSlashCommands(commands, query);
|
|
310
|
+
const activeCommand = filtered[Math.min(activeIndex, Math.max(filtered.length - 1, 0))];
|
|
311
|
+
|
|
312
|
+
const handleSelect = (command: SlashCommand) => {
|
|
313
|
+
selectCommand(command);
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const resultCount = filtered.length;
|
|
317
|
+
const statusText =
|
|
318
|
+
resultCount === 0
|
|
319
|
+
? "No matching blocks"
|
|
320
|
+
: query
|
|
321
|
+
? `${resultCount} result${resultCount === 1 ? "" : "s"} for “${query}”`
|
|
322
|
+
: `${resultCount} block${resultCount === 1 ? "" : "s"}`;
|
|
323
|
+
|
|
324
|
+
return (
|
|
325
|
+
<div
|
|
326
|
+
ref={menuRef}
|
|
327
|
+
className={cn(className)}
|
|
328
|
+
// Fixed positioning so it overlays the editor regardless of scroll.
|
|
329
|
+
style={{ position: "fixed", top: coords.top, left: coords.left, zIndex: 50 }}
|
|
330
|
+
// Prevent the mousedown from stealing focus from the editor.
|
|
331
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
332
|
+
>
|
|
333
|
+
{/* One polite live region — focus stays in the editor, so AT learns the
|
|
334
|
+
filter result count (and the empty state) only from here. The visual
|
|
335
|
+
chip below is aria-hidden to avoid a double announcement. */}
|
|
336
|
+
<span role="status" aria-live="polite" className="sr-only">
|
|
337
|
+
{statusText}
|
|
338
|
+
</span>
|
|
339
|
+
{query && (
|
|
340
|
+
<div
|
|
341
|
+
aria-hidden="true"
|
|
342
|
+
className="mb-0.5 rounded-sm border border-border bg-popover px-2 py-1 text-caption text-muted-foreground"
|
|
343
|
+
>
|
|
344
|
+
Filter: <span className="font-medium text-foreground">{query}</span>
|
|
345
|
+
</div>
|
|
346
|
+
)}
|
|
347
|
+
<SlashMenu
|
|
348
|
+
id={LISTBOX_ID}
|
|
349
|
+
commands={filtered}
|
|
350
|
+
activeId={activeCommand?.id}
|
|
351
|
+
onSelect={handleSelect}
|
|
352
|
+
idPrefix={ID_PREFIX}
|
|
353
|
+
/>
|
|
354
|
+
</div>
|
|
355
|
+
);
|
|
356
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shortcut helpers for the cross-pane slash menu (#271) — the MONACO half.
|
|
3
|
+
*
|
|
4
|
+
* Converts a shortcut string (e.g. `"Mod-/"`) into a Monaco keybinding bitmask.
|
|
5
|
+
* This is the ONLY slash module that imports `monaco-editor`; it is imported only
|
|
6
|
+
* by the Monaco-side surfaces (the source pane action in `MarkdownWorkspace`), so
|
|
7
|
+
* the pure `./shortcut.ts` — imported by the Milkdown plugin — stays Monaco-free.
|
|
8
|
+
*
|
|
9
|
+
* INTERNAL — not exported from package barrels.
|
|
10
|
+
*/
|
|
11
|
+
import * as monaco from "monaco-editor";
|
|
12
|
+
|
|
13
|
+
/** Supported letter key names (A-Z, case-insensitive in shortcut strings). */
|
|
14
|
+
const LETTER_KEY_MAP: Record<string, number> = (() => {
|
|
15
|
+
const map: Record<string, number> = {};
|
|
16
|
+
const kc = monaco.KeyCode as unknown as Record<string, number>;
|
|
17
|
+
for (let i = 0; i < 26; i++) {
|
|
18
|
+
const letter = String.fromCharCode(65 + i); // "A".."Z"
|
|
19
|
+
const code = kc[`Key${letter}`];
|
|
20
|
+
if (code !== undefined) map[letter.toLowerCase()] = code;
|
|
21
|
+
}
|
|
22
|
+
return map;
|
|
23
|
+
})();
|
|
24
|
+
|
|
25
|
+
const NAMED_KEY_MAP: Record<string, number> = {
|
|
26
|
+
"/": monaco.KeyCode.Slash,
|
|
27
|
+
backspace: monaco.KeyCode.Backspace,
|
|
28
|
+
delete: monaco.KeyCode.Delete,
|
|
29
|
+
escape: monaco.KeyCode.Escape,
|
|
30
|
+
enter: monaco.KeyCode.Enter,
|
|
31
|
+
tab: monaco.KeyCode.Tab,
|
|
32
|
+
arrowup: monaco.KeyCode.UpArrow,
|
|
33
|
+
arrowdown: monaco.KeyCode.DownArrow,
|
|
34
|
+
arrowleft: monaco.KeyCode.LeftArrow,
|
|
35
|
+
arrowright: monaco.KeyCode.RightArrow,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parse a shortcut string (e.g. `"Mod-/"`, `"Mod-Shift-K"`) into a Monaco
|
|
40
|
+
* keybinding bitmask. Supports `Mod` (CtrlCmd), `Shift`, `Alt` modifiers and
|
|
41
|
+
* a final key that is either a letter (A-Z) or one of the named keys in
|
|
42
|
+
* `NAMED_KEY_MAP`. Throws for unrecognized final keys.
|
|
43
|
+
*
|
|
44
|
+
* The shipped default is `"Mod-/"` → `KeyMod.CtrlCmd | KeyCode.Slash`.
|
|
45
|
+
*/
|
|
46
|
+
export function parseShortcut(shortcut: string): number {
|
|
47
|
+
const parts = shortcut.split("-");
|
|
48
|
+
let binding = 0;
|
|
49
|
+
const keyPart = parts[parts.length - 1] ?? "";
|
|
50
|
+
const modifiers = parts.slice(0, -1).map((m) => m.toLowerCase());
|
|
51
|
+
|
|
52
|
+
for (const mod of modifiers) {
|
|
53
|
+
if (mod === "mod") binding |= monaco.KeyMod.CtrlCmd;
|
|
54
|
+
else if (mod === "shift") binding |= monaco.KeyMod.Shift;
|
|
55
|
+
else if (mod === "alt") binding |= monaco.KeyMod.Alt;
|
|
56
|
+
// Ctrl/Meta can also be named explicitly
|
|
57
|
+
else if (mod === "ctrl") binding |= monaco.KeyMod.WinCtrl;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const key = keyPart.toLowerCase();
|
|
61
|
+
const keyCode =
|
|
62
|
+
NAMED_KEY_MAP[key] ??
|
|
63
|
+
LETTER_KEY_MAP[key] ??
|
|
64
|
+
(() => {
|
|
65
|
+
throw new Error(`[@elabs-ai/components-editor] parseShortcut: unrecognized key "${keyPart}"`);
|
|
66
|
+
})();
|
|
67
|
+
|
|
68
|
+
return binding | keyCode;
|
|
69
|
+
}
|