@djangocfg/ui-tools 2.1.239 → 2.1.240

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.
@@ -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,2 @@
1
+ export { Editor } from './Editor';
2
+ export { DiffEditor } from './DiffEditor';
@@ -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,4 @@
1
+ export { useMonaco } from './useMonaco';
2
+ export { useEditor } from './useEditor';
3
+ export { useLanguage } from './useLanguage';
4
+ export { useEditorTheme } from './useEditorTheme';
@@ -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
+ }