@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.
Files changed (207) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -0
  3. package/dist/chunk-LBC5VJBD.js +3748 -0
  4. package/dist/chunk-LBC5VJBD.js.map +1 -0
  5. package/dist/index.css +233 -0
  6. package/dist/index.css.map +1 -0
  7. package/dist/index.d.ts +199 -0
  8. package/dist/index.js +362 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/lib/monaco-environment.d.ts +2 -0
  11. package/dist/lib/monaco-environment.js +44 -0
  12. package/dist/lib/monaco-environment.js.map +1 -0
  13. package/dist/markdown/frontmatter.d.ts +11 -0
  14. package/dist/markdown/frontmatter.js +33 -0
  15. package/dist/markdown/frontmatter.js.map +1 -0
  16. package/dist/markdown/index.css +233 -0
  17. package/dist/markdown/index.css.map +1 -0
  18. package/dist/markdown/index.d.ts +1486 -0
  19. package/dist/markdown/index.js +4882 -0
  20. package/dist/markdown/index.js.map +1 -0
  21. package/dist/markdown/parse.d.ts +25 -0
  22. package/dist/markdown/parse.js +14 -0
  23. package/dist/markdown/parse.js.map +1 -0
  24. package/dist/markdown-editor-DfBZibAn.d.ts +702 -0
  25. package/package.json +101 -0
  26. package/src/ai-objects/ai-objects-integration.test.tsx +128 -0
  27. package/src/ai-objects/ai-objects.stories.tsx +233 -0
  28. package/src/ai-objects/ai-objects.test.tsx +205 -0
  29. package/src/ai-objects/decision-card.tsx +178 -0
  30. package/src/ai-objects/directives.ts +136 -0
  31. package/src/ai-objects/entity.tsx +181 -0
  32. package/src/ai-objects/index.ts +34 -0
  33. package/src/ai-objects/knowledge-card.tsx +143 -0
  34. package/src/calc-block/calc-block.stories.tsx +297 -0
  35. package/src/calc-block/calc-block.test.tsx +164 -0
  36. package/src/calc-block/calc-block.tsx +398 -0
  37. package/src/calc-block/calc-editor-monaco.ts +214 -0
  38. package/src/calc-block/calc-editor-prose.ts +128 -0
  39. package/src/calc-block/calc-editor.css +80 -0
  40. package/src/calc-block/calc-editor.stories.tsx +272 -0
  41. package/src/calc-block/calc-editor.test.ts +228 -0
  42. package/src/calc-block/calc-editor.ts +270 -0
  43. package/src/calc-block/calc-inline.stories.tsx +44 -0
  44. package/src/calc-block/calc-inline.test.tsx +44 -0
  45. package/src/calc-block/calc-inline.tsx +75 -0
  46. package/src/calc-block/index.ts +20 -0
  47. package/src/calc-block/types.ts +164 -0
  48. package/src/code-editor/code-editor.stories.tsx +218 -0
  49. package/src/code-editor/code-editor.test.tsx +134 -0
  50. package/src/code-editor/code-editor.tsx +250 -0
  51. package/src/code-editor/index.ts +6 -0
  52. package/src/code-workspace/code-workspace.stories.tsx +51 -0
  53. package/src/code-workspace/code-workspace.test.tsx +146 -0
  54. package/src/code-workspace/code-workspace.tsx +253 -0
  55. package/src/code-workspace/index.ts +6 -0
  56. package/src/copy-button/copy-button.tsx +51 -0
  57. package/src/copy-button/index.ts +1 -0
  58. package/src/css.d.ts +3 -0
  59. package/src/diff-editor/diff-editor.stories.tsx +44 -0
  60. package/src/diff-editor/diff-editor.test.tsx +68 -0
  61. package/src/diff-editor/diff-editor.tsx +151 -0
  62. package/src/diff-editor/index.ts +1 -0
  63. package/src/editor-context-menu/editor-context-menu.tsx +120 -0
  64. package/src/editor-context-menu/index.ts +1 -0
  65. package/src/editor-toolbar/editor-toolbar.tsx +83 -0
  66. package/src/editor-toolbar/index.ts +1 -0
  67. package/src/index.ts +48 -0
  68. package/src/lib/editor-completions-monaco.test.ts +206 -0
  69. package/src/lib/editor-completions-monaco.ts +127 -0
  70. package/src/lib/editor-completions.test.ts +156 -0
  71. package/src/lib/editor-completions.ts +153 -0
  72. package/src/lib/editor-content-access-prose.test.ts +411 -0
  73. package/src/lib/editor-content-access-prose.ts +172 -0
  74. package/src/lib/editor-content-access.test.ts +182 -0
  75. package/src/lib/editor-content-access.ts +109 -0
  76. package/src/lib/languages.ts +32 -0
  77. package/src/lib/markdown/diff.test.ts +80 -0
  78. package/src/lib/markdown/diff.ts +194 -0
  79. package/src/lib/markdown/directives.ts +342 -0
  80. package/src/lib/markdown/frontmatter.ts +50 -0
  81. package/src/lib/markdown/markdown-scale.test.ts +65 -0
  82. package/src/lib/markdown/markdown-scale.ts +62 -0
  83. package/src/lib/markdown/merge.test.ts +64 -0
  84. package/src/lib/markdown/merge.ts +158 -0
  85. package/src/lib/markdown/slugify.test.ts +156 -0
  86. package/src/lib/markdown/slugify.ts +47 -0
  87. package/src/lib/monaco-environment.ts +103 -0
  88. package/src/lib/monaco-theme-bridge.ts +293 -0
  89. package/src/lib/use-data-theme.ts +61 -0
  90. package/src/markdown/frontmatter.ts +14 -0
  91. package/src/markdown/index.ts +323 -0
  92. package/src/markdown/parse.test.ts +60 -0
  93. package/src/markdown/parse.ts +38 -0
  94. package/src/markdown-academic/citations.test.ts +77 -0
  95. package/src/markdown-academic/citations.tsx +442 -0
  96. package/src/markdown-academic/footnotes.tsx +235 -0
  97. package/src/markdown-academic/index.ts +32 -0
  98. package/src/markdown-academic/math.tsx +163 -0
  99. package/src/markdown-academic/toc.tsx +88 -0
  100. package/src/markdown-editor/completions/completions-menu.tsx +96 -0
  101. package/src/markdown-editor/completions/completions-prose.test.ts +356 -0
  102. package/src/markdown-editor/completions/completions-prose.ts +313 -0
  103. package/src/markdown-editor/completions/completions-widget.tsx +75 -0
  104. package/src/markdown-editor/completions/index.ts +37 -0
  105. package/src/markdown-editor/directive-nodes.ts +151 -0
  106. package/src/markdown-editor/directive-views.test.tsx +189 -0
  107. package/src/markdown-editor/directive-views.tsx +795 -0
  108. package/src/markdown-editor/exit-keymap.test.ts +88 -0
  109. package/src/markdown-editor/exit-keymap.ts +87 -0
  110. package/src/markdown-editor/index.ts +6 -0
  111. package/src/markdown-editor/markdown-editor.css +250 -0
  112. package/src/markdown-editor/markdown-editor.directives.test.tsx +105 -0
  113. package/src/markdown-editor/markdown-editor.fill.test.ts +34 -0
  114. package/src/markdown-editor/markdown-editor.paste-embed.test.tsx +297 -0
  115. package/src/markdown-editor/markdown-editor.stories.tsx +560 -0
  116. package/src/markdown-editor/markdown-editor.strictmode.test.tsx +38 -0
  117. package/src/markdown-editor/markdown-editor.table.test.tsx +287 -0
  118. package/src/markdown-editor/markdown-editor.tsx +560 -0
  119. package/src/markdown-editor/milkdown-react/editor.tsx +33 -0
  120. package/src/markdown-editor/milkdown-react/index.ts +8 -0
  121. package/src/markdown-editor/milkdown-react/types.ts +27 -0
  122. package/src/markdown-editor/milkdown-react/use-editor.ts +27 -0
  123. package/src/markdown-editor/milkdown-react/use-get-editor.ts +56 -0
  124. package/src/markdown-editor/milkdown-react/use-instance.ts +23 -0
  125. package/src/markdown-editor/paste-embed.ts +355 -0
  126. package/src/markdown-editor/slash/brand-slash-commands.test.ts +194 -0
  127. package/src/markdown-editor/slash/brand-slash-commands.ts +324 -0
  128. package/src/markdown-editor/slash/brand-slash-plugin.test.ts +254 -0
  129. package/src/markdown-editor/slash/brand-slash-plugin.ts +385 -0
  130. package/src/markdown-editor/slash/index.ts +107 -0
  131. package/src/markdown-editor/slash/insert-directive.test.ts +263 -0
  132. package/src/markdown-editor/slash/insert-directive.ts +345 -0
  133. package/src/markdown-editor/slash/monaco-slash-menu.test.tsx +198 -0
  134. package/src/markdown-editor/slash/monaco-slash-menu.tsx +356 -0
  135. package/src/markdown-editor/slash/shortcut-monaco.ts +69 -0
  136. package/src/markdown-editor/slash/shortcut.test.ts +157 -0
  137. package/src/markdown-editor/slash/shortcut.ts +57 -0
  138. package/src/markdown-editor/slash/slash-menu.stories.tsx +139 -0
  139. package/src/markdown-editor/slash/slash-menu.tsx +115 -0
  140. package/src/markdown-editor/slash/slash-scroll.test.tsx +52 -0
  141. package/src/markdown-editor/slash/slash-widget.tsx +97 -0
  142. package/src/markdown-editor/slash/source-slash-trigger.test.ts +42 -0
  143. package/src/markdown-editor/slash/source-slash-trigger.ts +46 -0
  144. package/src/markdown-editor/table-view.tsx +249 -0
  145. package/src/markdown-iteration/directive.tsx +92 -0
  146. package/src/markdown-iteration/edit-context.ts +56 -0
  147. package/src/markdown-iteration/index.ts +24 -0
  148. package/src/markdown-iteration/iteration-block.test.tsx +138 -0
  149. package/src/markdown-iteration/iteration-builder-dialog.stories.tsx +293 -0
  150. package/src/markdown-iteration/iteration-builder-dialog.tsx +312 -0
  151. package/src/markdown-iteration/iteration-builder.test.ts +252 -0
  152. package/src/markdown-iteration/iteration-builder.ts +303 -0
  153. package/src/markdown-iteration/iteration.test.ts +56 -0
  154. package/src/markdown-iteration/iteration.tsx +333 -0
  155. package/src/markdown-iteration/template-dialog.stories.tsx +63 -0
  156. package/src/markdown-iteration/template-dialog.test.tsx +104 -0
  157. package/src/markdown-iteration/template-dialog.tsx +116 -0
  158. package/src/markdown-outline/document-outline.stories.tsx +76 -0
  159. package/src/markdown-outline/document-outline.tsx +83 -0
  160. package/src/markdown-outline/index.ts +2 -0
  161. package/src/markdown-outline/markdown-outline.test.ts +49 -0
  162. package/src/markdown-outline/markdown-outline.ts +60 -0
  163. package/src/markdown-preview/code-fence.tsx +193 -0
  164. package/src/markdown-preview/index.ts +8 -0
  165. package/src/markdown-preview/markdown-preview-academic.stories.tsx +203 -0
  166. package/src/markdown-preview/markdown-preview-academic.test.tsx +257 -0
  167. package/src/markdown-preview/markdown-preview-iteration-calc.test.tsx +71 -0
  168. package/src/markdown-preview/markdown-preview-iteration-nested.test.tsx +58 -0
  169. package/src/markdown-preview/markdown-preview-iteration.stories.tsx +187 -0
  170. package/src/markdown-preview/markdown-preview-iteration.test.tsx +90 -0
  171. package/src/markdown-preview/markdown-preview-linking.test.tsx +61 -0
  172. package/src/markdown-preview/markdown-preview-transclusion.test.tsx +33 -0
  173. package/src/markdown-preview/markdown-preview.stories.tsx +108 -0
  174. package/src/markdown-preview/markdown-preview.test.tsx +411 -0
  175. package/src/markdown-preview/markdown-preview.tsx +1541 -0
  176. package/src/markdown-toolbar/index.ts +8 -0
  177. package/src/markdown-toolbar/markdown-commands.test.ts +84 -0
  178. package/src/markdown-toolbar/markdown-commands.ts +104 -0
  179. package/src/markdown-toolbar/markdown-toolbar.stories.tsx +56 -0
  180. package/src/markdown-toolbar/markdown-toolbar.tsx +263 -0
  181. package/src/markdown-workspace/focus-writing.test.ts +52 -0
  182. package/src/markdown-workspace/focus-writing.ts +38 -0
  183. package/src/markdown-workspace/index.ts +6 -0
  184. package/src/markdown-workspace/markdown-workspace.stories.tsx +688 -0
  185. package/src/markdown-workspace/markdown-workspace.test.tsx +326 -0
  186. package/src/markdown-workspace/markdown-workspace.tsx +787 -0
  187. package/src/mermaid-diagram/index.ts +1 -0
  188. package/src/mermaid-diagram/mermaid-diagram.stories.tsx +42 -0
  189. package/src/mermaid-diagram/mermaid-diagram.test.tsx +86 -0
  190. package/src/mermaid-diagram/mermaid-diagram.tsx +318 -0
  191. package/src/mermaid-diagram/mermaid-viewer.test.tsx +89 -0
  192. package/src/mermaid-diagram/mermaid-viewer.tsx +367 -0
  193. package/src/mermaid-diagram/real-parse.test.ts +42 -0
  194. package/src/mermaid-diagram/remediate.test.ts +60 -0
  195. package/src/mermaid-diagram/remediate.ts +124 -0
  196. package/src/mermaid-workspace/index.ts +1 -0
  197. package/src/mermaid-workspace/mermaid-workspace.stories.tsx +28 -0
  198. package/src/mermaid-workspace/mermaid-workspace.tsx +71 -0
  199. package/src/metric-block/index.ts +1 -0
  200. package/src/metric-block/metric-block.stories.tsx +40 -0
  201. package/src/metric-block/metric-block.tsx +14 -0
  202. package/src/monaco-workers.d.ts +10 -0
  203. package/src/prose/index.ts +14 -0
  204. package/src/prose/prose.stories.tsx +44 -0
  205. package/src/prose/prose.test.tsx +44 -0
  206. package/src/prose/prose.tsx +21 -0
  207. package/src/timeline/index.ts +18 -0
