@djangocfg/widget-code 0.1.1
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 +38 -0
- package/package.json +119 -0
- package/src/diff-viewer/DiffViewer.tsx +168 -0
- package/src/diff-viewer/README.md +33 -0
- package/src/diff-viewer/components/CopyButton.tsx +49 -0
- package/src/diff-viewer/components/DiffLineContent.tsx +48 -0
- package/src/diff-viewer/components/SplitView.tsx +270 -0
- package/src/diff-viewer/components/UnifiedView.tsx +189 -0
- package/src/diff-viewer/diff-viewer.stories.tsx +168 -0
- package/src/diff-viewer/hooks/useDiff.ts +47 -0
- package/src/diff-viewer/hooks/useHighlighter.ts +54 -0
- package/src/diff-viewer/index.ts +22 -0
- package/src/diff-viewer/lazy.tsx +25 -0
- package/src/diff-viewer/types.ts +125 -0
- package/src/diff-viewer/utils/computeDiff.ts +171 -0
- package/src/editor/README.md +187 -0
- package/src/editor/components/DiffEditor.tsx +158 -0
- package/src/editor/components/Editor.tsx +293 -0
- package/src/editor/components/index.ts +2 -0
- package/src/editor/context/EditorProvider.tsx +212 -0
- package/src/editor/context/index.ts +1 -0
- package/src/editor/editor.stories.tsx +397 -0
- package/src/editor/hooks/index.ts +4 -0
- package/src/editor/hooks/useEditor.ts +36 -0
- package/src/editor/hooks/useEditorTheme.ts +91 -0
- package/src/editor/hooks/useLanguage.ts +29 -0
- package/src/editor/hooks/useMonaco.ts +79 -0
- package/src/editor/index.ts +16 -0
- package/src/editor/lazy.tsx +76 -0
- package/src/editor/lib/index.ts +2 -0
- package/src/editor/lib/languages.ts +227 -0
- package/src/editor/lib/themes.ts +42 -0
- package/src/editor/types/index.ts +137 -0
- package/src/editor/workers/index.ts +1 -0
- package/src/editor/workers/setup.ts +58 -0
- package/src/index.ts +10 -0
- package/src/json-editor/JsonEditor.tsx +115 -0
- package/src/json-editor/index.ts +1 -0
- package/src/json-editor/lazy.tsx +24 -0
- package/src/markdown-message/ActionRow.tsx +53 -0
- package/src/markdown-message/ChatMessageRow.tsx +103 -0
- package/src/markdown-message/CodeBlock.tsx +137 -0
- package/src/markdown-message/CollapseToggle.tsx +69 -0
- package/src/markdown-message/MarkdownMessage.tsx +376 -0
- package/src/markdown-message/README.md +221 -0
- package/src/markdown-message/builtinRules.tsx +92 -0
- package/src/markdown-message/components.tsx +224 -0
- package/src/markdown-message/index.ts +22 -0
- package/src/markdown-message/linkRules.ts +83 -0
- package/src/markdown-message/plainText.ts +83 -0
- package/src/markdown-message/sanitize.ts +88 -0
- package/src/markdown-message/streamingBlocks.ts +168 -0
- package/src/markdown-message/types.ts +168 -0
- package/src/panel-header/CodePanelHeader.tsx +121 -0
- package/src/panel-header/index.ts +1 -0
- package/src/pretty-code/PrettyCode.client.tsx +402 -0
- package/src/pretty-code/README.md +81 -0
- package/src/pretty-code/index.tsx +72 -0
- package/src/pretty-code/lazy.tsx +69 -0
- package/src/pretty-code/pretty-code.stories.tsx +353 -0
- package/src/pretty-code/registerPrismLanguages.ts +116 -0
- package/src/role-tokens.ts +39 -0
- package/src/use-collapsible-content.ts +236 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useRef, useEffect } from 'react';
|
|
4
|
+
import type * as monaco from 'monaco-editor';
|
|
5
|
+
|
|
6
|
+
import { useMonaco } from '../hooks/useMonaco';
|
|
7
|
+
import { useEditorTheme, EDITOR_BACKGROUND } from '../hooks/useEditorTheme';
|
|
8
|
+
import type { DiffEditorProps } from '../types';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Monaco Diff Editor Component
|
|
12
|
+
*
|
|
13
|
+
* Side-by-side or inline diff view for comparing two versions of content.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```tsx
|
|
17
|
+
* <DiffEditor
|
|
18
|
+
* original={originalCode}
|
|
19
|
+
* modified={modifiedCode}
|
|
20
|
+
* language="typescript"
|
|
21
|
+
* />
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export function DiffEditor({
|
|
25
|
+
original,
|
|
26
|
+
modified,
|
|
27
|
+
language = 'plaintext',
|
|
28
|
+
options = {},
|
|
29
|
+
className = '',
|
|
30
|
+
height = '100%',
|
|
31
|
+
}: DiffEditorProps) {
|
|
32
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
33
|
+
const editorRef = useRef<monaco.editor.IStandaloneDiffEditor | null>(null);
|
|
34
|
+
const { monaco, isLoading, error } = useMonaco();
|
|
35
|
+
const resolvedTheme = useEditorTheme(monaco, options.theme);
|
|
36
|
+
|
|
37
|
+
// Create diff editor — runs once Monaco is available.
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!monaco || !containerRef.current || editorRef.current) return;
|
|
40
|
+
|
|
41
|
+
const editor = monaco.editor.createDiffEditor(containerRef.current, {
|
|
42
|
+
theme: resolvedTheme,
|
|
43
|
+
fontSize: options.fontSize || 14,
|
|
44
|
+
fontFamily: options.fontFamily || "'Fira Code', 'Consolas', monospace",
|
|
45
|
+
readOnly: true,
|
|
46
|
+
automaticLayout: true,
|
|
47
|
+
renderSideBySide: true,
|
|
48
|
+
scrollBeyondLastLine: false,
|
|
49
|
+
minimap: { enabled: false },
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const originalModel = monaco.editor.createModel(original, language);
|
|
53
|
+
const modifiedModel = monaco.editor.createModel(modified, language);
|
|
54
|
+
|
|
55
|
+
editor.setModel({
|
|
56
|
+
original: originalModel,
|
|
57
|
+
modified: modifiedModel,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
editorRef.current = editor;
|
|
61
|
+
|
|
62
|
+
return () => {
|
|
63
|
+
// Dispose models before the editor so Monaco releases all references.
|
|
64
|
+
originalModel.dispose();
|
|
65
|
+
modifiedModel.dispose();
|
|
66
|
+
editor.dispose();
|
|
67
|
+
editorRef.current = null;
|
|
68
|
+
};
|
|
69
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
70
|
+
}, [monaco]);
|
|
71
|
+
|
|
72
|
+
// Update models when content changes — skip no-op writes to avoid
|
|
73
|
+
// resetting scroll/decorations on unrelated re-renders.
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
const editor = editorRef.current;
|
|
76
|
+
if (!editor) return;
|
|
77
|
+
|
|
78
|
+
const model = editor.getModel();
|
|
79
|
+
if (!model) return;
|
|
80
|
+
if (model.original.getValue() !== original) model.original.setValue(original);
|
|
81
|
+
if (model.modified.getValue() !== modified) model.modified.setValue(modified);
|
|
82
|
+
}, [original, modified]);
|
|
83
|
+
|
|
84
|
+
// Update language
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
const editor = editorRef.current;
|
|
87
|
+
if (!editor || !monaco) return;
|
|
88
|
+
|
|
89
|
+
const model = editor.getModel();
|
|
90
|
+
if (model) {
|
|
91
|
+
monaco.editor.setModelLanguage(model.original, language);
|
|
92
|
+
monaco.editor.setModelLanguage(model.modified, language);
|
|
93
|
+
}
|
|
94
|
+
}, [language, monaco]);
|
|
95
|
+
|
|
96
|
+
// Apply theme — must go through setTheme(), updateOptions ignores `theme`.
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (!monaco || !editorRef.current) return;
|
|
99
|
+
monaco.editor.setTheme(resolvedTheme);
|
|
100
|
+
}, [monaco, resolvedTheme]);
|
|
101
|
+
|
|
102
|
+
if (error) {
|
|
103
|
+
return (
|
|
104
|
+
<div
|
|
105
|
+
className={className}
|
|
106
|
+
role="alert"
|
|
107
|
+
style={{
|
|
108
|
+
width: '100%',
|
|
109
|
+
height,
|
|
110
|
+
display: 'flex',
|
|
111
|
+
alignItems: 'center',
|
|
112
|
+
justifyContent: 'center',
|
|
113
|
+
padding: 16,
|
|
114
|
+
textAlign: 'center',
|
|
115
|
+
backgroundColor: EDITOR_BACKGROUND,
|
|
116
|
+
color: 'hsl(var(--destructive))',
|
|
117
|
+
fontSize: 13,
|
|
118
|
+
}}
|
|
119
|
+
>
|
|
120
|
+
Failed to load diff editor: {error.message}
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (isLoading) {
|
|
126
|
+
return (
|
|
127
|
+
<div
|
|
128
|
+
className={className}
|
|
129
|
+
role="status"
|
|
130
|
+
aria-busy="true"
|
|
131
|
+
style={{
|
|
132
|
+
width: '100%',
|
|
133
|
+
height,
|
|
134
|
+
display: 'flex',
|
|
135
|
+
alignItems: 'center',
|
|
136
|
+
justifyContent: 'center',
|
|
137
|
+
backgroundColor: EDITOR_BACKGROUND,
|
|
138
|
+
color: 'hsl(var(--muted-foreground))',
|
|
139
|
+
fontSize: 13,
|
|
140
|
+
}}
|
|
141
|
+
>
|
|
142
|
+
Loading diff editor…
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<div
|
|
149
|
+
ref={containerRef}
|
|
150
|
+
className={className}
|
|
151
|
+
style={{
|
|
152
|
+
width: '100%',
|
|
153
|
+
height,
|
|
154
|
+
backgroundColor: EDITOR_BACKGROUND,
|
|
155
|
+
}}
|
|
156
|
+
/>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useRef, useEffect, useState, useCallback, forwardRef, useImperativeHandle } from 'react';
|
|
4
|
+
import type * as monaco from 'monaco-editor';
|
|
5
|
+
|
|
6
|
+
import { useMonaco } from '../hooks/useMonaco';
|
|
7
|
+
import { useEditorTheme, EDITOR_BACKGROUND } from '../hooks/useEditorTheme';
|
|
8
|
+
import type { EditorProps } from '../types';
|
|
9
|
+
|
|
10
|
+
export interface EditorRef {
|
|
11
|
+
/** Get editor instance */
|
|
12
|
+
getEditor: () => monaco.editor.IStandaloneCodeEditor | null;
|
|
13
|
+
/** Get current value */
|
|
14
|
+
getValue: () => string;
|
|
15
|
+
/** Set value */
|
|
16
|
+
setValue: (value: string) => void;
|
|
17
|
+
/** Focus editor */
|
|
18
|
+
focus: () => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Monaco Editor Component
|
|
23
|
+
*
|
|
24
|
+
* A React wrapper around Monaco Editor with full TypeScript support.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```tsx
|
|
28
|
+
* <Editor
|
|
29
|
+
* value={code}
|
|
30
|
+
* language="typescript"
|
|
31
|
+
* onChange={(value) => setCode(value)}
|
|
32
|
+
* options={{ fontSize: 14, minimap: false }}
|
|
33
|
+
* />
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export const Editor = forwardRef<EditorRef, EditorProps>(function Editor(
|
|
37
|
+
{
|
|
38
|
+
value = '',
|
|
39
|
+
language = 'plaintext',
|
|
40
|
+
onChange,
|
|
41
|
+
onMount,
|
|
42
|
+
options = {},
|
|
43
|
+
className = '',
|
|
44
|
+
height = '100%',
|
|
45
|
+
width = '100%',
|
|
46
|
+
autoHeight = false,
|
|
47
|
+
minHeight = 100,
|
|
48
|
+
maxHeight = 600,
|
|
49
|
+
autoFocus = false,
|
|
50
|
+
onSave,
|
|
51
|
+
},
|
|
52
|
+
ref
|
|
53
|
+
) {
|
|
54
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
55
|
+
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
|
|
56
|
+
const { monaco, isLoading, error } = useMonaco();
|
|
57
|
+
const resolvedTheme = useEditorTheme(monaco, options.theme);
|
|
58
|
+
|
|
59
|
+
// Auto-height state
|
|
60
|
+
const [contentHeight, setContentHeight] = useState<number | null>(null);
|
|
61
|
+
|
|
62
|
+
const updateContentHeight = useCallback((editor: monaco.editor.IStandaloneCodeEditor) => {
|
|
63
|
+
if (!autoHeight) return;
|
|
64
|
+
const h = editor.getContentHeight();
|
|
65
|
+
setContentHeight(Math.min(Math.max(h, minHeight), maxHeight));
|
|
66
|
+
}, [autoHeight, minHeight, maxHeight]);
|
|
67
|
+
|
|
68
|
+
// Keep latest callbacks in refs so the create-effect can stay [monaco]-only
|
|
69
|
+
// without going stale when the parent passes new function identities.
|
|
70
|
+
const onChangeRef = useRef(onChange);
|
|
71
|
+
const onMountRef = useRef(onMount);
|
|
72
|
+
const onSaveRef = useRef(onSave);
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
onChangeRef.current = onChange;
|
|
75
|
+
onMountRef.current = onMount;
|
|
76
|
+
onSaveRef.current = onSave;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Expose editor methods via ref
|
|
80
|
+
useImperativeHandle(ref, () => ({
|
|
81
|
+
getEditor: () => editorRef.current,
|
|
82
|
+
getValue: () => editorRef.current?.getValue() ?? '',
|
|
83
|
+
setValue: (val: string) => editorRef.current?.setValue(val),
|
|
84
|
+
focus: () => editorRef.current?.focus(),
|
|
85
|
+
}), []);
|
|
86
|
+
|
|
87
|
+
// Create editor — runs once Monaco is available.
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (!monaco || !containerRef.current || editorRef.current) return;
|
|
90
|
+
|
|
91
|
+
const editor = monaco.editor.create(containerRef.current, {
|
|
92
|
+
value,
|
|
93
|
+
language,
|
|
94
|
+
theme: resolvedTheme,
|
|
95
|
+
fontSize: options.fontSize || 14,
|
|
96
|
+
fontFamily: options.fontFamily || "'Fira Code', 'Consolas', monospace",
|
|
97
|
+
tabSize: options.tabSize || 2,
|
|
98
|
+
insertSpaces: options.insertSpaces !== false,
|
|
99
|
+
wordWrap: options.wordWrap || 'on',
|
|
100
|
+
minimap: { enabled: options.minimap !== false },
|
|
101
|
+
lineNumbers: options.lineNumbers || 'on',
|
|
102
|
+
readOnly: options.readOnly || false,
|
|
103
|
+
automaticLayout: true,
|
|
104
|
+
scrollBeyondLastLine: !autoHeight,
|
|
105
|
+
scrollbar: autoHeight ? { vertical: 'hidden', horizontal: 'auto' } : undefined,
|
|
106
|
+
overviewRulerLanes: autoHeight ? 0 : undefined,
|
|
107
|
+
padding: { top: 16, bottom: 16 },
|
|
108
|
+
renderLineHighlight: 'all',
|
|
109
|
+
cursorBlinking: 'smooth',
|
|
110
|
+
cursorSmoothCaretAnimation: 'on',
|
|
111
|
+
smoothScrolling: true,
|
|
112
|
+
bracketPairColorization: { enabled: true },
|
|
113
|
+
guides: {
|
|
114
|
+
bracketPairs: true,
|
|
115
|
+
indentation: true,
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
editorRef.current = editor;
|
|
120
|
+
|
|
121
|
+
// Change listener — always attached; reads latest onChange via ref.
|
|
122
|
+
const changeSub = editor.onDidChangeModelContent(() => {
|
|
123
|
+
onChangeRef.current?.(editor.getValue());
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Auto-height: resize container to fit content
|
|
127
|
+
let sizeSub: monaco.IDisposable | undefined;
|
|
128
|
+
if (autoHeight) {
|
|
129
|
+
sizeSub = editor.onDidContentSizeChange(() => updateContentHeight(editor));
|
|
130
|
+
updateContentHeight(editor);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Cmd/Ctrl+S → save. Registered as a Monaco command so it wins over
|
|
134
|
+
// the browser's "save page" default whenever the editor has focus.
|
|
135
|
+
// Read through the ref so swapping handlers does not need to rebuild.
|
|
136
|
+
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
|
|
137
|
+
onSaveRef.current?.(editor.getValue());
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// autoFocus on first mount — Monaco refuses focus during layout
|
|
141
|
+
// measurement otherwise.
|
|
142
|
+
if (autoFocus) {
|
|
143
|
+
// queueMicrotask: defer past Monaco's own post-create layout so the
|
|
144
|
+
// focus call lands on a fully laid-out editor.
|
|
145
|
+
queueMicrotask(() => editorRef.current?.focus());
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Call onMount callback
|
|
149
|
+
onMountRef.current?.(editor);
|
|
150
|
+
|
|
151
|
+
return () => {
|
|
152
|
+
changeSub.dispose();
|
|
153
|
+
sizeSub?.dispose();
|
|
154
|
+
// Dispose the model explicitly — editor.dispose() does not always
|
|
155
|
+
// release models in older Monaco builds, and never the ones we own.
|
|
156
|
+
editor.getModel()?.dispose();
|
|
157
|
+
editor.dispose();
|
|
158
|
+
editorRef.current = null;
|
|
159
|
+
};
|
|
160
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
161
|
+
}, [monaco]);
|
|
162
|
+
|
|
163
|
+
// Sync value when the prop changes from outside (skip echoes of user typing).
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
const editor = editorRef.current;
|
|
166
|
+
if (!editor) return;
|
|
167
|
+
|
|
168
|
+
const currentValue = editor.getValue();
|
|
169
|
+
// If the prop already matches the editor, nothing to do — this also
|
|
170
|
+
// covers the common controlled case where onChange echoes value back,
|
|
171
|
+
// so we never reset the cursor on the user's own keystrokes.
|
|
172
|
+
if (value === currentValue) return;
|
|
173
|
+
|
|
174
|
+
const position = editor.getPosition();
|
|
175
|
+
const selections = editor.getSelections();
|
|
176
|
+
|
|
177
|
+
// executeEdits preserves undo history (unlike setValue, which wipes it).
|
|
178
|
+
const model = editor.getModel();
|
|
179
|
+
if (model) {
|
|
180
|
+
editor.executeEdits('external-sync', [
|
|
181
|
+
{ range: model.getFullModelRange(), text: value, forceMoveMarkers: true },
|
|
182
|
+
]);
|
|
183
|
+
editor.pushUndoStop();
|
|
184
|
+
} else {
|
|
185
|
+
editor.setValue(value);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (position) editor.setPosition(position);
|
|
189
|
+
if (selections && selections.length > 0) editor.setSelections(selections);
|
|
190
|
+
}, [value]);
|
|
191
|
+
|
|
192
|
+
// Update language when prop changes
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
const editor = editorRef.current;
|
|
195
|
+
if (!editor || !monaco) return;
|
|
196
|
+
|
|
197
|
+
const model = editor.getModel();
|
|
198
|
+
if (model && model.getLanguageId() !== language) {
|
|
199
|
+
monaco.editor.setModelLanguage(model, language);
|
|
200
|
+
}
|
|
201
|
+
}, [language, monaco]);
|
|
202
|
+
|
|
203
|
+
// Apply theme — Monaco ignores `theme` inside updateOptions, it must go
|
|
204
|
+
// through setTheme(). This is what makes light/dark switching actually work.
|
|
205
|
+
useEffect(() => {
|
|
206
|
+
if (!monaco || !editorRef.current) return;
|
|
207
|
+
monaco.editor.setTheme(resolvedTheme);
|
|
208
|
+
}, [monaco, resolvedTheme]);
|
|
209
|
+
|
|
210
|
+
// Update options when props change
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
const editor = editorRef.current;
|
|
213
|
+
if (!editor) return;
|
|
214
|
+
|
|
215
|
+
editor.updateOptions({
|
|
216
|
+
fontSize: options.fontSize,
|
|
217
|
+
readOnly: options.readOnly,
|
|
218
|
+
minimap: { enabled: options.minimap !== false },
|
|
219
|
+
wordWrap: options.wordWrap,
|
|
220
|
+
lineNumbers: options.lineNumbers,
|
|
221
|
+
tabSize: options.tabSize,
|
|
222
|
+
insertSpaces: options.insertSpaces,
|
|
223
|
+
});
|
|
224
|
+
}, [
|
|
225
|
+
options.fontSize,
|
|
226
|
+
options.readOnly,
|
|
227
|
+
options.minimap,
|
|
228
|
+
options.wordWrap,
|
|
229
|
+
options.lineNumbers,
|
|
230
|
+
options.tabSize,
|
|
231
|
+
options.insertSpaces,
|
|
232
|
+
]);
|
|
233
|
+
|
|
234
|
+
if (error) {
|
|
235
|
+
return (
|
|
236
|
+
<div
|
|
237
|
+
className={className}
|
|
238
|
+
role="alert"
|
|
239
|
+
style={{
|
|
240
|
+
width,
|
|
241
|
+
height,
|
|
242
|
+
display: 'flex',
|
|
243
|
+
alignItems: 'center',
|
|
244
|
+
justifyContent: 'center',
|
|
245
|
+
padding: 16,
|
|
246
|
+
textAlign: 'center',
|
|
247
|
+
backgroundColor: EDITOR_BACKGROUND,
|
|
248
|
+
color: 'hsl(var(--destructive))',
|
|
249
|
+
fontSize: 13,
|
|
250
|
+
}}
|
|
251
|
+
>
|
|
252
|
+
Failed to load editor: {error.message}
|
|
253
|
+
</div>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (isLoading) {
|
|
258
|
+
return (
|
|
259
|
+
<div
|
|
260
|
+
className={className}
|
|
261
|
+
role="status"
|
|
262
|
+
aria-busy="true"
|
|
263
|
+
style={{
|
|
264
|
+
width,
|
|
265
|
+
height,
|
|
266
|
+
display: 'flex',
|
|
267
|
+
alignItems: 'center',
|
|
268
|
+
justifyContent: 'center',
|
|
269
|
+
backgroundColor: EDITOR_BACKGROUND,
|
|
270
|
+
color: 'hsl(var(--muted-foreground))',
|
|
271
|
+
fontSize: 13,
|
|
272
|
+
}}
|
|
273
|
+
>
|
|
274
|
+
Loading editor…
|
|
275
|
+
</div>
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const resolvedHeight = autoHeight && contentHeight != null ? contentHeight : height;
|
|
280
|
+
|
|
281
|
+
return (
|
|
282
|
+
<div
|
|
283
|
+
ref={containerRef}
|
|
284
|
+
className={className}
|
|
285
|
+
style={{
|
|
286
|
+
width,
|
|
287
|
+
height: resolvedHeight,
|
|
288
|
+
backgroundColor: EDITOR_BACKGROUND,
|
|
289
|
+
...(autoHeight && { minHeight, maxHeight, overflow: 'hidden' }),
|
|
290
|
+
}}
|
|
291
|
+
/>
|
|
292
|
+
);
|
|
293
|
+
});
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useState, useCallback, useMemo } from 'react';
|
|
4
|
+
import type * as monaco from 'monaco-editor';
|
|
5
|
+
|
|
6
|
+
import { useMonaco } from '../hooks/useMonaco';
|
|
7
|
+
import { getLanguageByFilename } from '../lib/languages';
|
|
8
|
+
import type { EditorFile, EditorContextValue } from '../types';
|
|
9
|
+
|
|
10
|
+
const EditorContext = createContext<EditorContextValue | null>(null);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Hook to access editor context
|
|
14
|
+
* Must be used within EditorProvider
|
|
15
|
+
*/
|
|
16
|
+
export function useEditorContext(): EditorContextValue {
|
|
17
|
+
const context = useContext(EditorContext);
|
|
18
|
+
if (!context) {
|
|
19
|
+
throw new Error('useEditorContext must be used within EditorProvider');
|
|
20
|
+
}
|
|
21
|
+
return context;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface EditorProviderProps {
|
|
25
|
+
children: React.ReactNode;
|
|
26
|
+
/** Callback when file save is requested */
|
|
27
|
+
onSave?: (path: string, content: string) => Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Editor Context Provider
|
|
32
|
+
*
|
|
33
|
+
* Manages multiple open files, active file state, and editor instance.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```tsx
|
|
37
|
+
* <EditorProvider onSave={handleSave}>
|
|
38
|
+
* <EditorTabs />
|
|
39
|
+
* <Editor />
|
|
40
|
+
* </EditorProvider>
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export function EditorProvider({ children, onSave }: EditorProviderProps) {
|
|
44
|
+
const { monaco } = useMonaco();
|
|
45
|
+
const [editor, setEditor] = useState<monaco.editor.IStandaloneCodeEditor | null>(null);
|
|
46
|
+
const [openFiles, setOpenFiles] = useState<EditorFile[]>([]);
|
|
47
|
+
const [activeFilePath, setActiveFilePath] = useState<string | null>(null);
|
|
48
|
+
|
|
49
|
+
// Get active file
|
|
50
|
+
const activeFile = useMemo(
|
|
51
|
+
() => openFiles.find((f) => f.path === activeFilePath) || null,
|
|
52
|
+
[openFiles, activeFilePath]
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
// Open a file
|
|
56
|
+
const openFile = useCallback(
|
|
57
|
+
(path: string, content: string, language?: string) => {
|
|
58
|
+
setOpenFiles((files) => {
|
|
59
|
+
// Check if already open
|
|
60
|
+
const existing = files.find((f) => f.path === path);
|
|
61
|
+
if (existing) {
|
|
62
|
+
return files;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Detect language from filename
|
|
66
|
+
const basename = path.split('/').pop() || path;
|
|
67
|
+
const detectedLanguage = language || getLanguageByFilename(basename);
|
|
68
|
+
|
|
69
|
+
// Create new file entry
|
|
70
|
+
const newFile: EditorFile = {
|
|
71
|
+
path,
|
|
72
|
+
content,
|
|
73
|
+
language: detectedLanguage,
|
|
74
|
+
isDirty: false,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return [...files, newFile];
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Set as active
|
|
81
|
+
setActiveFilePath(path);
|
|
82
|
+
},
|
|
83
|
+
[]
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// Close a file
|
|
87
|
+
const closeFile = useCallback(
|
|
88
|
+
(path: string) => {
|
|
89
|
+
setOpenFiles((files) => {
|
|
90
|
+
const index = files.findIndex((f) => f.path === path);
|
|
91
|
+
if (index === -1) return files;
|
|
92
|
+
|
|
93
|
+
const newFiles = files.filter((f) => f.path !== path);
|
|
94
|
+
|
|
95
|
+
// If closing active file, activate adjacent file
|
|
96
|
+
if (activeFilePath === path && newFiles.length > 0) {
|
|
97
|
+
const newIndex = Math.min(index, newFiles.length - 1);
|
|
98
|
+
const next = newFiles[newIndex];
|
|
99
|
+
if (next) setActiveFilePath(next.path);
|
|
100
|
+
} else if (newFiles.length === 0) {
|
|
101
|
+
setActiveFilePath(null);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return newFiles;
|
|
105
|
+
});
|
|
106
|
+
},
|
|
107
|
+
[activeFilePath]
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// Set active file
|
|
111
|
+
const setActiveFile = useCallback((path: string) => {
|
|
112
|
+
setActiveFilePath(path);
|
|
113
|
+
}, []);
|
|
114
|
+
|
|
115
|
+
// Update file content
|
|
116
|
+
const updateContent = useCallback((path: string, content: string) => {
|
|
117
|
+
setOpenFiles((files) =>
|
|
118
|
+
files.map((f) =>
|
|
119
|
+
f.path === path
|
|
120
|
+
? { ...f, content, isDirty: true }
|
|
121
|
+
: f
|
|
122
|
+
)
|
|
123
|
+
);
|
|
124
|
+
}, []);
|
|
125
|
+
|
|
126
|
+
// Save file
|
|
127
|
+
const saveFile = useCallback(
|
|
128
|
+
async (path: string) => {
|
|
129
|
+
const file = openFiles.find((f) => f.path === path);
|
|
130
|
+
if (!file) return;
|
|
131
|
+
|
|
132
|
+
if (onSave) {
|
|
133
|
+
await onSave(path, file.content);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Mark as not dirty
|
|
137
|
+
setOpenFiles((files) =>
|
|
138
|
+
files.map((f) =>
|
|
139
|
+
f.path === path ? { ...f, isDirty: false } : f
|
|
140
|
+
)
|
|
141
|
+
);
|
|
142
|
+
},
|
|
143
|
+
[openFiles, onSave]
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
// Check if file is dirty
|
|
147
|
+
const isDirty = useCallback(
|
|
148
|
+
(path: string) => {
|
|
149
|
+
const file = openFiles.find((f) => f.path === path);
|
|
150
|
+
return file?.isDirty || false;
|
|
151
|
+
},
|
|
152
|
+
[openFiles]
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
// Get file content
|
|
156
|
+
const getContent = useCallback(
|
|
157
|
+
(path: string) => {
|
|
158
|
+
const file = openFiles.find((f) => f.path === path);
|
|
159
|
+
return file?.content || null;
|
|
160
|
+
},
|
|
161
|
+
[openFiles]
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// Get file by path
|
|
165
|
+
const getFile = useCallback(
|
|
166
|
+
(path: string) => {
|
|
167
|
+
return openFiles.find((f) => f.path === path) || null;
|
|
168
|
+
},
|
|
169
|
+
[openFiles]
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
// Memoize so consumers don't re-render on unrelated parent renders.
|
|
173
|
+
const value = useMemo<EditorContextValue>(
|
|
174
|
+
() => ({
|
|
175
|
+
openFiles,
|
|
176
|
+
activeFile,
|
|
177
|
+
monaco,
|
|
178
|
+
editor,
|
|
179
|
+
isReady: monaco !== null && editor !== null,
|
|
180
|
+
|
|
181
|
+
openFile,
|
|
182
|
+
closeFile,
|
|
183
|
+
setActiveFile,
|
|
184
|
+
updateContent,
|
|
185
|
+
saveFile,
|
|
186
|
+
|
|
187
|
+
isDirty,
|
|
188
|
+
getContent,
|
|
189
|
+
getFile,
|
|
190
|
+
}),
|
|
191
|
+
[
|
|
192
|
+
openFiles,
|
|
193
|
+
activeFile,
|
|
194
|
+
monaco,
|
|
195
|
+
editor,
|
|
196
|
+
openFile,
|
|
197
|
+
closeFile,
|
|
198
|
+
setActiveFile,
|
|
199
|
+
updateContent,
|
|
200
|
+
saveFile,
|
|
201
|
+
isDirty,
|
|
202
|
+
getContent,
|
|
203
|
+
getFile,
|
|
204
|
+
],
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
return (
|
|
208
|
+
<EditorContext.Provider value={value}>
|
|
209
|
+
{children}
|
|
210
|
+
</EditorContext.Provider>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { EditorProvider, useEditorContext } from './EditorProvider';
|