@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,120 @@
1
+ "use client";
2
+
3
+ import {
4
+ ContextMenu,
5
+ ContextMenuContent,
6
+ ContextMenuItem,
7
+ ContextMenuSeparator,
8
+ ContextMenuShortcut,
9
+ ContextMenuTrigger,
10
+ } from "@elabs-ai/components-ui";
11
+ import type * as Monaco from "monaco-editor";
12
+ import { type ReactNode } from "react";
13
+
14
+ export interface EditorContextMenuProps {
15
+ /** The Monaco editor instance to act on (null until mounted). */
16
+ editor: Monaco.editor.IStandaloneCodeEditor | null;
17
+ /** Hide editing items (cut/paste/format) for read-only editors. */
18
+ readOnly?: boolean;
19
+ /** The editor element wrapped as the right-click trigger. */
20
+ children: ReactNode;
21
+ }
22
+
23
+ /**
24
+ * Replaces Monaco's built-in context menu with brand-ui's `ContextMenu`, wired
25
+ * to the editor instance. This is the "reuse our components instead of Monaco's
26
+ * built-in ones" path for the right-click menu — set `contextMenu="brand"` on
27
+ * `CodeEditor` (the default) and it disables Monaco's menu and renders this.
28
+ *
29
+ * Clipboard ops use the async Clipboard API + `executeEdits` (reliable in the
30
+ * browser); other entries delegate to the editor's own actions.
31
+ */
32
+ export function EditorContextMenu({ editor, readOnly = false, children }: EditorContextMenuProps) {
33
+ const selection = () => {
34
+ if (!editor) return null;
35
+ const model = editor.getModel();
36
+ const sel = editor.getSelection();
37
+ return model && sel ? { editor, model, sel } : null;
38
+ };
39
+
40
+ const copy = async () => {
41
+ const ctx = selection();
42
+ if (!ctx) return;
43
+ const text = ctx.model.getValueInRange(ctx.sel);
44
+ if (text && typeof navigator !== "undefined" && navigator.clipboard) {
45
+ await navigator.clipboard.writeText(text);
46
+ }
47
+ ctx.editor.focus();
48
+ };
49
+
50
+ const cut = async () => {
51
+ const ctx = selection();
52
+ if (!ctx) return;
53
+ const text = ctx.model.getValueInRange(ctx.sel);
54
+ if (text && typeof navigator !== "undefined" && navigator.clipboard) {
55
+ await navigator.clipboard.writeText(text);
56
+ ctx.editor.executeEdits("brand-cut", [{ range: ctx.sel, text: "", forceMoveMarkers: true }]);
57
+ }
58
+ ctx.editor.focus();
59
+ };
60
+
61
+ const paste = async () => {
62
+ const ctx = selection();
63
+ if (!ctx || typeof navigator === "undefined" || !navigator.clipboard) return;
64
+ const text = await navigator.clipboard.readText();
65
+ ctx.editor.executeEdits("brand-paste", [{ range: ctx.sel, text, forceMoveMarkers: true }]);
66
+ ctx.editor.focus();
67
+ };
68
+
69
+ const selectAll = () => {
70
+ const ctx = selection();
71
+ if (!ctx) return;
72
+ ctx.editor.setSelection(ctx.model.getFullModelRange());
73
+ ctx.editor.focus();
74
+ };
75
+
76
+ const runAction = (id: string) => {
77
+ editor?.focus();
78
+ void editor?.getAction(id)?.run();
79
+ };
80
+
81
+ return (
82
+ <ContextMenu>
83
+ <ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
84
+ <ContextMenuContent className="w-52">
85
+ {!readOnly ? (
86
+ <ContextMenuItem onSelect={() => void cut()}>
87
+ Cut
88
+ <ContextMenuShortcut>⌘X</ContextMenuShortcut>
89
+ </ContextMenuItem>
90
+ ) : null}
91
+ <ContextMenuItem onSelect={() => void copy()}>
92
+ Copy
93
+ <ContextMenuShortcut>⌘C</ContextMenuShortcut>
94
+ </ContextMenuItem>
95
+ {!readOnly ? (
96
+ <ContextMenuItem onSelect={() => void paste()}>
97
+ Paste
98
+ <ContextMenuShortcut>⌘V</ContextMenuShortcut>
99
+ </ContextMenuItem>
100
+ ) : null}
101
+ <ContextMenuSeparator />
102
+ <ContextMenuItem onSelect={selectAll}>
103
+ Select all
104
+ <ContextMenuShortcut>⌘A</ContextMenuShortcut>
105
+ </ContextMenuItem>
106
+ {!readOnly ? (
107
+ <ContextMenuItem onSelect={() => runAction("editor.action.formatDocument")}>
108
+ Format document
109
+ <ContextMenuShortcut>⇧⌥F</ContextMenuShortcut>
110
+ </ContextMenuItem>
111
+ ) : null}
112
+ <ContextMenuSeparator />
113
+ <ContextMenuItem onSelect={() => runAction("editor.action.quickCommand")}>
114
+ Command palette
115
+ <ContextMenuShortcut>F1</ContextMenuShortcut>
116
+ </ContextMenuItem>
117
+ </ContextMenuContent>
118
+ </ContextMenu>
119
+ );
120
+ }
@@ -0,0 +1 @@
1
+ export { EditorContextMenu, type EditorContextMenuProps } from "./editor-context-menu";
@@ -0,0 +1,83 @@
1
+ "use client";
2
+
3
+ import {
4
+ Select,
5
+ SelectContent,
6
+ SelectItem,
7
+ SelectTrigger,
8
+ SelectValue,
9
+ } from "@elabs-ai/components-ui";
10
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
11
+ import { forwardRef, type HTMLAttributes, type ReactNode } from "react";
12
+ import { CopyButton } from "../copy-button";
13
+ import { EDITOR_LANGUAGES, type EditorLanguage } from "../lib/languages";
14
+
15
+ export interface EditorToolbarProps extends HTMLAttributes<HTMLDivElement> {
16
+ /** Filename shown on the left (e.g. "index.ts"). */
17
+ filename?: ReactNode;
18
+ /** Active language id; shows a language picker when `onLanguageChange` is set. */
19
+ language?: string;
20
+ /** Called when the user picks a language. Omit to hide the picker. */
21
+ onLanguageChange?: (language: string) => void;
22
+ /** Languages offered in the picker. Defaults to {@link EDITOR_LANGUAGES}. */
23
+ languages?: EditorLanguage[];
24
+ /** Text copied by the copy button. Omit to hide the button. */
25
+ copyValue?: string;
26
+ /** Extra actions rendered before the copy button (brand-ui buttons, etc.). */
27
+ actions?: ReactNode;
28
+ }
29
+
30
+ /**
31
+ * Brand-ui chrome for the editors — the part that genuinely reuses our
32
+ * components. Renders a filename label, an optional language `Select` and a copy
33
+ * `Button`. The editing surface itself is Monaco; this is the frame around it.
34
+ */
35
+ export const EditorToolbar = forwardRef<HTMLDivElement, EditorToolbarProps>(function EditorToolbar(
36
+ {
37
+ filename,
38
+ language,
39
+ onLanguageChange,
40
+ languages = EDITOR_LANGUAGES,
41
+ copyValue,
42
+ actions,
43
+ className,
44
+ ...props
45
+ },
46
+ ref,
47
+ ) {
48
+ return (
49
+ <div
50
+ ref={ref}
51
+ className={cn(
52
+ "flex h-10 shrink-0 items-center gap-2 border-b border-border bg-surface px-3",
53
+ className,
54
+ )}
55
+ {...props}
56
+ >
57
+ {filename != null ? (
58
+ <span className="truncate text-sm font-medium text-foreground">{filename}</span>
59
+ ) : null}
60
+
61
+ <div className="ml-auto flex items-center gap-1.5">
62
+ {actions}
63
+
64
+ {onLanguageChange ? (
65
+ <Select value={language} onValueChange={onLanguageChange}>
66
+ <SelectTrigger size="sm" className="h-7 w-[140px]" aria-label="Language">
67
+ <SelectValue placeholder="Language" />
68
+ </SelectTrigger>
69
+ <SelectContent>
70
+ {languages.map((l) => (
71
+ <SelectItem key={l.id} value={l.id}>
72
+ {l.label}
73
+ </SelectItem>
74
+ ))}
75
+ </SelectContent>
76
+ </Select>
77
+ ) : null}
78
+
79
+ {copyValue != null ? <CopyButton value={copyValue} /> : null}
80
+ </div>
81
+ </div>
82
+ );
83
+ });
@@ -0,0 +1 @@
1
+ export { EditorToolbar, type EditorToolbarProps } from "./editor-toolbar";
package/src/index.ts ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * @elabs-ai/components-editor — a token-themed Monaco (VS Code) editor wrapped as brand-ui
3
+ * components.
4
+ *
5
+ * Monaco is a self-rendering editor engine; this package wraps it the way
6
+ * @elabs-ai/components-flow wraps React Flow and @elabs-ai/components-data wraps TanStack. The editing
7
+ * surface + widgets are Monaco's, recolored from the active brand theme via the
8
+ * theming bridge; the chrome (toolbar, file tabs, copy button) is brand-ui.
9
+ *
10
+ * Worker setup (for completions/diagnostics) — import ONCE at the app entry:
11
+ * import "@elabs-ai/components-editor/monaco-environment"; // Vite apps
12
+ */
13
+ export {
14
+ CodeEditor,
15
+ type CodeEditorProps,
16
+ type MonacoCodeEditor,
17
+ type EditorAction,
18
+ } from "./code-editor";
19
+ export { DiffEditor, type DiffEditorProps, type MonacoDiffEditor } from "./diff-editor";
20
+ export {
21
+ MarkdownEditor,
22
+ type MarkdownEditorProps,
23
+ type MarkdownEditorHandle,
24
+ type EmbedAssetFn,
25
+ } from "./markdown-editor";
26
+ export {
27
+ CodeWorkspace,
28
+ type CodeWorkspaceHandle,
29
+ type CodeWorkspaceProps,
30
+ type EditorFile,
31
+ } from "./code-workspace";
32
+ export {
33
+ monacoContentAccess,
34
+ type EditorContentAccess,
35
+ type EditorSelection,
36
+ } from "./lib/editor-content-access";
37
+ export { EditorToolbar, type EditorToolbarProps } from "./editor-toolbar";
38
+ export { EditorContextMenu, type EditorContextMenuProps } from "./editor-context-menu";
39
+ export { CopyButton, type CopyButtonProps } from "./copy-button";
40
+
41
+ // Theming bridge + language helpers (advanced consumers / custom chrome).
42
+ export { applyBrandTheme, buildBrandThemeData, brandThemeId } from "./lib/monaco-theme-bridge";
43
+ export { useDataTheme, type DataThemeState } from "./lib/use-data-theme";
44
+ export { EDITOR_LANGUAGES, languageLabel, type EditorLanguage } from "./lib/languages";
45
+
46
+ // Re-export the monaco namespace so consumers can build custom editors / wire
47
+ // commands without adding a direct dependency.
48
+ export * as monaco from "monaco-editor";
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Unit tests for the Monaco registration lifecycle (#283) —
3
+ * `attachCompletionsMonaco`'s refcount/dispose behavior + the per-model
4
+ * scoping that keeps suggestions out of markdown models `@elabs-ai/components-editor` never
5
+ * attached. Monaco can't render in jsdom, so `monaco-editor` is mocked (the
6
+ * `markdown-workspace.test.tsx` / `calc-editor-monaco.ts` precedent); the real
7
+ * suggest-widget UI is verified via `test-storybook`.
8
+ *
9
+ * These tests run as ONE ordered sequence (not `vi.resetModules()` per test) so
10
+ * they exercise the REAL module-singleton refcount across "two workspaces
11
+ * mounted" the same way the app would: attach, attach again, detach, detach —
12
+ * asserting `registerCompletionItemProvider` is called exactly once per live
13
+ * span and disposed only once the last consumer detaches.
14
+ */
15
+ import { describe, expect, it, vi } from "vitest";
16
+
17
+ const { registerCompletionItemProvider } = vi.hoisted(() => ({
18
+ registerCompletionItemProvider: vi.fn(),
19
+ }));
20
+
21
+ vi.mock("monaco-editor", () => ({
22
+ languages: {
23
+ registerCompletionItemProvider,
24
+ CompletionItemKind: { Text: 18 },
25
+ },
26
+ }));
27
+
28
+ import { attachCompletionsMonaco } from "./editor-completions-monaco";
29
+ import type { EditorCompletionProvider } from "./editor-completions";
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Minimal Monaco editor mock — mirrors the shape used by
33
+ // markdown-workspace.test.tsx, extended with a mutable model + trigger().
34
+ // ---------------------------------------------------------------------------
35
+
36
+ function makeModel(id: string, value = "", lineText = "") {
37
+ return {
38
+ id,
39
+ getValue: vi.fn(() => value),
40
+ getLineContent: vi.fn(() => lineText),
41
+ };
42
+ }
43
+
44
+ function makeEditor(initialModel: ReturnType<typeof makeModel>) {
45
+ let model = initialModel;
46
+ const contentHandlers: Array<(e: { changes: { text: string }[] }) => void> = [];
47
+ const modelHandlers: Array<() => void> = [];
48
+ const disposeContent = vi.fn();
49
+ const disposeModel = vi.fn();
50
+
51
+ return {
52
+ getModel: vi.fn(() => model),
53
+ setModel: (m: ReturnType<typeof makeModel>) => {
54
+ model = m;
55
+ },
56
+ onDidChangeModelContent: vi.fn((cb: (e: { changes: { text: string }[] }) => void) => {
57
+ contentHandlers.push(cb);
58
+ return { dispose: disposeContent };
59
+ }),
60
+ onDidChangeModel: vi.fn((cb: () => void) => {
61
+ modelHandlers.push(cb);
62
+ return { dispose: disposeModel };
63
+ }),
64
+ trigger: vi.fn(),
65
+ fireContentChange: (text: string) =>
66
+ contentHandlers.forEach((cb) => cb({ changes: [{ text }] })),
67
+ fireModelChange: () => modelHandlers.forEach((cb) => cb()),
68
+ disposeContent,
69
+ disposeModel,
70
+ };
71
+ }
72
+
73
+ const wikilinkProvider: EditorCompletionProvider = {
74
+ id: "wikilink",
75
+ triggerCharacters: ["["],
76
+ provide: () => [{ label: "note-one", insertText: "note-one]]" }],
77
+ };
78
+
79
+ // NOTE: no `beforeEach` mock reset here — this suite is intentionally ONE
80
+ // ordered sequence sharing `registerCompletionItemProvider`'s call history, to
81
+ // exercise the real module-singleton refcount across "two workspaces mounted"
82
+ // exactly like the app would (see file doc).
83
+ describe("attachCompletionsMonaco — refcount + dispose lifecycle", () => {
84
+ const registrationDispose = vi.fn();
85
+ registerCompletionItemProvider.mockReturnValue({ dispose: registrationDispose });
86
+
87
+ const modelA = makeModel("A", "[[no", "[[no");
88
+ const editorA = makeEditor(modelA);
89
+ const modelB = makeModel("B", "", "");
90
+ const editorB = makeEditor(modelB);
91
+
92
+ let detachA: () => void;
93
+ let detachB: () => void;
94
+ // Mutable so the "rebuilt provider list" test can swap it out from UNDER the
95
+ // already-attached editorA — exactly what a re-rendered `completions` prop
96
+ // (a fresh array identity) does, read live via this same getter.
97
+ let editorAProviders: EditorCompletionProvider[] = [wikilinkProvider];
98
+
99
+ it("registers the global provider exactly once for the first attach", () => {
100
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
101
+ detachA = attachCompletionsMonaco(editorA as any, () => editorAProviders);
102
+ expect(registerCompletionItemProvider).toHaveBeenCalledTimes(1);
103
+ expect(registerCompletionItemProvider).toHaveBeenCalledWith("markdown", expect.any(Object));
104
+ });
105
+
106
+ it("a second attach (a second mounted workspace) does NOT re-register", () => {
107
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
108
+ detachB = attachCompletionsMonaco(editorB as any, () => undefined);
109
+ expect(registerCompletionItemProvider).toHaveBeenCalledTimes(1);
110
+ });
111
+
112
+ it("scopes suggestions to models THIS module attached — modelA gets suggestions, an unattached model doesn't", async () => {
113
+ const provideCompletionItems = registerCompletionItemProvider.mock.calls[0]?.[1]
114
+ .provideCompletionItems as (
115
+ model: unknown,
116
+ position: unknown,
117
+ ) => Promise<{ suggestions: unknown[] }>;
118
+ const position = { lineNumber: 1, column: 5 };
119
+
120
+ const resultA = await provideCompletionItems(modelA, position);
121
+ expect(resultA.suggestions).toHaveLength(1);
122
+ expect((resultA.suggestions[0] as { insertText: string }).insertText).toBe("note-one]]");
123
+
124
+ const unattached = makeModel("unattached", "[[no", "[[no");
125
+ const resultUnattached = await provideCompletionItems(unattached, position);
126
+ expect(resultUnattached.suggestions).toHaveLength(0);
127
+ });
128
+
129
+ it("reads a rebuilt provider list live — no re-registration needed", async () => {
130
+ const provideCompletionItems = registerCompletionItemProvider.mock.calls[0]?.[1]
131
+ .provideCompletionItems as (
132
+ model: unknown,
133
+ position: unknown,
134
+ ) => Promise<{ suggestions: unknown[] }>;
135
+ const position = { lineNumber: 1, column: 5 };
136
+
137
+ const before = await provideCompletionItems(modelA, position);
138
+ expect(before.suggestions).toHaveLength(1);
139
+
140
+ // Rebuild the candidate list (a fresh array identity, like a re-rendered
141
+ // `completions` prop) WITHOUT calling attach again — the SAME attach from
142
+ // the first test reads this live through the `editorAProviders` closure.
143
+ editorAProviders = [
144
+ wikilinkProvider,
145
+ { id: "second", triggerCharacters: ["["], provide: () => [{ label: "x", insertText: "x" }] },
146
+ ];
147
+ const after = await provideCompletionItems(modelA, position);
148
+ expect(after.suggestions).toHaveLength(2);
149
+ expect(registerCompletionItemProvider).toHaveBeenCalledTimes(1);
150
+ });
151
+
152
+ it("force-opens the suggest widget when a typed char matches a trigger character", () => {
153
+ editorA.fireContentChange("[");
154
+ expect(editorA.trigger).toHaveBeenCalledWith(
155
+ "brand-completions",
156
+ "editor.action.triggerSuggest",
157
+ {},
158
+ );
159
+ });
160
+
161
+ it("does NOT force-open the suggest widget for a non-trigger character", () => {
162
+ editorA.trigger.mockClear();
163
+ editorA.fireContentChange("x");
164
+ expect(editorA.trigger).not.toHaveBeenCalled();
165
+ });
166
+
167
+ it("moving to a new model re-scopes the registry (old model drops out)", async () => {
168
+ const provideCompletionItems = registerCompletionItemProvider.mock.calls[0]?.[1]
169
+ .provideCompletionItems as (
170
+ model: unknown,
171
+ position: unknown,
172
+ ) => Promise<{ suggestions: unknown[] }>;
173
+ const position = { lineNumber: 1, column: 5 };
174
+
175
+ const modelA2 = makeModel("A2", "[[no", "[[no");
176
+ editorA.setModel(modelA2);
177
+ editorA.fireModelChange();
178
+
179
+ const resultOld = await provideCompletionItems(modelA, position);
180
+ expect(resultOld.suggestions).toHaveLength(0);
181
+ // 2 providers by this point (the previous test rebuilt `editorAProviders`
182
+ // to include a second one) — the exact count isn't the point, only that
183
+ // the NEW model is now scoped in.
184
+ const resultNew = await provideCompletionItems(modelA2, position);
185
+ expect(resultNew.suggestions.length).toBeGreaterThan(0);
186
+ });
187
+
188
+ it("detaching a NON-last consumer does not dispose the global registration", () => {
189
+ detachB();
190
+ expect(registrationDispose).not.toHaveBeenCalled();
191
+ });
192
+
193
+ it("detaching the LAST consumer disposes the global registration (no leak)", () => {
194
+ detachA();
195
+ expect(registrationDispose).toHaveBeenCalledTimes(1);
196
+ });
197
+
198
+ it("a later attach re-registers fresh once refcount has dropped to zero", () => {
199
+ const modelC = makeModel("C", "", "");
200
+ const editorC = makeEditor(modelC);
201
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
202
+ const detachC = attachCompletionsMonaco(editorC as any, () => [wikilinkProvider]);
203
+ expect(registerCompletionItemProvider).toHaveBeenCalledTimes(2);
204
+ detachC();
205
+ });
206
+ });
@@ -0,0 +1,127 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Monaco registration lifecycle for the declarative completion-provider API
5
+ * (#283) — the engine that backs the `completions` prop on `MarkdownWorkspace`.
6
+ *
7
+ * `monaco.languages.registerCompletionItemProvider` is GLOBAL PER LANGUAGE, not
8
+ * per editor instance (the root cause #283 exists to fix — see the issue). So
9
+ * this module owns a single, REFCOUNTED registration for the "markdown"
10
+ * language: the first `attachCompletionsMonaco` call registers it, each
11
+ * subsequent call (a second mounted `MarkdownWorkspace`) bumps the refcount,
12
+ * and the registration is disposed ONLY when the LAST attached editor's
13
+ * disposer runs — no leak, no double-registration (#283 acceptance).
14
+ *
15
+ * Suggestions are scoped to the models THIS module attached (a `REGISTRY` keyed
16
+ * by `ITextModel`) — any other "markdown" model in the app (one the host built
17
+ * itself, outside `@elabs-ai/components-editor`) is ignored, never suggested into.
18
+ *
19
+ * `getProviders` is read fresh from the registry on every `provideCompletionItems`
20
+ * call — NOT captured as a closure at registration time — so a rebuilt provider
21
+ * list (a new `completions` array identity from React state/props) is picked up
22
+ * WITHOUT re-registering (#283 acceptance). Mirrors the calc layer's
23
+ * `REGISTRY`/`getHooks` pattern in `calc-block/calc-editor-monaco.ts`.
24
+ */
25
+ import * as monaco from "monaco-editor";
26
+
27
+ import {
28
+ collectCompletions,
29
+ resolveReplaceRange,
30
+ type EditorCompletionProvider,
31
+ } from "./editor-completions";
32
+
33
+ /** Resolver of the latest provider list for a model (read fresh on every call). */
34
+ type ProvidersGetter = () => EditorCompletionProvider[] | undefined;
35
+
36
+ /** Per-model provider registry — scopes suggestions to OUR editor instances. */
37
+ const REGISTRY = new Map<monaco.editor.ITextModel, ProvidersGetter>();
38
+
39
+ let refCount = 0;
40
+ let registration: monaco.IDisposable | null = null;
41
+
42
+ /** Register the "markdown" completion provider once (idempotent while refs > 0). */
43
+ function ensureRegistered(): void {
44
+ if (registration) return;
45
+ registration = monaco.languages.registerCompletionItemProvider("markdown", {
46
+ provideCompletionItems(model, position) {
47
+ const getProviders = REGISTRY.get(model);
48
+ if (!getProviders) return { suggestions: [] };
49
+ const providers = getProviders();
50
+ if (!providers || providers.length === 0) return { suggestions: [] };
51
+ const lineText = model.getLineContent(position.lineNumber);
52
+ const ctx = {
53
+ source: model.getValue(),
54
+ line: position.lineNumber,
55
+ column: position.column,
56
+ lineText,
57
+ };
58
+ return collectCompletions(providers, ctx).then((matches) => ({
59
+ suggestions: matches.map(({ provider, item }) => ({
60
+ label: item.label,
61
+ kind: monaco.languages.CompletionItemKind.Text,
62
+ insertText: item.insertText,
63
+ detail: item.detail,
64
+ range: resolveReplaceRange(item, position, lineText, provider.triggerCharacters),
65
+ })),
66
+ }));
67
+ },
68
+ });
69
+ }
70
+
71
+ /**
72
+ * Attach the declarative completion providers to a Monaco editor instance.
73
+ * `getProviders` is read live on every suggestion request (see module doc) —
74
+ * pass a ref-backed getter so a fresh `completions` array identity on every
75
+ * render never forces a re-attach.
76
+ *
77
+ * Also force-opens Monaco's native suggest widget (`editor.action.triggerSuggest`)
78
+ * whenever the user types a character matching one of the CURRENTLY-configured
79
+ * providers' `triggerCharacters` — read live, so changing that set never needs a
80
+ * re-registration either. Markdown punctuation like `[` doesn't extend a "word",
81
+ * so without this Monaco's own quick-suggestions heuristic would never invoke a
82
+ * provider on it.
83
+ *
84
+ * Returns a disposer. Call it on unmount / when `completions` is removed —
85
+ * refcounted, so the GLOBAL "markdown" registration is only torn down once the
86
+ * LAST attached editor calls its disposer (#283 acceptance: no leak, no
87
+ * double-registration with two workspaces mounted).
88
+ */
89
+ export function attachCompletionsMonaco(
90
+ editor: monaco.editor.IStandaloneCodeEditor,
91
+ getProviders: ProvidersGetter,
92
+ ): () => void {
93
+ ensureRegistered();
94
+ refCount++;
95
+
96
+ let model = editor.getModel();
97
+ if (model) REGISTRY.set(model, getProviders);
98
+
99
+ const contentSub = editor.onDidChangeModelContent((e) => {
100
+ const providers = getProviders();
101
+ if (!providers || providers.length === 0) return;
102
+ for (const change of e.changes) {
103
+ if (change.text.length !== 1) continue;
104
+ if (providers.some((p) => p.triggerCharacters?.includes(change.text))) {
105
+ editor.trigger("brand-completions", "editor.action.triggerSuggest", {});
106
+ return;
107
+ }
108
+ }
109
+ });
110
+
111
+ const modelSub = editor.onDidChangeModel(() => {
112
+ if (model) REGISTRY.delete(model);
113
+ model = editor.getModel();
114
+ if (model) REGISTRY.set(model, getProviders);
115
+ });
116
+
117
+ return () => {
118
+ contentSub.dispose();
119
+ modelSub.dispose();
120
+ if (model) REGISTRY.delete(model);
121
+ refCount = Math.max(0, refCount - 1);
122
+ if (refCount === 0) {
123
+ registration?.dispose();
124
+ registration = null;
125
+ }
126
+ };
127
+ }