@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,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the slash-menu shortcut helpers.
|
|
3
|
+
* Pure functions — no React, no DOM engine needed beyond vitest's node env.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, it, vi } from "vitest";
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Mock monaco-editor — the shortcut module reads KeyMod/KeyCode constants at
|
|
9
|
+
// import time so we must hoist this before the import.
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
vi.mock("monaco-editor", () => {
|
|
12
|
+
// Use real-ish numeric values that don't collide so we can assert bitwise ops.
|
|
13
|
+
const KeyCode = {
|
|
14
|
+
Slash: 85,
|
|
15
|
+
Backspace: 1,
|
|
16
|
+
Delete: 2,
|
|
17
|
+
Escape: 9,
|
|
18
|
+
Enter: 3,
|
|
19
|
+
Tab: 2048,
|
|
20
|
+
UpArrow: 16,
|
|
21
|
+
DownArrow: 17,
|
|
22
|
+
LeftArrow: 15,
|
|
23
|
+
RightArrow: 18,
|
|
24
|
+
// Letters KeyA..KeyZ
|
|
25
|
+
KeyA: 31,
|
|
26
|
+
KeyB: 32,
|
|
27
|
+
KeyC: 33,
|
|
28
|
+
KeyD: 34,
|
|
29
|
+
KeyE: 35,
|
|
30
|
+
KeyF: 36,
|
|
31
|
+
KeyG: 37,
|
|
32
|
+
KeyH: 38,
|
|
33
|
+
KeyI: 39,
|
|
34
|
+
KeyJ: 40,
|
|
35
|
+
KeyK: 41,
|
|
36
|
+
KeyL: 42,
|
|
37
|
+
KeyM: 43,
|
|
38
|
+
KeyN: 44,
|
|
39
|
+
KeyO: 45,
|
|
40
|
+
KeyP: 46,
|
|
41
|
+
KeyQ: 47,
|
|
42
|
+
KeyR: 48,
|
|
43
|
+
KeyS: 49,
|
|
44
|
+
KeyT: 50,
|
|
45
|
+
KeyU: 51,
|
|
46
|
+
KeyV: 52,
|
|
47
|
+
KeyW: 53,
|
|
48
|
+
KeyX: 54,
|
|
49
|
+
KeyY: 55,
|
|
50
|
+
KeyZ: 56,
|
|
51
|
+
} as const;
|
|
52
|
+
|
|
53
|
+
const KeyMod = {
|
|
54
|
+
CtrlCmd: 2048,
|
|
55
|
+
Shift: 1024,
|
|
56
|
+
Alt: 512,
|
|
57
|
+
WinCtrl: 256,
|
|
58
|
+
} as const;
|
|
59
|
+
|
|
60
|
+
return { KeyCode, KeyMod };
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
import { DEFAULT_SLASH_SHORTCUT, matchesKeyboardEvent } from "./shortcut";
|
|
64
|
+
// parseShortcut moved to the Monaco half (keeps ./shortcut Monaco-free); the
|
|
65
|
+
// monaco mock above backs it.
|
|
66
|
+
import { parseShortcut } from "./shortcut-monaco";
|
|
67
|
+
|
|
68
|
+
// Pull the mocked constants for assertions.
|
|
69
|
+
const { KeyCode, KeyMod } = await import("monaco-editor");
|
|
70
|
+
|
|
71
|
+
describe("DEFAULT_SLASH_SHORTCUT", () => {
|
|
72
|
+
it("is Mod-Shift-O (NOT Mod-/, which collides with Monaco's Toggle Line Comment)", () => {
|
|
73
|
+
expect(DEFAULT_SLASH_SHORTCUT).toBe("Mod-Shift-O");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("parseShortcut", () => {
|
|
78
|
+
it('parses "Mod-/" → CtrlCmd | Slash', () => {
|
|
79
|
+
expect(parseShortcut("Mod-/")).toBe(KeyMod.CtrlCmd | KeyCode.Slash);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('parses "Mod-Shift-K" → CtrlCmd | Shift | KeyK', () => {
|
|
83
|
+
expect(parseShortcut("Mod-Shift-K")).toBe(KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyK);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("parses letter keys case-insensitively", () => {
|
|
87
|
+
expect(parseShortcut("Mod-k")).toBe(parseShortcut("Mod-K"));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('parses "Alt-/" → Alt | Slash', () => {
|
|
91
|
+
expect(parseShortcut("Alt-/")).toBe(KeyMod.Alt | KeyCode.Slash);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("throws for an unrecognized final key", () => {
|
|
95
|
+
expect(() => parseShortcut("Mod-~")).toThrow();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("matchesKeyboardEvent", () => {
|
|
100
|
+
const makeEvent = (overrides: Partial<KeyboardEvent>): KeyboardEvent =>
|
|
101
|
+
({
|
|
102
|
+
metaKey: false,
|
|
103
|
+
ctrlKey: false,
|
|
104
|
+
shiftKey: false,
|
|
105
|
+
altKey: false,
|
|
106
|
+
key: "",
|
|
107
|
+
...overrides,
|
|
108
|
+
}) as KeyboardEvent;
|
|
109
|
+
|
|
110
|
+
describe('shortcut "Mod-/"', () => {
|
|
111
|
+
it("matches when metaKey=true and key='/'", () => {
|
|
112
|
+
expect(matchesKeyboardEvent("Mod-/", makeEvent({ metaKey: true, key: "/" }))).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("matches when ctrlKey=true and key='/'", () => {
|
|
116
|
+
expect(matchesKeyboardEvent("Mod-/", makeEvent({ ctrlKey: true, key: "/" }))).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("does NOT match plain '/' (no modifier)", () => {
|
|
120
|
+
expect(matchesKeyboardEvent("Mod-/", makeEvent({ key: "/" }))).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("does NOT match wrong modifier (altKey only)", () => {
|
|
124
|
+
expect(matchesKeyboardEvent("Mod-/", makeEvent({ altKey: true, key: "/" }))).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("does NOT match wrong key with correct modifier", () => {
|
|
128
|
+
expect(matchesKeyboardEvent("Mod-/", makeEvent({ metaKey: true, key: "k" }))).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("does NOT match when an unexpected extra modifier is pressed (shift)", () => {
|
|
132
|
+
expect(
|
|
133
|
+
matchesKeyboardEvent("Mod-/", makeEvent({ metaKey: true, shiftKey: true, key: "/" })),
|
|
134
|
+
).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('shortcut "Mod-Shift-K"', () => {
|
|
139
|
+
it("matches Cmd+Shift+k (metaKey + shiftKey)", () => {
|
|
140
|
+
expect(
|
|
141
|
+
matchesKeyboardEvent("Mod-Shift-K", makeEvent({ metaKey: true, shiftKey: true, key: "k" })),
|
|
142
|
+
).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("matches case-insensitively for the key", () => {
|
|
146
|
+
expect(
|
|
147
|
+
matchesKeyboardEvent("Mod-Shift-K", makeEvent({ metaKey: true, shiftKey: true, key: "K" })),
|
|
148
|
+
).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("does NOT match without shift", () => {
|
|
152
|
+
expect(matchesKeyboardEvent("Mod-Shift-K", makeEvent({ metaKey: true, key: "k" }))).toBe(
|
|
153
|
+
false,
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shortcut helpers for the cross-pane slash menu (#271) — the PURE half.
|
|
3
|
+
*
|
|
4
|
+
* No React, no Milkdown, **no Monaco**. This module is imported by
|
|
5
|
+
* `brand-slash-plugin.ts` (and therefore by the Milkdown `MarkdownEditor` import
|
|
6
|
+
* graph), so it must NOT pull `monaco-editor` — doing so drags the Monaco runtime
|
|
7
|
+
* (and its jsdom-hostile clipboard module) into every WYSIWYG test. The
|
|
8
|
+
* Monaco-keybinding conversion lives in the sibling `./shortcut-monaco.ts`, which
|
|
9
|
+
* only the Monaco-side surfaces (the workspace) import.
|
|
10
|
+
*
|
|
11
|
+
* INTERNAL — not exported from package barrels.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The default keyboard shortcut for the slash command menu.
|
|
16
|
+
* `Mod` = Cmd on macOS, Ctrl elsewhere (Monaco's `CtrlCmd`).
|
|
17
|
+
*
|
|
18
|
+
* NOTE: this is NOT `Mod-/` — Monaco binds `Mod-/` to "Toggle Line Comment" by
|
|
19
|
+
* default, which eats the keystroke before the slash action sees it (the source
|
|
20
|
+
* pane's primary trigger is typing `/`; this hotkey is the secondary path). `Mod-Shift-O`
|
|
21
|
+
* is free in Chrome/macOS; it DOES collide with the bookmarks shortcut in Firefox
|
|
22
|
+
* and on Windows/Linux Chrome, so consumers shipping cross-browser should override
|
|
23
|
+
* `slashMenu.shortcut`. The conflict-free trigger is typing `/`.
|
|
24
|
+
*/
|
|
25
|
+
export const DEFAULT_SLASH_SHORTCUT = "Mod-Shift-O";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Returns `true` when a DOM `KeyboardEvent` matches a shortcut string.
|
|
29
|
+
*
|
|
30
|
+
* - `Mod` matches `event.metaKey` (macOS) OR `event.ctrlKey` (other platforms).
|
|
31
|
+
* - `Shift` / `Alt` match the corresponding event fields.
|
|
32
|
+
* - The final key matches `event.key` (case-insensitive); `"/"` matches
|
|
33
|
+
* `event.key === "/"`.
|
|
34
|
+
*
|
|
35
|
+
* Used by the WYSIWYG plugin's `handleKeyDown` (which receives DOM events) to
|
|
36
|
+
* decide whether to open the menu — pure, so it never pulls the monaco namespace.
|
|
37
|
+
*/
|
|
38
|
+
export function matchesKeyboardEvent(shortcut: string, event: KeyboardEvent): boolean {
|
|
39
|
+
const parts = shortcut.split("-");
|
|
40
|
+
const keyPart = parts[parts.length - 1] ?? "";
|
|
41
|
+
const modifiers = new Set(parts.slice(0, -1).map((m) => m.toLowerCase()));
|
|
42
|
+
|
|
43
|
+
// Check modifier flags.
|
|
44
|
+
const wantsMod = modifiers.has("mod");
|
|
45
|
+
const wantsShift = modifiers.has("shift");
|
|
46
|
+
const wantsAlt = modifiers.has("alt");
|
|
47
|
+
|
|
48
|
+
if (wantsMod && !(event.metaKey || event.ctrlKey)) return false;
|
|
49
|
+
if (!wantsMod && (event.metaKey || event.ctrlKey)) return false;
|
|
50
|
+
if (wantsShift && !event.shiftKey) return false;
|
|
51
|
+
if (!wantsShift && event.shiftKey) return false;
|
|
52
|
+
if (wantsAlt && !event.altKey) return false;
|
|
53
|
+
if (!wantsAlt && event.altKey) return false;
|
|
54
|
+
|
|
55
|
+
// Check the key itself (case-insensitive).
|
|
56
|
+
return event.key.toLowerCase() === keyPart.toLowerCase();
|
|
57
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { expect, within } from "storybook/test";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
|
|
5
|
+
import { MarkdownEditor } from "../markdown-editor";
|
|
6
|
+
import { BRAND_SLASH_COMMANDS, filterSlashCommands } from "./brand-slash-commands";
|
|
7
|
+
import { SlashMenu } from "./slash-menu";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The `/` command menu for the WYSIWYG markdown editor. Typing `/` at a block
|
|
11
|
+
* start (or after whitespace) opens a branded popup that inserts the brand `:::`
|
|
12
|
+
* directives (card / callout / metric / timeline) + basic blocks live at the
|
|
13
|
+
* caret. Opt in via the `slashMenu` prop on `MarkdownEditor` /
|
|
14
|
+
* `MarkdownWorkspace` (default `true`).
|
|
15
|
+
*
|
|
16
|
+
* The popup itself is rendered here standalone (the `SlashMenu` stories) so its
|
|
17
|
+
* branding + a11y can be verified directly across all three themes; the in-editor
|
|
18
|
+
* stories exercise the integration (mount + the `slashMenu={false}` no-op). The
|
|
19
|
+
* live keyboard/insert UX is best verified in a real browser — ProseMirror
|
|
20
|
+
* keystroke dispatch can't be fully simulated in jsdom.
|
|
21
|
+
*/
|
|
22
|
+
const meta = {
|
|
23
|
+
title: "Editor/MarkdownEditor/Slash menu",
|
|
24
|
+
component: SlashMenu,
|
|
25
|
+
tags: ["autodocs"],
|
|
26
|
+
parameters: {
|
|
27
|
+
docs: {
|
|
28
|
+
description: {
|
|
29
|
+
component:
|
|
30
|
+
"Branded `/` command menu for the WYSIWYG editor. Inserts brand `:::` " +
|
|
31
|
+
"directives + basic blocks at the caret. Token-driven `listbox`/`option` " +
|
|
32
|
+
"list; the editor `textbox` owns `aria-activedescendant`.",
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
} satisfies Meta<typeof SlashMenu>;
|
|
37
|
+
|
|
38
|
+
export default meta;
|
|
39
|
+
type Story = StoryObj<typeof meta>;
|
|
40
|
+
|
|
41
|
+
/** The popup standalone, with the first command highlighted (default state). */
|
|
42
|
+
export const Default: Story = {
|
|
43
|
+
args: {
|
|
44
|
+
commands: BRAND_SLASH_COMMANDS,
|
|
45
|
+
activeId: BRAND_SLASH_COMMANDS[0]?.id,
|
|
46
|
+
onSelect: () => {},
|
|
47
|
+
},
|
|
48
|
+
render: (args) => (
|
|
49
|
+
<div className="p-6">
|
|
50
|
+
<SlashMenu {...args} />
|
|
51
|
+
</div>
|
|
52
|
+
),
|
|
53
|
+
play: async ({ canvasElement }) => {
|
|
54
|
+
const canvas = within(canvasElement);
|
|
55
|
+
// The popup is a labelled listbox of options grouped by section.
|
|
56
|
+
await expect(canvas.getByRole("listbox", { name: "Insert block" })).toBeVisible();
|
|
57
|
+
await expect(canvas.getByRole("option", { name: /Card/ })).toBeVisible();
|
|
58
|
+
// The first option is selected by default.
|
|
59
|
+
await expect(canvas.getByRole("option", { name: /Card/ })).toHaveAttribute(
|
|
60
|
+
"aria-selected",
|
|
61
|
+
"true",
|
|
62
|
+
);
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Filtered to a query (`/ca`) — Card + Callout match by label, the Brand group leads. */
|
|
67
|
+
export const Filtered: Story = {
|
|
68
|
+
args: {
|
|
69
|
+
commands: filterSlashCommands(BRAND_SLASH_COMMANDS, "ca"),
|
|
70
|
+
onSelect: () => {},
|
|
71
|
+
},
|
|
72
|
+
render: function FilteredStory(args) {
|
|
73
|
+
const [activeId, setActiveId] = useState(args.commands[0]?.id);
|
|
74
|
+
return (
|
|
75
|
+
<div className="p-6">
|
|
76
|
+
<SlashMenu {...args} activeId={activeId} onSelect={(c) => setActiveId(c.id)} />
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** The empty state when nothing matches the query. */
|
|
83
|
+
export const Empty: Story = {
|
|
84
|
+
args: {
|
|
85
|
+
commands: [],
|
|
86
|
+
onSelect: () => {},
|
|
87
|
+
},
|
|
88
|
+
render: (args) => (
|
|
89
|
+
<div className="p-6">
|
|
90
|
+
<SlashMenu {...args} />
|
|
91
|
+
</div>
|
|
92
|
+
),
|
|
93
|
+
play: async ({ canvasElement }) => {
|
|
94
|
+
const canvas = within(canvasElement);
|
|
95
|
+
await expect(canvas.getByText("No matching blocks")).toBeVisible();
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const SAMPLE = `# Release notes
|
|
100
|
+
|
|
101
|
+
Type "/" on a new line to insert a brand block (card, callout, metric, timeline)
|
|
102
|
+
or a basic block.
|
|
103
|
+
`;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The slash menu wired into the live editor. Place the caret on an empty line and
|
|
107
|
+
* type `/` to open the menu. (The play test only asserts the editor mounts — drive
|
|
108
|
+
* the keyboard/insert flow in a real browser.)
|
|
109
|
+
*/
|
|
110
|
+
export const InEditor: StoryObj<typeof MarkdownEditor> = {
|
|
111
|
+
name: "In editor (slashMenu enabled)",
|
|
112
|
+
render: () => (
|
|
113
|
+
<div className="mx-auto max-w-3xl p-6">
|
|
114
|
+
<MarkdownEditor defaultValue={SAMPLE} slashMenu />
|
|
115
|
+
</div>
|
|
116
|
+
),
|
|
117
|
+
play: async ({ canvasElement }) => {
|
|
118
|
+
const canvas = within(canvasElement);
|
|
119
|
+
await expect(
|
|
120
|
+
await canvas.findByRole("textbox", { name: "Markdown editor" }, { timeout: 8000 }),
|
|
121
|
+
).toBeVisible();
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** `slashMenu={false}` disables the menu (the editor renders normally). */
|
|
126
|
+
export const Disabled: StoryObj<typeof MarkdownEditor> = {
|
|
127
|
+
name: "In editor (slashMenu disabled)",
|
|
128
|
+
render: () => (
|
|
129
|
+
<div className="mx-auto max-w-3xl p-6">
|
|
130
|
+
<MarkdownEditor defaultValue={SAMPLE} slashMenu={false} />
|
|
131
|
+
</div>
|
|
132
|
+
),
|
|
133
|
+
play: async ({ canvasElement }) => {
|
|
134
|
+
const canvas = within(canvasElement);
|
|
135
|
+
await expect(
|
|
136
|
+
await canvas.findByRole("textbox", { name: "Markdown editor" }, { timeout: 8000 }),
|
|
137
|
+
).toBeVisible();
|
|
138
|
+
},
|
|
139
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SlashMenu — the branded popup the slash plugin renders at the caret.
|
|
5
|
+
*
|
|
6
|
+
* Presentational + controlled: the ProseMirror plugin owns the keyboard
|
|
7
|
+
* (↑/↓/Enter/Esc) and the open/query/active state, so this component just renders
|
|
8
|
+
* the filtered, grouped commands and exposes `onSelect`. It is a token-styled
|
|
9
|
+
* `listbox`/`option` list (NOT `@elabs-ai/components-ui` `Command`/cmdk): a cmdk instance would
|
|
10
|
+
* fight the editor for DOM focus and arrow-key handling, since the caret must stay
|
|
11
|
+
* in the ProseMirror `textbox` while the user types `/query`. The plugin keeps the
|
|
12
|
+
* active option in view and wires `aria-activedescendant` on the editor surface.
|
|
13
|
+
*
|
|
14
|
+
* Semantic tokens only; matches the `Command` popover look (bg-popover, accent
|
|
15
|
+
* selection, muted group headings).
|
|
16
|
+
*/
|
|
17
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
18
|
+
import { forwardRef, type HTMLAttributes } from "react";
|
|
19
|
+
|
|
20
|
+
import { groupSlashCommands, type SlashCommand } from "./brand-slash-commands";
|
|
21
|
+
|
|
22
|
+
export interface SlashMenuProps extends Omit<HTMLAttributes<HTMLDivElement>, "onSelect"> {
|
|
23
|
+
/** The (already filtered) commands to show. */
|
|
24
|
+
commands: SlashCommand[];
|
|
25
|
+
/** The id of the active (highlighted) command, for `aria-selected`. */
|
|
26
|
+
activeId?: string;
|
|
27
|
+
/** Called when a command is chosen (click or via the plugin's Enter). */
|
|
28
|
+
onSelect: (command: SlashCommand) => void;
|
|
29
|
+
/** DOM id prefix so each option id is stable + unique (matches `aria-activedescendant`). */
|
|
30
|
+
idPrefix?: string;
|
|
31
|
+
/** Shown when the query matches nothing. */
|
|
32
|
+
emptyLabel?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Build the stable DOM id for a command's option element. */
|
|
36
|
+
export function slashOptionId(idPrefix: string, commandId: string): string {
|
|
37
|
+
return `${idPrefix}-${commandId}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const SlashMenu = forwardRef<HTMLDivElement, SlashMenuProps>(function SlashMenu(
|
|
41
|
+
{
|
|
42
|
+
commands,
|
|
43
|
+
activeId,
|
|
44
|
+
onSelect,
|
|
45
|
+
idPrefix = "brand-slash",
|
|
46
|
+
emptyLabel = "No matching blocks",
|
|
47
|
+
className,
|
|
48
|
+
...props
|
|
49
|
+
},
|
|
50
|
+
ref,
|
|
51
|
+
) {
|
|
52
|
+
const groups = groupSlashCommands(commands);
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div
|
|
56
|
+
ref={ref}
|
|
57
|
+
// The editor surface carries `role="textbox"`; this list is its popup. The
|
|
58
|
+
// plugin sets `aria-controls`/`aria-activedescendant` on the textbox.
|
|
59
|
+
role="listbox"
|
|
60
|
+
aria-label="Insert block"
|
|
61
|
+
className={cn(
|
|
62
|
+
"max-h-[min(320px,60vh)] w-72 overflow-y-auto overflow-x-hidden rounded-md bg-popover p-1 text-popover-foreground shadow-ring-md",
|
|
63
|
+
className,
|
|
64
|
+
)}
|
|
65
|
+
{...props}
|
|
66
|
+
>
|
|
67
|
+
{commands.length === 0 ? (
|
|
68
|
+
<div className="px-2 py-6 text-center text-caption text-muted-foreground">{emptyLabel}</div>
|
|
69
|
+
) : (
|
|
70
|
+
groups.map(({ group, commands: groupCommands }) => (
|
|
71
|
+
<div key={group} role="group" aria-label={group} className="overflow-hidden p-1">
|
|
72
|
+
<div className="px-2 py-1.5 text-meta font-medium text-muted-foreground">{group}</div>
|
|
73
|
+
{groupCommands.map((command) => {
|
|
74
|
+
const selected = command.id === activeId;
|
|
75
|
+
return (
|
|
76
|
+
<div
|
|
77
|
+
key={command.id}
|
|
78
|
+
id={slashOptionId(idPrefix, command.id)}
|
|
79
|
+
role="option"
|
|
80
|
+
aria-selected={selected}
|
|
81
|
+
data-selected={selected ? "true" : undefined}
|
|
82
|
+
// The editor keeps focus, so select on mousedown (before the
|
|
83
|
+
// editor would steal focus back) and don't let the click move
|
|
84
|
+
// the selection out of the document.
|
|
85
|
+
onMouseDown={(e) => {
|
|
86
|
+
e.preventDefault();
|
|
87
|
+
onSelect(command);
|
|
88
|
+
}}
|
|
89
|
+
className={cn(
|
|
90
|
+
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-body outline-none transition-colors duration-fast",
|
|
91
|
+
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground",
|
|
92
|
+
)}
|
|
93
|
+
>
|
|
94
|
+
{command.icon ? (
|
|
95
|
+
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-4">
|
|
96
|
+
{command.icon}
|
|
97
|
+
</span>
|
|
98
|
+
) : null}
|
|
99
|
+
<span className="flex min-w-0 flex-col">
|
|
100
|
+
<span className="truncate">{command.label}</span>
|
|
101
|
+
{command.description ? (
|
|
102
|
+
<span className="truncate text-meta text-muted-foreground">
|
|
103
|
+
{command.description}
|
|
104
|
+
</span>
|
|
105
|
+
) : null}
|
|
106
|
+
</span>
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
})}
|
|
110
|
+
</div>
|
|
111
|
+
))
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locking tests for slash-menu scroll-to-active (A2). The scroll + aria wiring
|
|
3
|
+
* already ships (slash-widget.tsx), so these guard it from silent removal: the
|
|
4
|
+
* popup must be a capped, scrollable listbox whose options carry stable ids (the
|
|
5
|
+
* scroll / `aria-activedescendant` target), and the widget must scroll the active
|
|
6
|
+
* option into view as ↑/↓ moves it. The live keyboard UX is covered in the
|
|
7
|
+
* browser by slash-menu.stories.tsx.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect } from "vitest";
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { resolve } from "node:path";
|
|
12
|
+
import { render, screen } from "@testing-library/react";
|
|
13
|
+
|
|
14
|
+
import { SlashMenu, slashOptionId } from "./slash-menu";
|
|
15
|
+
import { BRAND_SLASH_COMMANDS } from "./brand-slash-commands";
|
|
16
|
+
|
|
17
|
+
describe("slash menu scroll-to-active (A2)", () => {
|
|
18
|
+
it("renders a capped, scrollable listbox so the active row can scroll into view", () => {
|
|
19
|
+
render(<SlashMenu commands={BRAND_SLASH_COMMANDS} activeId="brand-card" onSelect={() => {}} />);
|
|
20
|
+
const list = screen.getByRole("listbox");
|
|
21
|
+
expect(list.className).toMatch(/overflow-y-auto/);
|
|
22
|
+
expect(list.className).toMatch(/max-h-/);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("gives each option a stable id (the scroll + aria-activedescendant target)", () => {
|
|
26
|
+
render(
|
|
27
|
+
<SlashMenu
|
|
28
|
+
commands={BRAND_SLASH_COMMANDS}
|
|
29
|
+
activeId="brand-card"
|
|
30
|
+
onSelect={() => {}}
|
|
31
|
+
idPrefix="x"
|
|
32
|
+
/>,
|
|
33
|
+
);
|
|
34
|
+
const opt = document.getElementById(slashOptionId("x", "brand-card"));
|
|
35
|
+
expect(opt).not.toBeNull();
|
|
36
|
+
expect(opt?.getAttribute("role")).toBe("option");
|
|
37
|
+
expect(opt?.getAttribute("aria-selected")).toBe("true");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("the widget scrolls the active option into view and mirrors aria-activedescendant", () => {
|
|
41
|
+
// This behavior lives in the widget's layout effects, which need a live
|
|
42
|
+
// ProseMirror view to exercise — lock it at the source so it can't regress.
|
|
43
|
+
const src = readFileSync(
|
|
44
|
+
resolve(process.cwd(), "src/markdown-editor/slash/slash-widget.tsx"),
|
|
45
|
+
"utf8",
|
|
46
|
+
);
|
|
47
|
+
expect(src).toMatch(/scrollIntoView\(\{\s*block:\s*"nearest"\s*\}\)/);
|
|
48
|
+
expect(src).toMatch(/aria-activedescendant/);
|
|
49
|
+
// The scroll effect re-runs as ↑/↓ changes the active row.
|
|
50
|
+
expect(src).toMatch(/\[activeId\]/);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SlashWidget — the React widget the slash plugin mounts at the caret.
|
|
5
|
+
*
|
|
6
|
+
* The adapter renders this (no props) inside a ProseMirror widget decoration; it
|
|
7
|
+
* reads the live `EditorView` from `useWidgetViewContext()`, pulls the slash
|
|
8
|
+
* plugin state, and renders the branded `<SlashMenu>` floating just below the
|
|
9
|
+
* caret. Clicking an option runs the command through the shared
|
|
10
|
+
* {@link SlashController} (same path as the plugin's Enter handler), so the mouse
|
|
11
|
+
* and keyboard insert identically.
|
|
12
|
+
*
|
|
13
|
+
* Built via `createSlashWidget(controller)` so the component closes over the
|
|
14
|
+
* controller (commands + Milkdown `Ctx` getter) — no module-level mutable state,
|
|
15
|
+
* no circular import with the plugin.
|
|
16
|
+
*/
|
|
17
|
+
import type { ReactWidgetViewComponent } from "@prosemirror-adapter/react";
|
|
18
|
+
import { useWidgetViewContext } from "@prosemirror-adapter/react";
|
|
19
|
+
import { useLayoutEffect, useRef } from "react";
|
|
20
|
+
|
|
21
|
+
import { filterSlashCommands, type SlashCommand } from "./brand-slash-commands";
|
|
22
|
+
import { SlashMenu, slashOptionId } from "./slash-menu";
|
|
23
|
+
import {
|
|
24
|
+
runSlashCommand,
|
|
25
|
+
slashPluginKey,
|
|
26
|
+
type SlashController,
|
|
27
|
+
type SlashPluginState,
|
|
28
|
+
} from "./brand-slash-plugin";
|
|
29
|
+
|
|
30
|
+
const ID_PREFIX = "brand-slash";
|
|
31
|
+
|
|
32
|
+
/** Build the widget component bound to a controller. */
|
|
33
|
+
export function createSlashWidget(controller: SlashController): ReactWidgetViewComponent {
|
|
34
|
+
function SlashWidget() {
|
|
35
|
+
const { view } = useWidgetViewContext();
|
|
36
|
+
const wrapperRef = useRef<HTMLSpanElement>(null);
|
|
37
|
+
|
|
38
|
+
const state = slashPluginKey.getState(view.state) as SlashPluginState | undefined;
|
|
39
|
+
const query = state?.query ?? "";
|
|
40
|
+
const filtered = filterSlashCommands(controller.commands, query);
|
|
41
|
+
const activeIndex = state ? Math.min(state.index, Math.max(0, filtered.length - 1)) : 0;
|
|
42
|
+
const active = filtered[activeIndex];
|
|
43
|
+
const activeId = active?.id;
|
|
44
|
+
|
|
45
|
+
// Mirror the active option onto the editor's `textbox` for AT
|
|
46
|
+
// (`aria-activedescendant` + `aria-controls`), then clean up on unmount.
|
|
47
|
+
useLayoutEffect(() => {
|
|
48
|
+
const dom = view.dom as HTMLElement;
|
|
49
|
+
const listEl = wrapperRef.current?.querySelector<HTMLElement>('[role="listbox"]');
|
|
50
|
+
if (listEl && !listEl.id) listEl.id = `${ID_PREFIX}-listbox`;
|
|
51
|
+
dom.setAttribute("aria-expanded", "true");
|
|
52
|
+
if (listEl) dom.setAttribute("aria-controls", listEl.id);
|
|
53
|
+
if (activeId) dom.setAttribute("aria-activedescendant", slashOptionId(ID_PREFIX, activeId));
|
|
54
|
+
else dom.removeAttribute("aria-activedescendant");
|
|
55
|
+
return () => {
|
|
56
|
+
dom.removeAttribute("aria-expanded");
|
|
57
|
+
dom.removeAttribute("aria-controls");
|
|
58
|
+
dom.removeAttribute("aria-activedescendant");
|
|
59
|
+
};
|
|
60
|
+
}, [view, activeId]);
|
|
61
|
+
|
|
62
|
+
// Keep the highlighted option scrolled into view as ↑/↓ moves it.
|
|
63
|
+
useLayoutEffect(() => {
|
|
64
|
+
if (!activeId) return;
|
|
65
|
+
const el = wrapperRef.current?.querySelector<HTMLElement>(
|
|
66
|
+
`#${CSS.escape(slashOptionId(ID_PREFIX, activeId))}`,
|
|
67
|
+
);
|
|
68
|
+
el?.scrollIntoView({ block: "nearest" });
|
|
69
|
+
}, [activeId]);
|
|
70
|
+
|
|
71
|
+
const onSelect = (command: SlashCommand) => {
|
|
72
|
+
if (!state) return;
|
|
73
|
+
runSlashCommand(view, controller, state, command);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
// The widget anchor is zero-width inline; the menu floats below the caret.
|
|
78
|
+
<span
|
|
79
|
+
ref={wrapperRef}
|
|
80
|
+
contentEditable={false}
|
|
81
|
+
// Keep the widget out of the accessibility tree as a node — the listbox
|
|
82
|
+
// inside carries the semantics, and the editor textbox owns the relationship.
|
|
83
|
+
className="brand-slash-anchor relative inline-block h-0 w-0 align-baseline"
|
|
84
|
+
>
|
|
85
|
+
<span className="absolute left-0 top-1 z-50 block">
|
|
86
|
+
<SlashMenu
|
|
87
|
+
commands={filtered}
|
|
88
|
+
activeId={activeId}
|
|
89
|
+
onSelect={onSelect}
|
|
90
|
+
idPrefix={ID_PREFIX}
|
|
91
|
+
/>
|
|
92
|
+
</span>
|
|
93
|
+
</span>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return SlashWidget;
|
|
97
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { slashTriggerRange } from "./source-slash-trigger";
|
|
4
|
+
|
|
5
|
+
describe("slashTriggerRange", () => {
|
|
6
|
+
it("triggers at the start of a line (column 1)", () => {
|
|
7
|
+
expect(slashTriggerRange(3, "/", 1)).toEqual({
|
|
8
|
+
startLineNumber: 3,
|
|
9
|
+
startColumn: 1,
|
|
10
|
+
endLineNumber: 3,
|
|
11
|
+
endColumn: 2,
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("triggers right after whitespace", () => {
|
|
16
|
+
// "hello /" → the "/" is at column 7, preceded by a space.
|
|
17
|
+
expect(slashTriggerRange(1, "hello /", 7)).toEqual({
|
|
18
|
+
startLineNumber: 1,
|
|
19
|
+
startColumn: 7,
|
|
20
|
+
endLineNumber: 1,
|
|
21
|
+
endColumn: 8,
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("does NOT trigger inside a word", () => {
|
|
26
|
+
// "ab/" → the "/" is at column 3, preceded by "b".
|
|
27
|
+
expect(slashTriggerRange(1, "ab/", 3)).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("does NOT trigger inside a path / URL", () => {
|
|
31
|
+
expect(slashTriggerRange(1, "http://", 6)).toBeNull(); // preceded by ":"
|
|
32
|
+
expect(slashTriggerRange(1, "a/b/", 4)).toBeNull(); // preceded by "b"
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("returns null when the column does not actually hold a slash", () => {
|
|
36
|
+
expect(slashTriggerRange(1, "hello", 3)).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("treats a tab as whitespace", () => {
|
|
40
|
+
expect(slashTriggerRange(1, "\t/", 2)).not.toBeNull();
|
|
41
|
+
});
|
|
42
|
+
});
|