@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,253 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Tabs, TabsList, TabsTrigger } from "@elabs-ai/components-ui";
|
|
4
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
5
|
+
import {
|
|
6
|
+
forwardRef,
|
|
7
|
+
useCallback,
|
|
8
|
+
useEffect,
|
|
9
|
+
useImperativeHandle,
|
|
10
|
+
useRef,
|
|
11
|
+
useState,
|
|
12
|
+
type CSSProperties,
|
|
13
|
+
type HTMLAttributes,
|
|
14
|
+
} from "react";
|
|
15
|
+
import { CodeEditor, type MonacoCodeEditor } from "../code-editor";
|
|
16
|
+
import { CopyButton } from "../copy-button";
|
|
17
|
+
import {
|
|
18
|
+
monacoContentAccess,
|
|
19
|
+
type EditorContentAccess,
|
|
20
|
+
type EditorSelection,
|
|
21
|
+
} from "../lib/editor-content-access";
|
|
22
|
+
|
|
23
|
+
export interface EditorFile {
|
|
24
|
+
/** Unique path/name; shown (basename) on the tab and used as the model path. */
|
|
25
|
+
path: string;
|
|
26
|
+
/** Monaco language id. Inferred from the extension when omitted. */
|
|
27
|
+
language?: string;
|
|
28
|
+
/** File content. */
|
|
29
|
+
value: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CodeWorkspaceProps extends Omit<
|
|
33
|
+
HTMLAttributes<HTMLDivElement>,
|
|
34
|
+
"onChange" | "defaultValue"
|
|
35
|
+
> {
|
|
36
|
+
/** Open files, one tab each. */
|
|
37
|
+
files: EditorFile[];
|
|
38
|
+
/** Controlled active file path. */
|
|
39
|
+
activePath?: string;
|
|
40
|
+
/** Initial active path for uncontrolled use. Defaults to the first file. */
|
|
41
|
+
defaultActivePath?: string;
|
|
42
|
+
/** Called when the active tab changes. */
|
|
43
|
+
onActivePathChange?: (path: string) => void;
|
|
44
|
+
/** Called on every edit with the file path + new content. */
|
|
45
|
+
onFileChange?: (path: string, value: string) => void;
|
|
46
|
+
/** Render all files read-only. */
|
|
47
|
+
readOnly?: boolean;
|
|
48
|
+
/** Workspace height. Number → px. Defaults to "100%". */
|
|
49
|
+
height?: number | string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const EXT_LANGUAGE: Record<string, string> = {
|
|
53
|
+
ts: "typescript",
|
|
54
|
+
tsx: "typescript",
|
|
55
|
+
mts: "typescript",
|
|
56
|
+
cts: "typescript",
|
|
57
|
+
js: "javascript",
|
|
58
|
+
jsx: "javascript",
|
|
59
|
+
mjs: "javascript",
|
|
60
|
+
cjs: "javascript",
|
|
61
|
+
json: "json",
|
|
62
|
+
css: "css",
|
|
63
|
+
scss: "scss",
|
|
64
|
+
less: "less",
|
|
65
|
+
html: "html",
|
|
66
|
+
md: "markdown",
|
|
67
|
+
py: "python",
|
|
68
|
+
sql: "sql",
|
|
69
|
+
yml: "yaml",
|
|
70
|
+
yaml: "yaml",
|
|
71
|
+
xml: "xml",
|
|
72
|
+
sh: "shell",
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const basename = (path: string) => path.split("/").pop() || path;
|
|
76
|
+
|
|
77
|
+
function inferLanguage(file: EditorFile): string {
|
|
78
|
+
if (file.language) return file.language;
|
|
79
|
+
const ext = file.path.split(".").pop()?.toLowerCase() ?? "";
|
|
80
|
+
return EXT_LANGUAGE[ext] ?? "plaintext";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Imperative handle exposed via `CodeWorkspace`'s `ref`.
|
|
85
|
+
*
|
|
86
|
+
* **Migration note:** the forwarded `ref` type changed from `HTMLDivElement` to
|
|
87
|
+
* `CodeWorkspaceHandle` (mirrors the #273 `MarkdownWorkspaceHandle` precedent).
|
|
88
|
+
* Replace any `ref.current` / `ref.current.scrollIntoView()` DOM access with
|
|
89
|
+
* `ref.current?.getElement()`. This is the only breaking change in this work.
|
|
90
|
+
*/
|
|
91
|
+
export interface CodeWorkspaceHandle extends EditorContentAccess {
|
|
92
|
+
/**
|
|
93
|
+
* The live Monaco editor for the currently active tab, or `null` while booting
|
|
94
|
+
* or when no files are open. Consumers needing the full Monaco API can use this
|
|
95
|
+
* and pass it to `monacoContentAccess(handle.getActiveEditor()!)`.
|
|
96
|
+
*/
|
|
97
|
+
getActiveEditor(): MonacoCodeEditor | null;
|
|
98
|
+
/**
|
|
99
|
+
* The workspace root DOM element. Preserves the old `HTMLDivElement` ref access
|
|
100
|
+
* that existed before the ref-type change (#273 `MarkdownWorkspace` playbook).
|
|
101
|
+
*/
|
|
102
|
+
getElement(): HTMLDivElement | null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** No-op EditorContentAccess returned when no Monaco editor is mounted. */
|
|
106
|
+
const NULL_ACCESS: EditorContentAccess = {
|
|
107
|
+
getText: () => "",
|
|
108
|
+
getSelection: () => ({ text: "", empty: true }),
|
|
109
|
+
replaceSelection: () => undefined,
|
|
110
|
+
insertAtCursor: () => undefined,
|
|
111
|
+
focus: () => undefined,
|
|
112
|
+
onSelectionChange: () => () => undefined,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Multi-file editor shell: brand-ui `Tabs` for the file bar above a single
|
|
117
|
+
* {@link CodeEditor} that swaps to the active file. This is the "reuse our
|
|
118
|
+
* components for the chrome" surface — Tabs/Button are ours; the editing surface
|
|
119
|
+
* is Monaco. Edits bubble through `onFileChange`.
|
|
120
|
+
*
|
|
121
|
+
* The forwarded `ref` exposes a {@link CodeWorkspaceHandle} (not `HTMLDivElement`).
|
|
122
|
+
* Use `ref.current?.getElement()` to access the root DOM node.
|
|
123
|
+
*/
|
|
124
|
+
export const CodeWorkspace = forwardRef<CodeWorkspaceHandle, CodeWorkspaceProps>(
|
|
125
|
+
function CodeWorkspace(
|
|
126
|
+
{
|
|
127
|
+
files,
|
|
128
|
+
activePath,
|
|
129
|
+
defaultActivePath,
|
|
130
|
+
onActivePathChange,
|
|
131
|
+
onFileChange,
|
|
132
|
+
readOnly = false,
|
|
133
|
+
height = "100%",
|
|
134
|
+
className,
|
|
135
|
+
style,
|
|
136
|
+
...props
|
|
137
|
+
},
|
|
138
|
+
ref,
|
|
139
|
+
) {
|
|
140
|
+
const [internalActive, setInternalActive] = useState(defaultActivePath ?? files[0]?.path ?? "");
|
|
141
|
+
const isControlled = activePath !== undefined;
|
|
142
|
+
const requested = isControlled ? activePath : internalActive;
|
|
143
|
+
const activeFile = files.find((f) => f.path === requested) ?? files[0];
|
|
144
|
+
const active = activeFile?.path ?? "";
|
|
145
|
+
|
|
146
|
+
// The root div ref (getElement() preserves old HTMLDivElement access).
|
|
147
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
148
|
+
|
|
149
|
+
// The live Monaco editor for the active tab. Nulled when there is no active file.
|
|
150
|
+
const [activeEditor, setActiveEditor] = useState<MonacoCodeEditor | null>(null);
|
|
151
|
+
|
|
152
|
+
// Defensive: if the active file is removed (no `activeFile`), null the held instance
|
|
153
|
+
// so getActiveEditor() and content-access methods don't act on a disposed editor.
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
if (!activeFile) setActiveEditor(null);
|
|
156
|
+
}, [activeFile]);
|
|
157
|
+
|
|
158
|
+
// A STABLE listener set for onSelectionChange. The handle adds/removes here;
|
|
159
|
+
// the binding effect below forwards the ACTIVE editor's selection changes into
|
|
160
|
+
// it. This is what makes a subscribe-in-mount-effect survive the editor's async
|
|
161
|
+
// mount (and tab swaps): the consumer subscribes once; binding re-attaches as
|
|
162
|
+
// `activeEditor` changes. (Delegating onSelectionChange to a call-time snapshot
|
|
163
|
+
// would bind to the not-yet-mounted editor and silently never fire.)
|
|
164
|
+
const [selectionListeners] = useState(() => new Set<(sel: EditorSelection) => void>());
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
if (!activeEditor) return;
|
|
167
|
+
const unsub = monacoContentAccess(activeEditor).onSelectionChange((sel) =>
|
|
168
|
+
selectionListeners.forEach((l) => l(sel)),
|
|
169
|
+
);
|
|
170
|
+
return unsub;
|
|
171
|
+
}, [activeEditor, selectionListeners]);
|
|
172
|
+
|
|
173
|
+
const selectTab = useCallback(
|
|
174
|
+
(path: string) => {
|
|
175
|
+
if (!isControlled) setInternalActive(path);
|
|
176
|
+
onActivePathChange?.(path);
|
|
177
|
+
},
|
|
178
|
+
[isControlled, onActivePathChange],
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
useImperativeHandle(ref, () => {
|
|
182
|
+
const access = activeEditor ? monacoContentAccess(activeEditor) : NULL_ACCESS;
|
|
183
|
+
return {
|
|
184
|
+
getActiveEditor: () => activeEditor,
|
|
185
|
+
getElement: () => rootRef.current,
|
|
186
|
+
getText: () => access.getText(),
|
|
187
|
+
getSelection: () => access.getSelection(),
|
|
188
|
+
replaceSelection: (text: string) => access.replaceSelection(text),
|
|
189
|
+
insertAtCursor: (text: string) => access.insertAtCursor(text),
|
|
190
|
+
focus: () => access.focus(),
|
|
191
|
+
// Stable set (not the call-time `access`) so a subscription survives the
|
|
192
|
+
// editor's async mount + tab swaps; the binding effect forwards events.
|
|
193
|
+
onSelectionChange: (listener) => {
|
|
194
|
+
selectionListeners.add(listener);
|
|
195
|
+
return () => selectionListeners.delete(listener);
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}, [activeEditor, selectionListeners]);
|
|
199
|
+
|
|
200
|
+
const resolvedStyle: CSSProperties = {
|
|
201
|
+
height: typeof height === "number" ? `${height}px` : height,
|
|
202
|
+
...style,
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
return (
|
|
206
|
+
<div
|
|
207
|
+
ref={rootRef}
|
|
208
|
+
className={cn(
|
|
209
|
+
"flex flex-col overflow-hidden rounded-lg border border-border bg-surface",
|
|
210
|
+
className,
|
|
211
|
+
)}
|
|
212
|
+
style={resolvedStyle}
|
|
213
|
+
{...props}
|
|
214
|
+
>
|
|
215
|
+
<Tabs
|
|
216
|
+
value={active}
|
|
217
|
+
onValueChange={selectTab}
|
|
218
|
+
className="flex min-h-0 flex-1 flex-col gap-0"
|
|
219
|
+
>
|
|
220
|
+
<div className="flex items-center border-b border-border bg-surface">
|
|
221
|
+
<TabsList className="h-9 flex-1 justify-start gap-0 rounded-none bg-transparent p-0">
|
|
222
|
+
{files.map((file) => (
|
|
223
|
+
<TabsTrigger
|
|
224
|
+
key={file.path}
|
|
225
|
+
value={file.path}
|
|
226
|
+
className="h-9 rounded-none border-e border-border px-3 text-xs data-[state=active]:bg-background data-[state=active]:shadow-none"
|
|
227
|
+
>
|
|
228
|
+
{basename(file.path)}
|
|
229
|
+
</TabsTrigger>
|
|
230
|
+
))}
|
|
231
|
+
</TabsList>
|
|
232
|
+
{activeFile ? <CopyButton value={activeFile.value} className="me-1.5" /> : null}
|
|
233
|
+
</div>
|
|
234
|
+
|
|
235
|
+
<div className="min-h-0 flex-1">
|
|
236
|
+
{activeFile ? (
|
|
237
|
+
<CodeEditor
|
|
238
|
+
key={activeFile.path}
|
|
239
|
+
path={activeFile.path}
|
|
240
|
+
language={inferLanguage(activeFile)}
|
|
241
|
+
value={activeFile.value}
|
|
242
|
+
readOnly={readOnly}
|
|
243
|
+
onChange={(next) => onFileChange?.(activeFile.path, next)}
|
|
244
|
+
onMount={(editor) => setActiveEditor(editor)}
|
|
245
|
+
height="100%"
|
|
246
|
+
/>
|
|
247
|
+
) : null}
|
|
248
|
+
</div>
|
|
249
|
+
</Tabs>
|
|
250
|
+
</div>
|
|
251
|
+
);
|
|
252
|
+
},
|
|
253
|
+
);
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button, useCopyToClipboard } from "@elabs-ai/components-ui";
|
|
4
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
5
|
+
import { CheckIcon, CopyIcon } from "lucide-react";
|
|
6
|
+
import { useCallback, type ComponentProps } from "react";
|
|
7
|
+
|
|
8
|
+
export interface CopyButtonProps extends Omit<ComponentProps<typeof Button>, "value"> {
|
|
9
|
+
/** Text written to the clipboard on click. */
|
|
10
|
+
value: string;
|
|
11
|
+
/** Show the "Copy" / "Copied" label next to the icon. Defaults to true. */
|
|
12
|
+
label?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Brand-ui copy-to-clipboard button with a transient "Copied" state. Shared by
|
|
17
|
+
* the editor toolbar and workspace so the copy affordance is defined once.
|
|
18
|
+
*/
|
|
19
|
+
export function CopyButton({ value, label = true, className, ...props }: CopyButtonProps) {
|
|
20
|
+
// Clipboard write + transient flag come from the shared `@elabs-ai/components-ui`
|
|
21
|
+
// hook, so this button and `CopyableValue` cannot drift on timing or on what
|
|
22
|
+
// happens where there is no clipboard.
|
|
23
|
+
const { copied, copy } = useCopyToClipboard();
|
|
24
|
+
|
|
25
|
+
const onClick = useCallback(() => {
|
|
26
|
+
void copy(value);
|
|
27
|
+
}, [copy, value]);
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<Button
|
|
31
|
+
variant="ghost"
|
|
32
|
+
size="sm"
|
|
33
|
+
{...props}
|
|
34
|
+
type="button"
|
|
35
|
+
className={cn("h-7 gap-1.5", className)}
|
|
36
|
+
onClick={onClick}
|
|
37
|
+
aria-label={copied ? "Copied" : "Copy"}
|
|
38
|
+
>
|
|
39
|
+
{copied ? (
|
|
40
|
+
<CheckIcon
|
|
41
|
+
key={String(copied)}
|
|
42
|
+
className="size-4 text-success animate-in fade-in zoom-in-95 duration-fast ease-entrance"
|
|
43
|
+
aria-hidden="true"
|
|
44
|
+
/>
|
|
45
|
+
) : (
|
|
46
|
+
<CopyIcon className="size-4" aria-hidden="true" />
|
|
47
|
+
)}
|
|
48
|
+
{label ? <span className="text-xs">{copied ? "Copied" : "Copy"}</span> : null}
|
|
49
|
+
</Button>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { CopyButton, type CopyButtonProps } from "./copy-button";
|
package/src/css.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { DiffEditor } from "./diff-editor";
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: "Editor/DiffEditor",
|
|
6
|
+
component: DiffEditor,
|
|
7
|
+
tags: ["autodocs"],
|
|
8
|
+
parameters: { layout: "fullscreen" },
|
|
9
|
+
} satisfies Meta<typeof DiffEditor>;
|
|
10
|
+
|
|
11
|
+
export default meta;
|
|
12
|
+
type Story = StoryObj<typeof meta>;
|
|
13
|
+
|
|
14
|
+
const ORIGINAL = `export function greet(name) {
|
|
15
|
+
return "Hello " + name;
|
|
16
|
+
}
|
|
17
|
+
`;
|
|
18
|
+
|
|
19
|
+
const MODIFIED = `export function greet(name: string): string {
|
|
20
|
+
// Use a template literal.
|
|
21
|
+
return \`Hello \${name}!\`;
|
|
22
|
+
}
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
export const SideBySide: Story = {
|
|
26
|
+
render: () => (
|
|
27
|
+
<div className="h-[360px] w-full border border-border">
|
|
28
|
+
<DiffEditor language="typescript" original={ORIGINAL} modified={MODIFIED} />
|
|
29
|
+
</div>
|
|
30
|
+
),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const Inline: Story = {
|
|
34
|
+
render: () => (
|
|
35
|
+
<div className="h-[360px] w-full border border-border">
|
|
36
|
+
<DiffEditor
|
|
37
|
+
language="typescript"
|
|
38
|
+
original={ORIGINAL}
|
|
39
|
+
modified={MODIFIED}
|
|
40
|
+
renderSideBySide={false}
|
|
41
|
+
/>
|
|
42
|
+
</div>
|
|
43
|
+
),
|
|
44
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { cleanup, render } from "@testing-library/react";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
// Monaco can't mount in jsdom — mock it and assert the wrapper's lifecycle.
|
|
5
|
+
const h = vi.hoisted(() => {
|
|
6
|
+
interface MockModel {
|
|
7
|
+
value: string;
|
|
8
|
+
language: string;
|
|
9
|
+
getValue: () => string;
|
|
10
|
+
setValue: ReturnType<typeof vi.fn>;
|
|
11
|
+
dispose: ReturnType<typeof vi.fn>;
|
|
12
|
+
}
|
|
13
|
+
const models: MockModel[] = [];
|
|
14
|
+
const createModel = vi.fn((value: string, language: string) => {
|
|
15
|
+
const m = { value, language, getValue: () => value, setValue: vi.fn(), dispose: vi.fn() };
|
|
16
|
+
models.push(m);
|
|
17
|
+
return m;
|
|
18
|
+
});
|
|
19
|
+
const diff = {
|
|
20
|
+
setModel: vi.fn(),
|
|
21
|
+
getModel: vi.fn(() => ({ original: models[0], modified: models[1] })),
|
|
22
|
+
updateOptions: vi.fn(),
|
|
23
|
+
dispose: vi.fn(),
|
|
24
|
+
};
|
|
25
|
+
return { models, createModel, diff, createDiffEditor: vi.fn(() => diff) };
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
vi.mock("monaco-editor", () => ({
|
|
29
|
+
editor: {
|
|
30
|
+
createDiffEditor: h.createDiffEditor,
|
|
31
|
+
createModel: h.createModel,
|
|
32
|
+
setModelLanguage: vi.fn(),
|
|
33
|
+
defineTheme: vi.fn(),
|
|
34
|
+
setTheme: vi.fn(),
|
|
35
|
+
},
|
|
36
|
+
Uri: { parse: (s: string) => ({ toString: () => s }) },
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
import { DiffEditor } from "./diff-editor";
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
h.models.length = 0;
|
|
43
|
+
vi.clearAllMocks();
|
|
44
|
+
});
|
|
45
|
+
afterEach(cleanup);
|
|
46
|
+
|
|
47
|
+
describe("DiffEditor", () => {
|
|
48
|
+
it("creates a diff editor with original + modified models", () => {
|
|
49
|
+
const { getByTestId } = render(<DiffEditor original="a" modified="b" language="typescript" />);
|
|
50
|
+
expect(getByTestId("diff-editor")).toBeInTheDocument();
|
|
51
|
+
expect(h.createDiffEditor).toHaveBeenCalledTimes(1);
|
|
52
|
+
expect(h.createModel).toHaveBeenNthCalledWith(1, "a", "typescript");
|
|
53
|
+
expect(h.createModel).toHaveBeenNthCalledWith(2, "b", "typescript");
|
|
54
|
+
expect(h.diff.setModel).toHaveBeenCalledWith({
|
|
55
|
+
original: h.models[0],
|
|
56
|
+
modified: h.models[1],
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("disposes the editor + both models on unmount", () => {
|
|
61
|
+
const { unmount } = render(<DiffEditor original="a" modified="b" />);
|
|
62
|
+
const [original, modified] = h.models;
|
|
63
|
+
unmount();
|
|
64
|
+
expect(h.diff.dispose).toHaveBeenCalledTimes(1);
|
|
65
|
+
expect(original!.dispose).toHaveBeenCalledTimes(1);
|
|
66
|
+
expect(modified!.dispose).toHaveBeenCalledTimes(1);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as monaco from "monaco-editor";
|
|
4
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
5
|
+
import {
|
|
6
|
+
forwardRef,
|
|
7
|
+
useEffect,
|
|
8
|
+
useImperativeHandle,
|
|
9
|
+
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
type CSSProperties,
|
|
12
|
+
type HTMLAttributes,
|
|
13
|
+
} from "react";
|
|
14
|
+
import { applyBrandTheme } from "../lib/monaco-theme-bridge";
|
|
15
|
+
import { useDataTheme } from "../lib/use-data-theme";
|
|
16
|
+
|
|
17
|
+
export type MonacoDiffEditor = monaco.editor.IStandaloneDiffEditor;
|
|
18
|
+
|
|
19
|
+
export interface DiffEditorProps extends Omit<HTMLAttributes<HTMLDivElement>, "defaultValue"> {
|
|
20
|
+
/** Left/original document. */
|
|
21
|
+
original: string;
|
|
22
|
+
/** Right/modified document. */
|
|
23
|
+
modified: string;
|
|
24
|
+
/** Monaco language id applied to both sides. Defaults to "typescript". */
|
|
25
|
+
language?: string;
|
|
26
|
+
/** Read-only (diffs are read-only by default). */
|
|
27
|
+
readOnly?: boolean;
|
|
28
|
+
/** Side-by-side (true) vs inline (false) diff. Defaults to side-by-side. */
|
|
29
|
+
renderSideBySide?: boolean;
|
|
30
|
+
/** Editor height. Number → px. Defaults to "100%". */
|
|
31
|
+
height?: number | string;
|
|
32
|
+
/** Passthrough Monaco diff options (merged over the defaults). */
|
|
33
|
+
options?: monaco.editor.IStandaloneDiffEditorConstructionOptions;
|
|
34
|
+
/** Called once the diff editor + monaco namespace are ready. */
|
|
35
|
+
onMount?: (editor: MonacoDiffEditor, monacoApi: typeof monaco) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const BASE_OPTIONS: monaco.editor.IStandaloneDiffEditorConstructionOptions = {
|
|
39
|
+
automaticLayout: true,
|
|
40
|
+
minimap: { enabled: false },
|
|
41
|
+
scrollBeyondLastLine: false,
|
|
42
|
+
fontSize: 13,
|
|
43
|
+
ignoreTrimWhitespace: false,
|
|
44
|
+
padding: { top: 12, bottom: 12 },
|
|
45
|
+
scrollbar: { verticalScrollbarSize: 10, horizontalScrollbarSize: 10 },
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A token-themed Monaco diff editor (original ↔ modified). Same theming bridge
|
|
50
|
+
* and chrome story as {@link CodeEditor}; reads as a side-by-side or inline diff.
|
|
51
|
+
*/
|
|
52
|
+
export const DiffEditor = forwardRef<MonacoDiffEditor | null, DiffEditorProps>(function DiffEditor(
|
|
53
|
+
{
|
|
54
|
+
original,
|
|
55
|
+
modified,
|
|
56
|
+
language = "typescript",
|
|
57
|
+
readOnly = true,
|
|
58
|
+
renderSideBySide = true,
|
|
59
|
+
height = "100%",
|
|
60
|
+
options,
|
|
61
|
+
onMount,
|
|
62
|
+
className,
|
|
63
|
+
style,
|
|
64
|
+
...props
|
|
65
|
+
},
|
|
66
|
+
ref,
|
|
67
|
+
) {
|
|
68
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
69
|
+
const [editor, setEditor] = useState<MonacoDiffEditor | null>(null);
|
|
70
|
+
const { theme, revision } = useDataTheme();
|
|
71
|
+
|
|
72
|
+
const onMountRef = useRef(onMount);
|
|
73
|
+
onMountRef.current = onMount;
|
|
74
|
+
|
|
75
|
+
useImperativeHandle<MonacoDiffEditor | null, MonacoDiffEditor | null>(ref, () => editor, [
|
|
76
|
+
editor,
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
// Mount once.
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
const container = containerRef.current;
|
|
82
|
+
if (!container) return;
|
|
83
|
+
|
|
84
|
+
const instance = monaco.editor.createDiffEditor(container, {
|
|
85
|
+
...BASE_OPTIONS,
|
|
86
|
+
readOnly,
|
|
87
|
+
renderSideBySide,
|
|
88
|
+
...options,
|
|
89
|
+
});
|
|
90
|
+
const originalModel = monaco.editor.createModel(original, language);
|
|
91
|
+
const modifiedModel = monaco.editor.createModel(modified, language);
|
|
92
|
+
instance.setModel({ original: originalModel, modified: modifiedModel });
|
|
93
|
+
// Theme is applied by the effect below once `setEditor` runs.
|
|
94
|
+
setEditor(instance);
|
|
95
|
+
onMountRef.current?.(instance, monaco);
|
|
96
|
+
|
|
97
|
+
return () => {
|
|
98
|
+
instance.dispose();
|
|
99
|
+
originalModel.dispose();
|
|
100
|
+
modifiedModel.dispose();
|
|
101
|
+
setEditor(null);
|
|
102
|
+
};
|
|
103
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
104
|
+
}, []);
|
|
105
|
+
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
const original_ = editor?.getModel()?.original;
|
|
108
|
+
if (original_ && original_.getValue() !== original) original_.setValue(original);
|
|
109
|
+
}, [editor, original]);
|
|
110
|
+
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
const modified_ = editor?.getModel()?.modified;
|
|
113
|
+
if (modified_ && modified_.getValue() !== modified) modified_.setValue(modified);
|
|
114
|
+
}, [editor, modified]);
|
|
115
|
+
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
const models = editor?.getModel();
|
|
118
|
+
if (!models) return;
|
|
119
|
+
monaco.editor.setModelLanguage(models.original, language);
|
|
120
|
+
monaco.editor.setModelLanguage(models.modified, language);
|
|
121
|
+
}, [editor, language]);
|
|
122
|
+
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
editor?.updateOptions({ readOnly, renderSideBySide });
|
|
125
|
+
}, [editor, readOnly, renderSideBySide]);
|
|
126
|
+
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
if (!editor) return;
|
|
129
|
+
try {
|
|
130
|
+
applyBrandTheme(monaco, theme);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
console.error("[@elabs-ai/components-editor] failed to apply brand theme", err);
|
|
133
|
+
}
|
|
134
|
+
// `revision` re-applies once the data-theme attribute settles. See CodeEditor.
|
|
135
|
+
}, [editor, theme, revision]);
|
|
136
|
+
|
|
137
|
+
const resolvedStyle: CSSProperties = {
|
|
138
|
+
height: typeof height === "number" ? `${height}px` : height,
|
|
139
|
+
...style,
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<div
|
|
144
|
+
ref={containerRef}
|
|
145
|
+
data-testid="diff-editor"
|
|
146
|
+
className={cn("h-full w-full overflow-hidden bg-background text-foreground", className)}
|
|
147
|
+
style={resolvedStyle}
|
|
148
|
+
{...props}
|
|
149
|
+
/>
|
|
150
|
+
);
|
|
151
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { DiffEditor, type DiffEditorProps, type MonacoDiffEditor } from "./diff-editor";
|