@djangocfg/ui-tools 2.1.239 → 2.1.241
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/README.md +49 -3
- package/dist/index.cjs +731 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +233 -1
- package/dist/index.d.ts +233 -1
- package/dist/index.mjs +726 -2
- package/dist/index.mjs.map +1 -1
- package/package.json +15 -7
- package/src/tools/CodeEditor/CodeEditor.story.tsx +202 -0
- package/src/tools/CodeEditor/README.md +189 -0
- package/src/tools/CodeEditor/components/DiffEditor.tsx +123 -0
- package/src/tools/CodeEditor/components/Editor.tsx +222 -0
- package/src/tools/CodeEditor/components/index.ts +2 -0
- package/src/tools/CodeEditor/context/EditorProvider.tsx +194 -0
- package/src/tools/CodeEditor/context/index.ts +1 -0
- package/src/tools/CodeEditor/hooks/index.ts +4 -0
- package/src/tools/CodeEditor/hooks/useEditor.ts +36 -0
- package/src/tools/CodeEditor/hooks/useEditorTheme.ts +158 -0
- package/src/tools/CodeEditor/hooks/useLanguage.ts +29 -0
- package/src/tools/CodeEditor/hooks/useMonaco.ts +64 -0
- package/src/tools/CodeEditor/index.ts +16 -0
- package/src/tools/CodeEditor/lib/index.ts +2 -0
- package/src/tools/CodeEditor/lib/languages.ts +227 -0
- package/src/tools/CodeEditor/lib/themes.ts +78 -0
- package/src/tools/CodeEditor/types/index.ts +130 -0
- package/src/tools/CodeEditor/workers/index.ts +1 -0
- package/src/tools/CodeEditor/workers/setup.ts +58 -0
- package/src/tools/index.ts +25 -0
|
@@ -0,0 +1,222 @@
|
|
|
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 } 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
|
+
},
|
|
50
|
+
ref
|
|
51
|
+
) {
|
|
52
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
53
|
+
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
|
|
54
|
+
const { monaco, isLoading } = useMonaco();
|
|
55
|
+
const resolvedTheme = useEditorTheme(monaco, options.theme);
|
|
56
|
+
|
|
57
|
+
// Auto-height state
|
|
58
|
+
const [contentHeight, setContentHeight] = useState<number | null>(null);
|
|
59
|
+
|
|
60
|
+
const updateContentHeight = useCallback((editor: monaco.editor.IStandaloneCodeEditor) => {
|
|
61
|
+
if (!autoHeight) return;
|
|
62
|
+
const h = editor.getContentHeight();
|
|
63
|
+
setContentHeight(Math.min(Math.max(h, minHeight), maxHeight));
|
|
64
|
+
}, [autoHeight, minHeight, maxHeight]);
|
|
65
|
+
|
|
66
|
+
// Track internal changes to prevent cursor reset on prop sync
|
|
67
|
+
const isInternalChangeRef = useRef(false);
|
|
68
|
+
|
|
69
|
+
// Expose editor methods via ref
|
|
70
|
+
useImperativeHandle(ref, () => ({
|
|
71
|
+
getEditor: () => editorRef.current,
|
|
72
|
+
getValue: () => editorRef.current?.getValue() || '',
|
|
73
|
+
setValue: (val: string) => editorRef.current?.setValue(val),
|
|
74
|
+
focus: () => editorRef.current?.focus(),
|
|
75
|
+
}));
|
|
76
|
+
|
|
77
|
+
// Create editor
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!monaco || !containerRef.current || editorRef.current) return;
|
|
80
|
+
|
|
81
|
+
const editor = monaco.editor.create(containerRef.current, {
|
|
82
|
+
value,
|
|
83
|
+
language,
|
|
84
|
+
theme: resolvedTheme,
|
|
85
|
+
fontSize: options.fontSize || 14,
|
|
86
|
+
fontFamily: options.fontFamily || "'Fira Code', 'Consolas', monospace",
|
|
87
|
+
tabSize: options.tabSize || 2,
|
|
88
|
+
insertSpaces: options.insertSpaces !== false,
|
|
89
|
+
wordWrap: options.wordWrap || 'on',
|
|
90
|
+
minimap: { enabled: options.minimap !== false },
|
|
91
|
+
lineNumbers: options.lineNumbers || 'on',
|
|
92
|
+
readOnly: options.readOnly || false,
|
|
93
|
+
automaticLayout: true,
|
|
94
|
+
scrollBeyondLastLine: autoHeight ? false : false,
|
|
95
|
+
scrollbar: autoHeight ? { vertical: 'hidden', horizontal: 'auto' } : undefined,
|
|
96
|
+
overviewRulerLanes: autoHeight ? 0 : undefined,
|
|
97
|
+
padding: { top: 16, bottom: 16 },
|
|
98
|
+
renderLineHighlight: 'all',
|
|
99
|
+
cursorBlinking: 'smooth',
|
|
100
|
+
cursorSmoothCaretAnimation: 'on',
|
|
101
|
+
smoothScrolling: true,
|
|
102
|
+
bracketPairColorization: { enabled: true },
|
|
103
|
+
guides: {
|
|
104
|
+
bracketPairs: true,
|
|
105
|
+
indentation: true,
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
editorRef.current = editor;
|
|
110
|
+
|
|
111
|
+
// Setup change listener
|
|
112
|
+
if (onChange) {
|
|
113
|
+
editor.onDidChangeModelContent(() => {
|
|
114
|
+
// Mark as internal change to prevent cursor reset on prop sync
|
|
115
|
+
isInternalChangeRef.current = true;
|
|
116
|
+
onChange(editor.getValue());
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Auto-height: resize container to fit content
|
|
121
|
+
if (autoHeight) {
|
|
122
|
+
editor.onDidContentSizeChange(() => updateContentHeight(editor));
|
|
123
|
+
updateContentHeight(editor);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Call onMount callback
|
|
127
|
+
onMount?.(editor);
|
|
128
|
+
|
|
129
|
+
return () => {
|
|
130
|
+
editor.dispose();
|
|
131
|
+
editorRef.current = null;
|
|
132
|
+
};
|
|
133
|
+
}, [monaco]);
|
|
134
|
+
|
|
135
|
+
// Update value when prop changes (only for external changes)
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
const editor = editorRef.current;
|
|
138
|
+
if (!editor) return;
|
|
139
|
+
|
|
140
|
+
// Skip if this is an internal change (user typing) - prevents cursor reset
|
|
141
|
+
if (isInternalChangeRef.current) {
|
|
142
|
+
isInternalChangeRef.current = false;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const currentValue = editor.getValue();
|
|
147
|
+
if (value !== currentValue) {
|
|
148
|
+
// Save cursor position
|
|
149
|
+
const position = editor.getPosition();
|
|
150
|
+
const selections = editor.getSelections();
|
|
151
|
+
|
|
152
|
+
editor.setValue(value);
|
|
153
|
+
|
|
154
|
+
// Restore cursor position if possible
|
|
155
|
+
if (position) {
|
|
156
|
+
editor.setPosition(position);
|
|
157
|
+
}
|
|
158
|
+
if (selections && selections.length > 0) {
|
|
159
|
+
editor.setSelections(selections);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}, [value]);
|
|
163
|
+
|
|
164
|
+
// Update language when prop changes
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
const editor = editorRef.current;
|
|
167
|
+
if (!editor || !monaco) return;
|
|
168
|
+
|
|
169
|
+
const model = editor.getModel();
|
|
170
|
+
if (model) {
|
|
171
|
+
monaco.editor.setModelLanguage(model, language);
|
|
172
|
+
}
|
|
173
|
+
}, [language, monaco]);
|
|
174
|
+
|
|
175
|
+
// Update options when props change
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
const editor = editorRef.current;
|
|
178
|
+
if (!editor) return;
|
|
179
|
+
|
|
180
|
+
editor.updateOptions({
|
|
181
|
+
theme: resolvedTheme,
|
|
182
|
+
fontSize: options.fontSize,
|
|
183
|
+
readOnly: options.readOnly,
|
|
184
|
+
minimap: { enabled: options.minimap !== false },
|
|
185
|
+
wordWrap: options.wordWrap,
|
|
186
|
+
lineNumbers: options.lineNumbers,
|
|
187
|
+
});
|
|
188
|
+
}, [options, resolvedTheme]);
|
|
189
|
+
|
|
190
|
+
if (isLoading) {
|
|
191
|
+
return (
|
|
192
|
+
<div
|
|
193
|
+
className={className}
|
|
194
|
+
style={{
|
|
195
|
+
width,
|
|
196
|
+
height,
|
|
197
|
+
display: 'flex',
|
|
198
|
+
alignItems: 'center',
|
|
199
|
+
justifyContent: 'center',
|
|
200
|
+
backgroundColor: '#1e1e1e',
|
|
201
|
+
color: '#666',
|
|
202
|
+
}}
|
|
203
|
+
>
|
|
204
|
+
Loading editor...
|
|
205
|
+
</div>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const resolvedHeight = autoHeight && contentHeight != null ? contentHeight : height;
|
|
210
|
+
|
|
211
|
+
return (
|
|
212
|
+
<div
|
|
213
|
+
ref={containerRef}
|
|
214
|
+
className={className}
|
|
215
|
+
style={{
|
|
216
|
+
width,
|
|
217
|
+
height: resolvedHeight,
|
|
218
|
+
...(autoHeight && { minHeight: minHeight, maxHeight: maxHeight, overflow: 'hidden' }),
|
|
219
|
+
}}
|
|
220
|
+
/>
|
|
221
|
+
);
|
|
222
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
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
|
+
setActiveFilePath(newFiles[newIndex].path);
|
|
99
|
+
} else if (newFiles.length === 0) {
|
|
100
|
+
setActiveFilePath(null);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return newFiles;
|
|
104
|
+
});
|
|
105
|
+
},
|
|
106
|
+
[activeFilePath]
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// Set active file
|
|
110
|
+
const setActiveFile = useCallback((path: string) => {
|
|
111
|
+
setActiveFilePath(path);
|
|
112
|
+
}, []);
|
|
113
|
+
|
|
114
|
+
// Update file content
|
|
115
|
+
const updateContent = useCallback((path: string, content: string) => {
|
|
116
|
+
setOpenFiles((files) =>
|
|
117
|
+
files.map((f) =>
|
|
118
|
+
f.path === path
|
|
119
|
+
? { ...f, content, isDirty: true }
|
|
120
|
+
: f
|
|
121
|
+
)
|
|
122
|
+
);
|
|
123
|
+
}, []);
|
|
124
|
+
|
|
125
|
+
// Save file
|
|
126
|
+
const saveFile = useCallback(
|
|
127
|
+
async (path: string) => {
|
|
128
|
+
const file = openFiles.find((f) => f.path === path);
|
|
129
|
+
if (!file) return;
|
|
130
|
+
|
|
131
|
+
if (onSave) {
|
|
132
|
+
await onSave(path, file.content);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Mark as not dirty
|
|
136
|
+
setOpenFiles((files) =>
|
|
137
|
+
files.map((f) =>
|
|
138
|
+
f.path === path ? { ...f, isDirty: false } : f
|
|
139
|
+
)
|
|
140
|
+
);
|
|
141
|
+
},
|
|
142
|
+
[openFiles, onSave]
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
// Check if file is dirty
|
|
146
|
+
const isDirty = useCallback(
|
|
147
|
+
(path: string) => {
|
|
148
|
+
const file = openFiles.find((f) => f.path === path);
|
|
149
|
+
return file?.isDirty || false;
|
|
150
|
+
},
|
|
151
|
+
[openFiles]
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
// Get file content
|
|
155
|
+
const getContent = useCallback(
|
|
156
|
+
(path: string) => {
|
|
157
|
+
const file = openFiles.find((f) => f.path === path);
|
|
158
|
+
return file?.content || null;
|
|
159
|
+
},
|
|
160
|
+
[openFiles]
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
// Get file by path
|
|
164
|
+
const getFile = useCallback(
|
|
165
|
+
(path: string) => {
|
|
166
|
+
return openFiles.find((f) => f.path === path) || null;
|
|
167
|
+
},
|
|
168
|
+
[openFiles]
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const value: EditorContextValue = {
|
|
172
|
+
openFiles,
|
|
173
|
+
activeFile,
|
|
174
|
+
monaco,
|
|
175
|
+
editor,
|
|
176
|
+
isReady: monaco !== null && editor !== null,
|
|
177
|
+
|
|
178
|
+
openFile,
|
|
179
|
+
closeFile,
|
|
180
|
+
setActiveFile,
|
|
181
|
+
updateContent,
|
|
182
|
+
saveFile,
|
|
183
|
+
|
|
184
|
+
isDirty,
|
|
185
|
+
getContent,
|
|
186
|
+
getFile,
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
return (
|
|
190
|
+
<EditorContext.Provider value={value}>
|
|
191
|
+
{children}
|
|
192
|
+
</EditorContext.Provider>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { EditorProvider, useEditorContext } from './EditorProvider';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback } from 'react';
|
|
4
|
+
import type * as monaco from 'monaco-editor';
|
|
5
|
+
|
|
6
|
+
import type { UseEditorReturn } from '../types';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Hook to manage editor instance reference
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```tsx
|
|
13
|
+
* const { editor, isReady, setEditor } = useEditor();
|
|
14
|
+
*
|
|
15
|
+
* // Pass setEditor to Editor component's onMount
|
|
16
|
+
* <Editor onMount={setEditor} />
|
|
17
|
+
*
|
|
18
|
+
* // Use editor when ready
|
|
19
|
+
* if (isReady) {
|
|
20
|
+
* editor.getModel()?.getValue();
|
|
21
|
+
* }
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export function useEditor(): UseEditorReturn {
|
|
25
|
+
const [editor, setEditorState] = useState<monaco.editor.IStandaloneCodeEditor | null>(null);
|
|
26
|
+
|
|
27
|
+
const setEditor = useCallback((editorInstance: monaco.editor.IStandaloneCodeEditor | null) => {
|
|
28
|
+
setEditorState(editorInstance);
|
|
29
|
+
}, []);
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
editor,
|
|
33
|
+
isReady: editor !== null,
|
|
34
|
+
setEditor,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from 'react';
|
|
4
|
+
import { useResolvedTheme } from '@djangocfg/ui-core/hooks';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Hook that syncs Monaco editor theme with the app theme.
|
|
8
|
+
*
|
|
9
|
+
* Reads CSS variables from the document and creates custom Monaco themes
|
|
10
|
+
* that match the current light/dark mode. Falls back to built-in 'vs' / 'vs-dark'.
|
|
11
|
+
*
|
|
12
|
+
* @param monaco - Monaco namespace (null during loading)
|
|
13
|
+
* @param themeOverride - Optional explicit theme name (skips auto-detection)
|
|
14
|
+
* @returns Resolved Monaco theme name to pass to editor options
|
|
15
|
+
*/
|
|
16
|
+
export function useEditorTheme(
|
|
17
|
+
monaco: typeof import('monaco-editor') | null,
|
|
18
|
+
themeOverride?: string,
|
|
19
|
+
): string {
|
|
20
|
+
const appTheme = useResolvedTheme();
|
|
21
|
+
const registered = useRef(false);
|
|
22
|
+
|
|
23
|
+
// Register custom themes once Monaco is loaded
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (!monaco || registered.current) return;
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const colors = _readCSSColors();
|
|
29
|
+
|
|
30
|
+
monaco.editor.defineTheme('app-dark', {
|
|
31
|
+
base: 'vs-dark',
|
|
32
|
+
inherit: true,
|
|
33
|
+
rules: [
|
|
34
|
+
{ token: 'comment', foreground: '6A9955', fontStyle: 'italic' },
|
|
35
|
+
{ token: 'keyword', foreground: 'C586C0' },
|
|
36
|
+
{ token: 'string', foreground: 'CE9178' },
|
|
37
|
+
{ token: 'number', foreground: 'B5CEA8' },
|
|
38
|
+
{ token: 'type', foreground: '4EC9B0' },
|
|
39
|
+
{ token: 'function', foreground: 'DCDCAA' },
|
|
40
|
+
{ token: 'variable', foreground: '9CDCFE' },
|
|
41
|
+
],
|
|
42
|
+
colors: {
|
|
43
|
+
'editor.background': colors.background,
|
|
44
|
+
'editor.foreground': colors.foreground,
|
|
45
|
+
'editor.lineHighlightBackground': colors.lineHighlight,
|
|
46
|
+
'editor.selectionBackground': colors.selection,
|
|
47
|
+
'editorCursor.foreground': colors.foreground,
|
|
48
|
+
'editorLineNumber.foreground': colors.mutedForeground,
|
|
49
|
+
'editorWidget.background': colors.card,
|
|
50
|
+
'editorWidget.border': colors.border,
|
|
51
|
+
'input.background': colors.card,
|
|
52
|
+
'dropdown.background': colors.card,
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
monaco.editor.defineTheme('app-light', {
|
|
57
|
+
base: 'vs',
|
|
58
|
+
inherit: true,
|
|
59
|
+
rules: [
|
|
60
|
+
{ token: 'comment', foreground: '008000', fontStyle: 'italic' },
|
|
61
|
+
{ token: 'keyword', foreground: 'AF00DB' },
|
|
62
|
+
{ token: 'string', foreground: 'A31515' },
|
|
63
|
+
{ token: 'number', foreground: '098658' },
|
|
64
|
+
{ token: 'type', foreground: '267F99' },
|
|
65
|
+
{ token: 'function', foreground: '795E26' },
|
|
66
|
+
{ token: 'variable', foreground: '001080' },
|
|
67
|
+
],
|
|
68
|
+
colors: {
|
|
69
|
+
'editor.background': colors.backgroundLight,
|
|
70
|
+
'editor.foreground': colors.foregroundLight,
|
|
71
|
+
'editor.lineHighlightBackground': colors.lineHighlightLight,
|
|
72
|
+
'editor.selectionBackground': colors.selectionLight,
|
|
73
|
+
'editorLineNumber.foreground': colors.mutedForegroundLight,
|
|
74
|
+
'editorWidget.background': colors.cardLight,
|
|
75
|
+
'editorWidget.border': colors.borderLight,
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
registered.current = true;
|
|
80
|
+
} catch {
|
|
81
|
+
// Fallback — use built-in themes
|
|
82
|
+
}
|
|
83
|
+
}, [monaco]);
|
|
84
|
+
|
|
85
|
+
// If explicit override provided, use it
|
|
86
|
+
if (themeOverride) return themeOverride;
|
|
87
|
+
|
|
88
|
+
// Use registered app themes if available, fallback to built-in
|
|
89
|
+
if (registered.current) {
|
|
90
|
+
return appTheme === 'dark' ? 'app-dark' : 'app-light';
|
|
91
|
+
}
|
|
92
|
+
return appTheme === 'dark' ? 'vs-dark' : 'vs';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
/** Read CSS variables and convert HSL to hex for Monaco */
|
|
97
|
+
function _readCSSColors() {
|
|
98
|
+
const get = (varName: string): string => {
|
|
99
|
+
if (typeof document === 'undefined') return '';
|
|
100
|
+
return getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const hslToHex = (hsl: string): string => {
|
|
104
|
+
if (!hsl) return '';
|
|
105
|
+
// CSS vars are "0 0% 96%" — strip % before parsing
|
|
106
|
+
const parts = hsl.split(/\s+/).map(s => parseFloat(s.replace('%', '')));
|
|
107
|
+
if (parts.length < 3 || parts.some(isNaN)) return '';
|
|
108
|
+
const [h, s, l] = [parts[0], parts[1] / 100, parts[2] / 100];
|
|
109
|
+
|
|
110
|
+
const a = s * Math.min(l, 1 - l);
|
|
111
|
+
const f = (n: number) => {
|
|
112
|
+
const k = (n + h / 30) % 12;
|
|
113
|
+
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
|
|
114
|
+
return Math.round(255 * color).toString(16).padStart(2, '0');
|
|
115
|
+
};
|
|
116
|
+
return `#${f(0)}${f(8)}${f(4)}`;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// Read current (dark) theme CSS variables
|
|
120
|
+
const background = hslToHex(get('--background')) || '#0a0a0a';
|
|
121
|
+
const foreground = hslToHex(get('--foreground')) || '#f5f5f5';
|
|
122
|
+
const card = hslToHex(get('--card')) || '#141414';
|
|
123
|
+
const border = hslToHex(get('--border')) || '#262626';
|
|
124
|
+
const mutedForeground = hslToHex(get('--muted-foreground')) || '#858585';
|
|
125
|
+
|
|
126
|
+
// Compute derived colors
|
|
127
|
+
const lineHighlight = _adjustBrightness(background, 10);
|
|
128
|
+
const primary = hslToHex(get('--primary'));
|
|
129
|
+
const selection = primary ? _adjustBrightness(primary, -40) : '#264F78';
|
|
130
|
+
|
|
131
|
+
// Light theme colors (hardcoded since we can't switch CSS context)
|
|
132
|
+
return {
|
|
133
|
+
background,
|
|
134
|
+
foreground,
|
|
135
|
+
card,
|
|
136
|
+
border,
|
|
137
|
+
mutedForeground,
|
|
138
|
+
lineHighlight,
|
|
139
|
+
selection,
|
|
140
|
+
// Light variants
|
|
141
|
+
backgroundLight: '#ffffff',
|
|
142
|
+
foregroundLight: '#1a1a1a',
|
|
143
|
+
cardLight: '#ffffff',
|
|
144
|
+
borderLight: '#e5e5e5',
|
|
145
|
+
mutedForegroundLight: '#737373',
|
|
146
|
+
lineHighlightLight: '#f5f5f5',
|
|
147
|
+
selectionLight: '#ADD6FF',
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function _adjustBrightness(hex: string, amount: number): string {
|
|
152
|
+
const num = parseInt(hex.replace('#', ''), 16);
|
|
153
|
+
const r = Math.min(255, Math.max(0, ((num >> 16) & 0xff) + amount));
|
|
154
|
+
const g = Math.min(255, Math.max(0, ((num >> 8) & 0xff) + amount));
|
|
155
|
+
const b = Math.min(255, Math.max(0, (num & 0xff) + amount));
|
|
156
|
+
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMemo } from 'react';
|
|
4
|
+
import { getLanguageByFilename } from '../lib/languages';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Hook to detect language from filename
|
|
8
|
+
*
|
|
9
|
+
* @param filename - File name or path
|
|
10
|
+
* @returns Monaco language ID
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```tsx
|
|
14
|
+
* const language = useLanguage('app.tsx');
|
|
15
|
+
* // Returns: 'typescript'
|
|
16
|
+
*
|
|
17
|
+
* const language2 = useLanguage('/path/to/Dockerfile');
|
|
18
|
+
* // Returns: 'dockerfile'
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export function useLanguage(filename: string | undefined): string {
|
|
22
|
+
return useMemo(() => {
|
|
23
|
+
if (!filename) return 'plaintext';
|
|
24
|
+
|
|
25
|
+
// Extract basename from path
|
|
26
|
+
const basename = filename.split('/').pop() || filename;
|
|
27
|
+
return getLanguageByFilename(basename);
|
|
28
|
+
}, [filename]);
|
|
29
|
+
}
|