@@ -0,0 +1,385 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The brand slash-menu Milkdown plugin (the ProseMirror half).
5
+ *
6
+ * A self-owned raw ProseMirror plugin (`$prose`) — NOT `@milkdown/kit/plugin/slash`
7
+ * — so the editor pulls zero new dependencies and we keep full control of the
8
+ * interaction. It owns the STATE and the KEYBOARD; the React widget
9
+ * (slash-widget.tsx) owns the rendered popup. They share one
10
+ * {@link SlashController} (the command list + a Milkdown `Ctx` getter + the
11
+ * insert/close helpers), built once in `MarkdownEditorView` so both the plugin's
12
+ * Enter handler and the widget's click handler run a command the SAME way.
13
+ *
14
+ * Responsibilities here:
15
+ * 1. Detect a `/` typed at a textblock START or after whitespace; track
16
+ * `{ active, from, query, index }` as the user keeps typing (a literal `/`
17
+ * mid-word is never hijacked; a space ends the query).
18
+ * 2. Render the React `<SlashMenu>` as a ProseMirror WIDGET decoration at the
19
+ * caret (via the `useWidgetViewFactory()` factory passed in).
20
+ * 3. ↑/↓ move the selection, Enter inserts the active command (replacing the
21
+ * `/query` range), Esc / deleting the `/` closes.
22
+ */
23
+ import type { Ctx, MilkdownPlugin } from "@milkdown/kit/ctx";
24
+ import { $prose } from "@milkdown/kit/utils";
25
+ import type { ReactWidgetViewComponent, useWidgetViewFactory } from "@prosemirror-adapter/react";
26
+ import type { Node as ProseNode } from "@milkdown/kit/prose/model";
27
+ import type { Transaction } from "@milkdown/kit/prose/state";
28
+ import { Plugin, PluginKey } from "@milkdown/kit/prose/state";
29
+ import type { EditorView } from "@milkdown/kit/prose/view";
30
+ import { DecorationSet } from "@milkdown/kit/prose/view";
31
+
32
+ import type { IterationEditHandler } from "../../markdown-iteration/edit-context";
33
+ import { filterSlashCommands, type SlashCommand } from "./brand-slash-commands";
34
+ import { matchesKeyboardEvent } from "./shortcut";
35
+
36
+ /**
37
+ * The factory `useWidgetViewFactory()` returns: `(options) => (pos, spec?) =>
38
+ * Decoration`. We borrow the exact type from the hook so we never name the
39
+ * (un-re-exported) `@prosemirror-adapter/core` internals.
40
+ */
41
+ export type SlashWidgetFactory = ReturnType<typeof useWidgetViewFactory>;
42
+
43
+ /** Plugin state. `active === false` means the menu is closed. */
44
+ export interface SlashPluginState {
45
+ active: boolean;
46
+ /** Document position of the trigger `/` (char mode) or the caret (shortcut mode). */
47
+ from: number;
48
+ /** Text typed after the `/` (the filter query). */
49
+ query: string;
50
+ /** Index into the CURRENTLY-FILTERED list of the highlighted command. */
51
+ index: number;
52
+ /**
53
+ * How the menu was opened:
54
+ * - `"char"` — the user typed the trigger character (default); the `from`
55
+ * position holds the `/`, which is deleted when the range is inserted.
56
+ * - `"shortcut"` — opened programmatically via the keyboard shortcut; `from`
57
+ * is the bare caret position (no leading `/`), so `range` must be `null`
58
+ * on insert (insert-at-caret, delete nothing).
59
+ */
60
+ triggered: "char" | "shortcut";
61
+ }
62
+
63
+ /** The closed (inactive) plugin state. Exported for unit tests; not barreled. */
64
+ export const CLOSED: SlashPluginState = {
65
+ active: false,
66
+ from: 0,
67
+ query: "",
68
+ index: 0,
69
+ triggered: "char",
70
+ };
71
+
72
+ export const slashPluginKey = new PluginKey<SlashPluginState>("brand-slash");
73
+
74
+ /**
75
+ * The shared controller. Built once in React so the plugin (keyboard) and the
76
+ * widget (mouse) run commands identically with the live Milkdown `Ctx`. The
77
+ * `ctx` is captured by the plugin's `$prose((ctx) => …)` callback (see
78
+ * {@link brandSlashPlugin}) and read back through `getCtx()`.
79
+ */
80
+ export interface SlashController {
81
+ /** The command list driving both the menu and the keyboard. */
82
+ commands: SlashCommand[];
83
+ /** The trigger character (default `"/"`). */
84
+ trigger: string;
85
+ /**
86
+ * Optional keyboard shortcut string (e.g. `"Mod-/"`) that opens the menu
87
+ * programmatically at the caret (shortcut mode, no leading `/` in the doc).
88
+ * Parsed by `matchesKeyboardEvent` in `handleKeyDown`.
89
+ */
90
+ shortcut?: string;
91
+ /** Resolve the live Milkdown `Ctx` (set by the plugin at build time). */
92
+ getCtx: () => Ctx;
93
+ /** Internal: the plugin assigns the captured `Ctx` here. */
94
+ _ctx?: Ctx;
95
+ /**
96
+ * Programmatically open the slash menu at the current caret position.
97
+ * Dispatches the `"open"` meta — equivalent to pressing the shortcut key.
98
+ * No-op if no ProseMirror view is available yet.
99
+ */
100
+ openMenu?: (view: EditorView) => void;
101
+ /**
102
+ * Resolve the LIVE `IterationEditContext` handler (#223) — read lazily (a
103
+ * ref-backed getter) so a fresh Provider identity each render never needs the
104
+ * controller (and its editor) to be rebuilt. Set by `MarkdownEditorView`;
105
+ * returns `null`/`undefined` when the consumer hasn't wired one, in which case
106
+ * `runSlashCommand` falls back to a command's plain `run`.
107
+ */
108
+ getIterationEditHandler?: () => IterationEditHandler | null | undefined;
109
+ }
110
+
111
+ /** Create a {@link SlashController} with a settable, lazily-read `Ctx`. */
112
+ export function createSlashController(
113
+ commands: SlashCommand[],
114
+ trigger = "/",
115
+ shortcut?: string,
116
+ getIterationEditHandler?: () => IterationEditHandler | null | undefined,
117
+ ): SlashController {
118
+ const controller: SlashController = {
119
+ commands,
120
+ trigger,
121
+ shortcut,
122
+ getIterationEditHandler,
123
+ getCtx: () => {
124
+ if (!controller._ctx) {
125
+ throw new Error("Brand slash controller used before the editor Ctx was captured.");
126
+ }
127
+ return controller._ctx;
128
+ },
129
+ openMenu: (view: EditorView) => {
130
+ const pos = view.state.selection.from;
131
+ view.dispatch(
132
+ view.state.tr.setMeta(slashPluginKey, {
133
+ _open: true,
134
+ from: pos,
135
+ triggered: "shortcut",
136
+ }),
137
+ );
138
+ },
139
+ };
140
+ return controller;
141
+ }
142
+
143
+ /** Compute the `[from, to)` range covering the trigger + query (to be replaced). */
144
+ export function slashRange(state: SlashPluginState): { from: number; to: number } {
145
+ return { from: state.from, to: state.from + 1 + state.query.length };
146
+ }
147
+
148
+ /** Run a command: insert (replacing the `/query` range or at caret), then close the menu. */
149
+ export function runSlashCommand(
150
+ view: EditorView,
151
+ controller: SlashController,
152
+ state: SlashPluginState,
153
+ command: SlashCommand,
154
+ ): void {
155
+ // Char mode: replace the whole `/query` run (slashRange covers the `/` + query).
156
+ // Shortcut mode: there is no leading `/`, but any filter the user typed IS real
157
+ // document text [from, from+query.length) — replace that span so the inserted
158
+ // block lands at `from` with the typed query removed (empty query → insert at
159
+ // caret, delete nothing). NOT range:null, which would leave the query behind.
160
+ const range =
161
+ state.triggered === "shortcut"
162
+ ? { from: state.from, to: state.from + state.query.length }
163
+ : slashRange(state);
164
+ // #223: a `guided` command (currently `/iterate` `/pivot`) opens its modal
165
+ // instead of inserting a bare directive — but ONLY when the consumer has
166
+ // actually wired an `IterationEditContext` handler; with none, fall back to
167
+ // the plain `run` (today's direct-insert behaviour, unchanged).
168
+ const iterationHandler = controller.getIterationEditHandler?.();
169
+ if (command.guided && iterationHandler) {
170
+ // The guided modal doesn't dispatch an insert transaction until it SAVES
171
+ // (Cancel must leave the document untouched) — so the typed `/query` range
172
+ // has to be consumed NOW, or it is left behind as literal text forever
173
+ // when the user cancels. Delete it up front (collapsing the selection to
174
+ // `range.from`) and hand the resolver `range: null` so its eventual save
175
+ // inserts at the now-current caret rather than a stale, already-consumed span.
176
+ view.dispatch(view.state.tr.delete(range.from, range.to));
177
+ command.guided({ ctx: controller.getCtx(), range: null }, iterationHandler);
178
+ } else {
179
+ // The command's `run` dispatches its own insert transaction via the view;
180
+ // then close the (now stale) menu. If the command already removed the
181
+ // range, the close meta is harmless.
182
+ command.run({ ctx: controller.getCtx(), range });
183
+ }
184
+ if (slashPluginKey.getState(view.state)?.active) {
185
+ view.dispatch(view.state.tr.setMeta(slashPluginKey, "close"));
186
+ }
187
+ view.focus();
188
+ }
189
+
190
+ /** A `/` is a valid trigger only at a textblock start or right after whitespace. */
191
+ function triggerAllowed(doc: ProseNode, triggerPos: number): boolean {
192
+ const $pos = doc.resolve(triggerPos);
193
+ if (!$pos.parent.isTextblock) return false;
194
+ if ($pos.parent.type.spec.code) return false;
195
+ if ($pos.parentOffset === 0) return true; // start of block
196
+ const before = $pos.parent.textBetween(Math.max(0, $pos.parentOffset - 1), $pos.parentOffset);
197
+ return /\s/.test(before);
198
+ }
199
+
200
+ /**
201
+ * Recompute open/query/index from a transaction. Opens when the trigger is typed
202
+ * in an allowed spot; tracks the query; closes when the `/` is removed, the caret
203
+ * leaves the trigger run/block, the selection is non-empty, or a space is typed.
204
+ *
205
+ * Also handles the `_open` meta (programmatic open via shortcut / `openMenu()`):
206
+ * sets `triggered: "shortcut"` so `runSlashCommand` uses `range: null`.
207
+ */
208
+ export function nextState(
209
+ prev: SlashPluginState,
210
+ tr: Transaction,
211
+ trigger: string,
212
+ ): SlashPluginState {
213
+ const meta = tr.getMeta(slashPluginKey) as
214
+ | (Partial<SlashPluginState> & { _open?: boolean })
215
+ | "close"
216
+ | undefined;
217
+
218
+ if (meta === "close") return CLOSED;
219
+
220
+ if (meta && typeof meta === "object") {
221
+ // Programmatic open (shortcut / openMenu): open unconditionally at caret.
222
+ if (meta._open) {
223
+ return {
224
+ active: true,
225
+ from: (meta as { from: number }).from,
226
+ query: "",
227
+ index: 0,
228
+ triggered: "shortcut",
229
+ };
230
+ }
231
+ // Nav meta (index change) only applies while still active.
232
+ return prev.active ? { ...prev, ...meta } : prev;
233
+ }
234
+
235
+ const sel = tr.selection;
236
+ if (!sel.empty) return prev.active ? CLOSED : prev;
237
+
238
+ const pos = sel.from;
239
+ const $pos = tr.doc.resolve(pos);
240
+
241
+ if (prev.active) {
242
+ if (pos <= prev.from) return CLOSED;
243
+ const start = tr.doc.resolve(prev.from);
244
+ if (start.parent !== $pos.parent) return CLOSED;
245
+
246
+ if (prev.triggered === "shortcut") {
247
+ // In shortcut mode there is no leading trigger char — read the query as
248
+ // text typed AFTER the caret-open position. Skip the triggerChar check.
249
+ const query = $pos.parent.textBetween(start.parentOffset, $pos.parentOffset);
250
+ if (/\s/.test(query)) return CLOSED;
251
+ const index = query === prev.query ? prev.index : 0;
252
+ return { active: true, from: prev.from, query, index, triggered: "shortcut" };
253
+ }
254
+
255
+ // Char mode: the first character at `from` must still be the trigger.
256
+ const triggerChar = start.parent.textBetween(start.parentOffset, start.parentOffset + 1);
257
+ if (triggerChar !== trigger) return CLOSED;
258
+ const query = $pos.parent.textBetween(start.parentOffset + 1, $pos.parentOffset);
259
+ if (/\s/.test(query)) return CLOSED;
260
+ const index = query === prev.query ? prev.index : 0;
261
+ return { active: true, from: prev.from, query, index, triggered: "char" };
262
+ }
263
+
264
+ if (!tr.docChanged) return prev;
265
+ const justTyped = tr.doc.textBetween(Math.max(0, pos - 1), pos);
266
+ if (justTyped !== trigger) return prev;
267
+ const triggerPos = pos - 1;
268
+ if (!triggerAllowed(tr.doc, triggerPos)) return prev;
269
+ return { active: true, from: triggerPos, query: "", index: 0, triggered: "char" };
270
+ }
271
+
272
+ /** Options for {@link brandSlashPlugin}. */
273
+ export interface BrandSlashPluginOptions {
274
+ /** The widget factory from `useWidgetViewFactory()` (created under the adapter provider). */
275
+ widgetFactory: SlashWidgetFactory;
276
+ /** The React component rendered as the widget (built with the controller). */
277
+ widgetComponent: ReactWidgetViewComponent;
278
+ /** The shared controller (commands + ctx getter + trigger). */
279
+ controller: SlashController;
280
+ }
281
+
282
+ /**
283
+ * Build the slash plugin. The widget factory + component + controller are created
284
+ * in React (so the factory lives under `<ProsemirrorAdapterProvider>` and the
285
+ * component closes over the controller). Returns a Milkdown plugin to `.use()`.
286
+ */
287
+ export function brandSlashPlugin(options: BrandSlashPluginOptions): MilkdownPlugin {
288
+ const { widgetFactory, widgetComponent, controller } = options;
289
+ const trigger = controller.trigger || "/";
290
+
291
+ return $prose((ctx) => {
292
+ // Capture the live editor Ctx so command `run`s (keyboard + mouse) can
293
+ // resolve NodeTypes and the view.
294
+ controller._ctx = ctx;
295
+ return new Plugin<SlashPluginState>({
296
+ key: slashPluginKey,
297
+ state: {
298
+ init: () => CLOSED,
299
+ apply: (tr, value) => nextState(value, tr, trigger),
300
+ },
301
+ props: {
302
+ decorations: (state) => {
303
+ const s = slashPluginKey.getState(state);
304
+ if (!s?.active) return DecorationSet.empty;
305
+ const factory = widgetFactory({ component: widgetComponent, as: "span" });
306
+ // Anchor: in char mode `from + 1` skips the `/`; in shortcut mode
307
+ // anchor at `from` (the bare caret — no `/` to step over).
308
+ const anchor = s.triggered === "shortcut" ? s.from : s.from + 1;
309
+ // `spec` goes on the factory CALL (the Decoration.widget spec), not the
310
+ // factory options. After the caret; don't let the widget disturb the
311
+ // editor selection. Key on from+query+index so ProseMirror re-renders the
312
+ // widget when the filter OR the highlighted row changes (decoration
313
+ // diffing reuses a widget with an unchanged key, which would freeze the
314
+ // ↑/↓ highlight).
315
+ const decoration = factory(anchor, {
316
+ side: 1,
317
+ ignoreSelection: true,
318
+ key: `brand-slash:${s.from}:${s.query}:${s.index}`,
319
+ });
320
+ return DecorationSet.create(state.doc, [decoration]);
321
+ },
322
+ handleKeyDown: (view, event) => {
323
+ // --- Shortcut open (BEFORE the active guard) ---
324
+ // If a shortcut is configured and this event matches it, open the menu
325
+ // at the current caret position in shortcut mode. This runs before keymaps
326
+ // so it beats commonmark/gfm bindings (the exit-keymap.ts precedent).
327
+ if (controller.shortcut && matchesKeyboardEvent(controller.shortcut, event)) {
328
+ const pos = view.state.selection.from;
329
+ view.dispatch(
330
+ view.state.tr.setMeta(slashPluginKey, {
331
+ _open: true,
332
+ from: pos,
333
+ triggered: "shortcut",
334
+ }),
335
+ );
336
+ event.preventDefault();
337
+ return true;
338
+ }
339
+
340
+ const s = slashPluginKey.getState(view.state);
341
+ if (!s?.active) return false;
342
+ const filtered = filterSlashCommands(controller.commands, s.query);
343
+
344
+ if (event.key === "Escape") {
345
+ view.dispatch(view.state.tr.setMeta(slashPluginKey, "close"));
346
+ event.preventDefault();
347
+ return true;
348
+ }
349
+ if (filtered.length === 0) return false;
350
+
351
+ if (event.key === "ArrowDown") {
352
+ const index = (s.index + 1) % filtered.length;
353
+ view.dispatch(view.state.tr.setMeta(slashPluginKey, { index }));
354
+ event.preventDefault();
355
+ return true;
356
+ }
357
+ if (event.key === "ArrowUp") {
358
+ const index = (s.index - 1 + filtered.length) % filtered.length;
359
+ view.dispatch(view.state.tr.setMeta(slashPluginKey, { index }));
360
+ event.preventDefault();
361
+ return true;
362
+ }
363
+ if (event.key === "Enter") {
364
+ const command = filtered[Math.min(s.index, filtered.length - 1)];
365
+ if (command) {
366
+ runSlashCommand(view, controller, s, command);
367
+ event.preventDefault();
368
+ return true;
369
+ }
370
+ }
371
+ if (event.key === "Tab") {
372
+ // Tab also selects the active command (common in Notion-style menus).
373
+ const command = filtered[Math.min(s.index, filtered.length - 1)];
374
+ if (command) {
375
+ runSlashCommand(view, controller, s, command);
376
+ event.preventDefault();
377
+ return true;
378
+ }
379
+ }
380
+ return false;
381
+ },
382
+ },
383
+ });
384
+ }) as unknown as MilkdownPlugin;
385
+ }
@@ -0,0 +1,107 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Brand slash-menu — public surface for the WYSIWYG markdown editor.
5
+ *
6
+ * Typing `/` at a block start (or after whitespace) opens a branded command menu
7
+ * that inserts the brand `:::` directives + basic blocks live in the editor. The
8
+ * load-bearing pieces:
9
+ * - `brand-slash-commands` — the typed, tree-shakeable command registry.
10
+ * - `insert-directive` — the pure ProseMirror insertion commands (unit-tested).
11
+ * - `brand-slash-plugin` — the `$prose` plugin (state + keyboard).
12
+ * - `slash-widget` / `slash-menu` — the React widget + the branded popup.
13
+ *
14
+ * `brandSlashViewPlugins(widgetFactory, options)` wires it all together — call it
15
+ * with the factory from `useWidgetViewFactory()` (so it runs under
16
+ * `<ProsemirrorAdapterProvider>`), exactly like `directiveViewPlugins`.
17
+ */
18
+ import type { MilkdownPlugin } from "@milkdown/kit/ctx";
19
+
20
+ import type { IterationEditHandler } from "../../markdown-iteration/edit-context";
21
+ import { BRAND_SLASH_COMMANDS, type SlashCommand } from "./brand-slash-commands";
22
+ import {
23
+ brandSlashPlugin,
24
+ createSlashController,
25
+ type SlashWidgetFactory,
26
+ } from "./brand-slash-plugin";
27
+ import { DEFAULT_SLASH_SHORTCUT } from "./shortcut";
28
+ import { createSlashWidget } from "./slash-widget";
29
+
30
+ /** Options for {@link brandSlashViewPlugins}. */
31
+ export interface BrandSlashViewOptions {
32
+ /** Override the default command list. */
33
+ commands?: SlashCommand[];
34
+ /** The trigger character. Defaults to `"/"`. */
35
+ trigger?: string;
36
+ /**
37
+ * Keyboard shortcut that opens the menu at the caret in BOTH panes (#271).
38
+ * Defaults to `DEFAULT_SLASH_SHORTCUT` (`"Mod-Shift-O"`). Set to `undefined` or
39
+ * an empty string to disable the shortcut binding.
40
+ */
41
+ shortcut?: string;
42
+ /**
43
+ * Resolve the live `IterationEditContext` handler (#223, internal —
44
+ * `MarkdownEditorView` wires this from `useContext`). When present, a
45
+ * `guided` command (`/iterate` `/pivot`) opens its modal instead of a bare
46
+ * insert. Not part of the public `slashMenu` prop surface.
47
+ */
48
+ getIterationEditHandler?: () => IterationEditHandler | null | undefined;
49
+ }
50
+
51
+ /**
52
+ * Build the slash-menu Milkdown plugins. Call with the widget factory from
53
+ * `useWidgetViewFactory()` (must run inside a `<ProsemirrorAdapterProvider>`),
54
+ * then `.use()` the result on the editor.
55
+ */
56
+ export function brandSlashViewPlugins(
57
+ widgetFactory: SlashWidgetFactory,
58
+ options: BrandSlashViewOptions = {},
59
+ ): MilkdownPlugin[] {
60
+ const commands = options.commands ?? BRAND_SLASH_COMMANDS;
61
+ const shortcut = "shortcut" in options ? options.shortcut : DEFAULT_SLASH_SHORTCUT;
62
+ const controller = createSlashController(
63
+ commands,
64
+ options.trigger ?? "/",
65
+ shortcut,
66
+ options.getIterationEditHandler,
67
+ );
68
+ const widgetComponent = createSlashWidget(controller);
69
+ return [brandSlashPlugin({ widgetFactory, widgetComponent, controller })];
70
+ }
71
+
72
+ export {
73
+ BRAND_SLASH_COMMANDS,
74
+ filterSlashCommands,
75
+ groupSlashCommands,
76
+ type SlashCommand,
77
+ type SlashInsertContext,
78
+ type SourceSlashContext,
79
+ type BasicBlockId,
80
+ } from "./brand-slash-commands";
81
+ export {
82
+ insertBrandDirective,
83
+ insertBasicBlock,
84
+ insertCalcFence,
85
+ resolveBrandInsert,
86
+ resolveBasicInsert,
87
+ resolveCalcInsert,
88
+ isContainerDirective,
89
+ isLeafDirective,
90
+ CALC_FENCE_SEED,
91
+ type InsertableDirective,
92
+ type SlashRange,
93
+ } from "./insert-directive";
94
+ export { SlashMenu, slashOptionId, type SlashMenuProps } from "./slash-menu";
95
+ export {
96
+ slashPluginKey,
97
+ slashRange,
98
+ createSlashController,
99
+ type SlashController,
100
+ type SlashPluginState,
101
+ type SlashWidgetFactory,
102
+ } from "./brand-slash-plugin";
103
+ // NOTE: `MonacoSlashMenu` is deliberately NOT re-exported here. It pulls the
104
+ // Monaco runtime (via `markdown-commands`), and this barrel is imported by the
105
+ // Milkdown `MarkdownEditor` graph, which must stay Monaco-free. It is exported
106
+ // from the heavy `@elabs-ai/components-editor/markdown` subpath instead (which already pulls
107
+ // Monaco via `MarkdownWorkspace`).