@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,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The brand slash-command registry — pure, typed, tree-shakeable data.
|
|
3
|
+
*
|
|
4
|
+
* Each command is a label + group + match keywords + an icon + a `run` that
|
|
5
|
+
* inserts the corresponding block at the caret. `run` is given a
|
|
6
|
+
* {@link SlashInsertContext} (the Milkdown `Ctx` + the active `/query` range);
|
|
7
|
+
* brand directives delegate to `resolveBrandInsert`, basic blocks to
|
|
8
|
+
* `resolveBasicInsert` — so the SAME registry drives the menu and any
|
|
9
|
+
* programmatic insert, and the load-bearing insertion logic stays in
|
|
10
|
+
* `insert-directive.ts` (unit-tested there).
|
|
11
|
+
*
|
|
12
|
+
* Defaults: card · callout · metric · timeline ("Brand blocks") +
|
|
13
|
+
* heading · bullet-list · ordered-list · quote · code · divider ("Basic").
|
|
14
|
+
* Consumers can extend or replace the list via the `slashMenu` prop.
|
|
15
|
+
*
|
|
16
|
+
* `runInSource` (#299) is an OPTIONAL, ADDITIVE second handler for the Monaco
|
|
17
|
+
* source pane — `run` (Milkdown) stays untouched, so the 12 default commands
|
|
18
|
+
* need zero migration. `MonacoCodeEditor` / `IRange` / `EditorContentAccess`
|
|
19
|
+
* below are TYPE-ONLY imports (erased at build) — this file stays free of any
|
|
20
|
+
* `monaco-editor` RUNTIME import, so the Milkdown-facing `slash/index.ts`
|
|
21
|
+
* barrel this file feeds still pulls zero Monaco code.
|
|
22
|
+
*/
|
|
23
|
+
import type { Ctx } from "@milkdown/kit/ctx";
|
|
24
|
+
import {
|
|
25
|
+
Calculator,
|
|
26
|
+
Grid3x3,
|
|
27
|
+
Heading2,
|
|
28
|
+
List,
|
|
29
|
+
ListOrdered,
|
|
30
|
+
Minus,
|
|
31
|
+
Quote,
|
|
32
|
+
Repeat2,
|
|
33
|
+
SquareCode,
|
|
34
|
+
type LucideIcon,
|
|
35
|
+
} from "lucide-react";
|
|
36
|
+
import type { IRange } from "monaco-editor";
|
|
37
|
+
import { createElement, type ReactNode } from "react";
|
|
38
|
+
|
|
39
|
+
import type { MonacoCodeEditor } from "../../code-editor";
|
|
40
|
+
import type { EditorContentAccess } from "../../lib/editor-content-access";
|
|
41
|
+
import type { IterationEditHandler } from "../../markdown-iteration/edit-context";
|
|
42
|
+
import {
|
|
43
|
+
CALC_FENCE_SEED,
|
|
44
|
+
resolveBasicInsert,
|
|
45
|
+
resolveBrandInsert,
|
|
46
|
+
resolveCalcInsert,
|
|
47
|
+
resolveGuidedIterationInsert,
|
|
48
|
+
type InsertableDirective,
|
|
49
|
+
type SlashRange,
|
|
50
|
+
} from "./insert-directive";
|
|
51
|
+
|
|
52
|
+
/** The native (non-directive) blocks the slash menu can insert. */
|
|
53
|
+
export type BasicBlockId =
|
|
54
|
+
| "heading"
|
|
55
|
+
| "bullet-list"
|
|
56
|
+
| "ordered-list"
|
|
57
|
+
| "quote"
|
|
58
|
+
| "code"
|
|
59
|
+
| "divider";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Context handed to a {@link SlashCommand.run}. `ctx` is the live Milkdown editor
|
|
63
|
+
* context; `range` is the `[from, to)` document range of the typed trigger +
|
|
64
|
+
* query (so the command replaces it), or `null` to insert at the caret.
|
|
65
|
+
*/
|
|
66
|
+
export interface SlashInsertContext {
|
|
67
|
+
ctx: Ctx;
|
|
68
|
+
range: SlashRange | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Context handed to a {@link SlashCommand.runInSource} (#299) — the Monaco
|
|
73
|
+
* SOURCE-pane counterpart to {@link SlashInsertContext}. By the time this runs,
|
|
74
|
+
* the typed `/query` trigger has already been stripped from the document (the
|
|
75
|
+
* same edit `cancel` uses), so the handler starts from a clean doc.
|
|
76
|
+
*/
|
|
77
|
+
export interface SourceSlashContext {
|
|
78
|
+
/** The raw Monaco editor instance — full imperative access when needed. */
|
|
79
|
+
editor: MonacoCodeEditor;
|
|
80
|
+
/**
|
|
81
|
+
* Model range the typed `/query` trigger occupied (already removed by the
|
|
82
|
+
* time this fires) — `null` on the hotkey path, where no `/` was ever typed.
|
|
83
|
+
*/
|
|
84
|
+
range: IRange | null;
|
|
85
|
+
/** Engine-agnostic content access (get/replace-selection/insert-at-cursor). */
|
|
86
|
+
content: EditorContentAccess;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** One entry in the slash menu. Pure data + an insertion `run`. */
|
|
90
|
+
export interface SlashCommand {
|
|
91
|
+
/** Stable id (used as the React key + `aria-activedescendant` target). */
|
|
92
|
+
id: string;
|
|
93
|
+
/** Visible label, e.g. "Card". */
|
|
94
|
+
label: string;
|
|
95
|
+
/** Grouping header, e.g. "Brand blocks" | "Basic". */
|
|
96
|
+
group?: string;
|
|
97
|
+
/** A short description shown under the label. */
|
|
98
|
+
description?: string;
|
|
99
|
+
/** Extra fuzzy-match aids beyond the label (matched case-insensitively). */
|
|
100
|
+
keywords?: string[];
|
|
101
|
+
/** Leading icon (a Lucide glyph or any node). */
|
|
102
|
+
icon?: ReactNode;
|
|
103
|
+
/**
|
|
104
|
+
* Markdown SNIPPET the source / split toolbar's Insert menu inserts (text mode).
|
|
105
|
+
* Optional: commands without one — the basic blocks, which already have their
|
|
106
|
+
* own toolbar buttons — don't appear in that menu. Brand blocks set this so the
|
|
107
|
+
* source toolbar inserts the SAME directive / fence the WYSIWYG `run` builds, so
|
|
108
|
+
* the two surfaces stay at parity. (A4)
|
|
109
|
+
*/
|
|
110
|
+
snippet?: string;
|
|
111
|
+
/** Insert the block at the caret. WYSIWYG (Milkdown) — UNCHANGED (#299). */
|
|
112
|
+
run: (ctx: SlashInsertContext) => void;
|
|
113
|
+
/**
|
|
114
|
+
* OPTIONAL source-pane (Monaco) handler (#299) — for a RUN-ONLY command (no
|
|
115
|
+
* `snippet`, e.g. an "Ask AI" entry) that can't express itself as a markdown
|
|
116
|
+
* insert. When present, selecting the command in the Monaco source-pane menu
|
|
117
|
+
* (`MonacoSlashMenu`) strips the typed `/query` first, then calls this with
|
|
118
|
+
* the live editor + the (now-stripped) trigger range + engine-agnostic
|
|
119
|
+
* content access — instead of the `snippet`-only `commitSnippet` fallback. A
|
|
120
|
+
* command needs only ONE of `snippet` / `runInSource` to appear in source (both
|
|
121
|
+
* is fine too — `runInSource` wins there since it's the more capable handler).
|
|
122
|
+
*/
|
|
123
|
+
runInSource?: (ctx: SourceSlashContext) => void;
|
|
124
|
+
/**
|
|
125
|
+
* OPTIONAL guided variant (#223, WYSIWYG only). When present AND the
|
|
126
|
+
* consumer has wired an `IterationEditContext` handler (an
|
|
127
|
+
* `IterationBuilderProvider` / `IterationTemplateProvider` above the
|
|
128
|
+
* editor), this REPLACES `run` — it opens the guided modal seeded blank
|
|
129
|
+
* instead of inserting a bare directive. Falls back to `run` when no
|
|
130
|
+
* handler is available, so the 12 non-guided default commands need zero
|
|
131
|
+
* changes. Set on the `iterate` / `pivot` commands only.
|
|
132
|
+
*/
|
|
133
|
+
guided?: (insert: SlashInsertContext, handler: IterationEditHandler) => void;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Build a Lucide icon element at the menu's standard size. */
|
|
137
|
+
function glyph(icon: LucideIcon): ReactNode {
|
|
138
|
+
return createElement(icon, { className: "size-4", "aria-hidden": "true" });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Source-mode markdown snippet per brand directive — the text the Insert menu
|
|
143
|
+
* drops into the Monaco pane. Mirrors the WYSIWYG insert (the `DIRECTIVE_DEFAULTS`
|
|
144
|
+
* attrs + body seeds in `insert-directive.ts`) so both surfaces produce the same
|
|
145
|
+
* `:::` markdown.
|
|
146
|
+
*/
|
|
147
|
+
const DIRECTIVE_SNIPPET: Record<InsertableDirective, string> = {
|
|
148
|
+
card: `:::card{title="Title"}\nContent\n:::`,
|
|
149
|
+
callout: `:::callout{type="info" title="Note"}\nMessage\n:::`,
|
|
150
|
+
metric: `::metric{label="Label" value="0" description="detail"}`,
|
|
151
|
+
timeline: `:::timeline\n- (done) Step one\n- (active) Step two\n- (pending) Step three\n:::`,
|
|
152
|
+
iterate: `:::iterate{as="item" layout="stacked"}\n{{item.name}}\n:::`,
|
|
153
|
+
pivot: `:::pivot{layout="matrix"}\n{{cell}}\n:::`,
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/** Directives with a guided authoring modal (#223) — see {@link SlashCommand.guided}. */
|
|
157
|
+
const GUIDED_DIRECTIVES = new Set<InsertableDirective>(["iterate", "pivot"]);
|
|
158
|
+
|
|
159
|
+
/** A brand-directive command (card/callout/metric/timeline/iterate/pivot). */
|
|
160
|
+
function brandCommand(
|
|
161
|
+
name: InsertableDirective,
|
|
162
|
+
label: string,
|
|
163
|
+
description: string,
|
|
164
|
+
keywords: string[],
|
|
165
|
+
icon: ReactNode,
|
|
166
|
+
): SlashCommand {
|
|
167
|
+
const command: SlashCommand = {
|
|
168
|
+
id: `brand-${name}`,
|
|
169
|
+
label,
|
|
170
|
+
group: "Brand blocks",
|
|
171
|
+
description,
|
|
172
|
+
keywords,
|
|
173
|
+
icon,
|
|
174
|
+
snippet: DIRECTIVE_SNIPPET[name],
|
|
175
|
+
run: ({ ctx, range }) => resolveBrandInsert(ctx, name, range),
|
|
176
|
+
};
|
|
177
|
+
if (GUIDED_DIRECTIVES.has(name)) {
|
|
178
|
+
command.guided = ({ ctx, range }, handler) =>
|
|
179
|
+
resolveGuidedIterationInsert(ctx, name as "iterate" | "pivot", range, handler);
|
|
180
|
+
}
|
|
181
|
+
return command;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** A basic native-block command. */
|
|
185
|
+
function basicCommand(
|
|
186
|
+
id: BasicBlockId,
|
|
187
|
+
label: string,
|
|
188
|
+
description: string,
|
|
189
|
+
keywords: string[],
|
|
190
|
+
icon: LucideIcon,
|
|
191
|
+
): SlashCommand {
|
|
192
|
+
return {
|
|
193
|
+
id: `basic-${id}`,
|
|
194
|
+
label,
|
|
195
|
+
group: "Basic",
|
|
196
|
+
description,
|
|
197
|
+
keywords,
|
|
198
|
+
icon: glyph(icon),
|
|
199
|
+
run: ({ ctx, range }) => resolveBasicInsert(ctx, id, range),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The default command list. Brand blocks first (the wedge), then the basics.
|
|
205
|
+
* The brand-block icons reuse the `data-brand-directive` chrome look via Lucide
|
|
206
|
+
* stand-ins (the editor renders the real component once inserted).
|
|
207
|
+
*/
|
|
208
|
+
export const BRAND_SLASH_COMMANDS: SlashCommand[] = [
|
|
209
|
+
brandCommand(
|
|
210
|
+
"card",
|
|
211
|
+
"Card",
|
|
212
|
+
"A titled content card",
|
|
213
|
+
["card", "panel", "box", "section"],
|
|
214
|
+
glyph(SquareCode),
|
|
215
|
+
),
|
|
216
|
+
brandCommand(
|
|
217
|
+
"callout",
|
|
218
|
+
"Callout",
|
|
219
|
+
"A highlighted note / alert",
|
|
220
|
+
["callout", "alert", "note", "info", "warning", "tip"],
|
|
221
|
+
glyph(Quote),
|
|
222
|
+
),
|
|
223
|
+
brandCommand(
|
|
224
|
+
"metric",
|
|
225
|
+
"Metric",
|
|
226
|
+
"A single KPI value",
|
|
227
|
+
["metric", "kpi", "stat", "number", "value"],
|
|
228
|
+
glyph(Minus),
|
|
229
|
+
),
|
|
230
|
+
brandCommand(
|
|
231
|
+
"timeline",
|
|
232
|
+
"Timeline",
|
|
233
|
+
"A list of steps with status",
|
|
234
|
+
["timeline", "steps", "milestones", "roadmap", "progress"],
|
|
235
|
+
glyph(List),
|
|
236
|
+
),
|
|
237
|
+
brandCommand(
|
|
238
|
+
"iterate",
|
|
239
|
+
"Iterate",
|
|
240
|
+
"Repeat a template per data row",
|
|
241
|
+
["iterate", "repeat", "loop", "for-each", "foreach", "map", "list", "template"],
|
|
242
|
+
glyph(Repeat2),
|
|
243
|
+
),
|
|
244
|
+
brandCommand(
|
|
245
|
+
"pivot",
|
|
246
|
+
"Pivot",
|
|
247
|
+
"A row × column cross-tab",
|
|
248
|
+
["pivot", "matrix", "cross-tab", "crosstab", "table", "grid"],
|
|
249
|
+
glyph(Grid3x3),
|
|
250
|
+
),
|
|
251
|
+
{
|
|
252
|
+
// Inserts a ```calc fence (not a `:::` directive), so it runs its own command
|
|
253
|
+
// rather than `resolveBrandInsert`. Highlight + result inlays come from the
|
|
254
|
+
// editor's calc hooks; the fence is harmless when none are wired.
|
|
255
|
+
id: "brand-calc",
|
|
256
|
+
label: "Calc",
|
|
257
|
+
group: "Brand blocks",
|
|
258
|
+
description: "A live calculation block",
|
|
259
|
+
keywords: ["calc", "calculation", "math", "formula", "sum", "ledger", "budget"],
|
|
260
|
+
icon: glyph(Calculator),
|
|
261
|
+
snippet: ["```calc", CALC_FENCE_SEED, "```"].join("\n"),
|
|
262
|
+
run: ({ ctx, range }) => resolveCalcInsert(ctx, range),
|
|
263
|
+
},
|
|
264
|
+
basicCommand("heading", "Heading", "Section heading", ["heading", "title", "h2"], Heading2),
|
|
265
|
+
basicCommand(
|
|
266
|
+
"bullet-list",
|
|
267
|
+
"Bullet list",
|
|
268
|
+
"An unordered list",
|
|
269
|
+
["bullet", "list", "unordered", "ul"],
|
|
270
|
+
List,
|
|
271
|
+
),
|
|
272
|
+
basicCommand(
|
|
273
|
+
"ordered-list",
|
|
274
|
+
"Numbered list",
|
|
275
|
+
"An ordered list",
|
|
276
|
+
["numbered", "ordered", "list", "ol"],
|
|
277
|
+
ListOrdered,
|
|
278
|
+
),
|
|
279
|
+
basicCommand("quote", "Quote", "A block quotation", ["quote", "blockquote", "citation"], Quote),
|
|
280
|
+
basicCommand(
|
|
281
|
+
"code",
|
|
282
|
+
"Code block",
|
|
283
|
+
"A fenced code block",
|
|
284
|
+
["code", "snippet", "pre", "fence"],
|
|
285
|
+
SquareCode,
|
|
286
|
+
),
|
|
287
|
+
basicCommand(
|
|
288
|
+
"divider",
|
|
289
|
+
"Divider",
|
|
290
|
+
"A horizontal rule",
|
|
291
|
+
["divider", "rule", "hr", "separator"],
|
|
292
|
+
Minus,
|
|
293
|
+
),
|
|
294
|
+
];
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Filter a command list by a query (case-insensitive substring over label +
|
|
298
|
+
* keywords). An empty query returns the full list (in registry order).
|
|
299
|
+
*/
|
|
300
|
+
export function filterSlashCommands(commands: SlashCommand[], query: string): SlashCommand[] {
|
|
301
|
+
const q = query.trim().toLowerCase();
|
|
302
|
+
if (!q) return commands;
|
|
303
|
+
return commands.filter((c) => {
|
|
304
|
+
if (c.label.toLowerCase().includes(q)) return true;
|
|
305
|
+
return (c.keywords ?? []).some((k) => k.toLowerCase().includes(q));
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Preserve registry order while grouping; used by the menu to render headers. */
|
|
310
|
+
export function groupSlashCommands(
|
|
311
|
+
commands: SlashCommand[],
|
|
312
|
+
): { group: string; commands: SlashCommand[] }[] {
|
|
313
|
+
const order: string[] = [];
|
|
314
|
+
const byGroup = new Map<string, SlashCommand[]>();
|
|
315
|
+
for (const c of commands) {
|
|
316
|
+
const g = c.group ?? "Other";
|
|
317
|
+
if (!byGroup.has(g)) {
|
|
318
|
+
byGroup.set(g, []);
|
|
319
|
+
order.push(g);
|
|
320
|
+
}
|
|
321
|
+
byGroup.get(g)!.push(c);
|
|
322
|
+
}
|
|
323
|
+
return order.map((group) => ({ group, commands: byGroup.get(group)! }));
|
|
324
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for brand-slash-plugin.ts — the `nextState` open-meta path and the
|
|
3
|
+
* `runSlashCommand` range behavior for shortcut mode (#271).
|
|
4
|
+
*
|
|
5
|
+
* We use a real headless Milkdown editor (the insert-directive.test.ts precedent)
|
|
6
|
+
* to verify the pure ProseMirror logic. The React widget and the keyboard UX are
|
|
7
|
+
* verified in Storybook (browser only — jsdom can't render Monaco/Milkdown).
|
|
8
|
+
*
|
|
9
|
+
* No monaco mock is needed: brand-slash-plugin.ts imports only the PURE
|
|
10
|
+
* `./shortcut` (matchesKeyboardEvent); the Monaco-keybinding parser lives in the
|
|
11
|
+
* separate `./shortcut-monaco`, which this plugin never imports — so the WYSIWYG
|
|
12
|
+
* plugin graph stays Monaco-free (and jsdom-safe).
|
|
13
|
+
*/
|
|
14
|
+
import { Editor, defaultValueCtx, editorViewCtx, rootCtx } from "@milkdown/kit/core";
|
|
15
|
+
import { commonmark } from "@milkdown/kit/preset/commonmark";
|
|
16
|
+
import { gfm } from "@milkdown/kit/preset/gfm";
|
|
17
|
+
import { getMarkdown } from "@milkdown/kit/utils";
|
|
18
|
+
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
19
|
+
|
|
20
|
+
import { directivePlugins } from "../directive-nodes";
|
|
21
|
+
import { BRAND_SLASH_COMMANDS } from "./brand-slash-commands";
|
|
22
|
+
import {
|
|
23
|
+
CLOSED,
|
|
24
|
+
createSlashController,
|
|
25
|
+
nextState,
|
|
26
|
+
runSlashCommand,
|
|
27
|
+
slashPluginKey,
|
|
28
|
+
type SlashPluginState,
|
|
29
|
+
} from "./brand-slash-plugin";
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Headless Milkdown helpers (same pattern as insert-directive.test.ts)
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
let editorRoot: HTMLDivElement | null = null;
|
|
36
|
+
|
|
37
|
+
async function makeEditor(initial = ""): Promise<Editor> {
|
|
38
|
+
const root = document.createElement("div");
|
|
39
|
+
document.body.appendChild(root);
|
|
40
|
+
editorRoot = root;
|
|
41
|
+
const editor = await Editor.make()
|
|
42
|
+
.config((ctx) => {
|
|
43
|
+
ctx.set(rootCtx, root);
|
|
44
|
+
ctx.set(defaultValueCtx, initial);
|
|
45
|
+
})
|
|
46
|
+
.use(commonmark)
|
|
47
|
+
.use(gfm)
|
|
48
|
+
.use(directivePlugins)
|
|
49
|
+
.create();
|
|
50
|
+
return editor;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function serialize(editor: Editor): string {
|
|
54
|
+
return editor.action(getMarkdown());
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
editorRoot = null;
|
|
59
|
+
});
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
editorRoot?.remove();
|
|
62
|
+
editorRoot = null;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// nextState: the `_open` meta opens the menu in shortcut mode (real behavior).
|
|
67
|
+
//
|
|
68
|
+
// `nextState` is a pure function over (prev, transaction, trigger). We build a
|
|
69
|
+
// real ProseMirror transaction from a headless editor and assert the resulting
|
|
70
|
+
// state — no source-text regex, no widget mounting needed.
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
describe("nextState — shortcut open meta", () => {
|
|
74
|
+
test("CLOSED defaults triggered to 'char'", () => {
|
|
75
|
+
expect(CLOSED.triggered).toBe("char");
|
|
76
|
+
expect(CLOSED.active).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("an `_open` meta opens the menu at the given caret in shortcut mode", async () => {
|
|
80
|
+
const editor = await makeEditor("Before text\n");
|
|
81
|
+
await editor.action((ctx) => {
|
|
82
|
+
const view = ctx.get(editorViewCtx);
|
|
83
|
+
const tr = view.state.tr.setMeta(slashPluginKey, {
|
|
84
|
+
_open: true,
|
|
85
|
+
from: 5,
|
|
86
|
+
triggered: "shortcut",
|
|
87
|
+
});
|
|
88
|
+
const next = nextState(CLOSED, tr, "/");
|
|
89
|
+
expect(next.active).toBe(true);
|
|
90
|
+
expect(next.triggered).toBe("shortcut");
|
|
91
|
+
expect(next.from).toBe(5);
|
|
92
|
+
expect(next.query).toBe("");
|
|
93
|
+
expect(next.index).toBe(0);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("a `close` meta returns the CLOSED state", async () => {
|
|
98
|
+
const editor = await makeEditor("Before text\n");
|
|
99
|
+
await editor.action((ctx) => {
|
|
100
|
+
const view = ctx.get(editorViewCtx);
|
|
101
|
+
const open: SlashPluginState = {
|
|
102
|
+
active: true,
|
|
103
|
+
from: 5,
|
|
104
|
+
query: "",
|
|
105
|
+
index: 0,
|
|
106
|
+
triggered: "shortcut",
|
|
107
|
+
};
|
|
108
|
+
const tr = view.state.tr.setMeta(slashPluginKey, "close");
|
|
109
|
+
const next = nextState(open, tr, "/");
|
|
110
|
+
expect(next.active).toBe(false);
|
|
111
|
+
expect(next.triggered).toBe("char");
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// runSlashCommand — shortcut mode replaces [from, from+query.length]; char mode
|
|
118
|
+
// replaces the whole /query run.
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
describe("runSlashCommand — shortcut vs char range", () => {
|
|
122
|
+
test("in shortcut mode, the card command inserts at caret without deleting anything", async () => {
|
|
123
|
+
const editor = await makeEditor("Before text\n");
|
|
124
|
+
|
|
125
|
+
await editor.action(async (ctx) => {
|
|
126
|
+
const view = ctx.get(editorViewCtx);
|
|
127
|
+
|
|
128
|
+
// Shortcut opens at the caret with no typed query — here the caret is at the
|
|
129
|
+
// end of "Before text" (pos 12). An empty-query shortcut inserts at `from`
|
|
130
|
+
// and deletes nothing, so the paragraph text survives intact.
|
|
131
|
+
const shortcutState: SlashPluginState = {
|
|
132
|
+
active: true,
|
|
133
|
+
from: 12,
|
|
134
|
+
query: "",
|
|
135
|
+
index: 0,
|
|
136
|
+
triggered: "shortcut",
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const controller = createSlashController(BRAND_SLASH_COMMANDS, "/");
|
|
140
|
+
controller._ctx = ctx;
|
|
141
|
+
|
|
142
|
+
const cardCommand = BRAND_SLASH_COMMANDS.find((c) => c.id === "brand-card")!;
|
|
143
|
+
expect(cardCommand).toBeDefined();
|
|
144
|
+
|
|
145
|
+
runSlashCommand(view, controller, shortcutState, cardCommand);
|
|
146
|
+
|
|
147
|
+
// The document should now contain the card directive.
|
|
148
|
+
const md = serialize(editor);
|
|
149
|
+
expect(md).toContain(":::card");
|
|
150
|
+
// "Before text" must still be present (nothing was deleted).
|
|
151
|
+
expect(md).toContain("Before text");
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("in char mode, the card command replaces the /query range", async () => {
|
|
156
|
+
const editor = await makeEditor("/\n");
|
|
157
|
+
|
|
158
|
+
await editor.action(async (ctx) => {
|
|
159
|
+
const view = ctx.get(editorViewCtx);
|
|
160
|
+
|
|
161
|
+
// Char-mode state: the `/` is at position 1 (start of paragraph).
|
|
162
|
+
const charState: SlashPluginState = {
|
|
163
|
+
active: true,
|
|
164
|
+
from: 1,
|
|
165
|
+
query: "",
|
|
166
|
+
index: 0,
|
|
167
|
+
triggered: "char",
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const controller = createSlashController(BRAND_SLASH_COMMANDS, "/");
|
|
171
|
+
controller._ctx = ctx;
|
|
172
|
+
|
|
173
|
+
const cardCommand = BRAND_SLASH_COMMANDS.find((c) => c.id === "brand-card")!;
|
|
174
|
+
runSlashCommand(view, controller, charState, cardCommand);
|
|
175
|
+
|
|
176
|
+
const md = serialize(editor);
|
|
177
|
+
expect(md).toContain(":::card");
|
|
178
|
+
// The `/` trigger should be gone.
|
|
179
|
+
expect(md).not.toMatch(/^\/\s*$/m);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("in shortcut mode, a typed filter query is removed (#271 review)", async () => {
|
|
184
|
+
// The shortcut opens with no leading `/`, but any filter the user typed is
|
|
185
|
+
// real document text — runSlashCommand must replace [from, from+query.length].
|
|
186
|
+
const editor = await makeEditor("callout\n");
|
|
187
|
+
|
|
188
|
+
await editor.action(async (ctx) => {
|
|
189
|
+
const view = ctx.get(editorViewCtx);
|
|
190
|
+
// Caret-open at paragraph start (pos 1); the 7-char query "callout" follows.
|
|
191
|
+
const shortcutState: SlashPluginState = {
|
|
192
|
+
active: true,
|
|
193
|
+
from: 1,
|
|
194
|
+
query: "callout",
|
|
195
|
+
index: 0,
|
|
196
|
+
triggered: "shortcut",
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const controller = createSlashController(BRAND_SLASH_COMMANDS, "/");
|
|
200
|
+
controller._ctx = ctx;
|
|
201
|
+
|
|
202
|
+
const calloutCommand = BRAND_SLASH_COMMANDS.find((c) => c.id === "brand-callout")!;
|
|
203
|
+
runSlashCommand(view, controller, shortcutState, calloutCommand);
|
|
204
|
+
|
|
205
|
+
const md = serialize(editor);
|
|
206
|
+
expect(md).toContain(":::callout");
|
|
207
|
+
// The typed filter text must NOT survive as a stray paragraph.
|
|
208
|
+
expect(md).not.toMatch(/^callout\s*$/m);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// runSlashCommand — the GUIDED branch (#223) must consume the typed `/query`
|
|
215
|
+
// range up front, even when the consumer's handler never saves (Cancel). The
|
|
216
|
+
// guided resolver doesn't dispatch an insert transaction until the modal
|
|
217
|
+
// SAVES, so without this the literal "/iterate" text was left behind
|
|
218
|
+
// permanently whenever the user opened the guided builder and cancelled it.
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
describe("runSlashCommand — guided branch consumes the /query range", () => {
|
|
222
|
+
test("typing /iterate and cancelling the guided modal still removes the typed trigger text", async () => {
|
|
223
|
+
const editor = await makeEditor("/iterate\n");
|
|
224
|
+
|
|
225
|
+
await editor.action(async (ctx) => {
|
|
226
|
+
const view = ctx.get(editorViewCtx);
|
|
227
|
+
|
|
228
|
+
const charState: SlashPluginState = {
|
|
229
|
+
active: true,
|
|
230
|
+
from: 1,
|
|
231
|
+
query: "iterate",
|
|
232
|
+
index: 0,
|
|
233
|
+
triggered: "char",
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
// A handler that simulates the user cancelling the guided modal: it
|
|
237
|
+
// receives the edit request but never calls onSave/onSaveData.
|
|
238
|
+
const controller = createSlashController(BRAND_SLASH_COMMANDS, "/", undefined, () => () => {
|
|
239
|
+
// Cancel: intentionally does nothing.
|
|
240
|
+
});
|
|
241
|
+
controller._ctx = ctx;
|
|
242
|
+
|
|
243
|
+
const iterateCommand = BRAND_SLASH_COMMANDS.find((c) => c.id === "brand-iterate")!;
|
|
244
|
+
expect(iterateCommand.guided).toBeDefined();
|
|
245
|
+
|
|
246
|
+
runSlashCommand(view, controller, charState, iterateCommand);
|
|
247
|
+
|
|
248
|
+
const md = serialize(editor);
|
|
249
|
+
// No directive was inserted (Cancel) — but the typed "/iterate" trigger
|
|
250
|
+
// text must be gone too, not left behind as stray body text.
|
|
251
|
+
expect(md).not.toMatch(/\/iterate/);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
});
|