Skip to content

Commit 8bb75f9

Browse files
authored
feat: add custom Editor component (#16)
* feat: add custom Editor component and integrate with HtmlEditor - Introduced a new Editor component for code editing with language support. - Updated HtmlEditor to utilize the new Editor component for HTML content editing. - Added LanguageSelector for selecting programming languages in the Editor. - Enhanced state management in HtmlEditor to load content from customData. - Implemented auto-save functionality in the Editor to persist changes. - Updated styles for the new Editor component and its toolbar. * feat: add code editor option to MainMenu
1 parent 496b43a commit 8bb75f9

File tree

8 files changed

+532
-20
lines changed

8 files changed

+532
-20
lines changed

src/frontend/src/CustomEmbeddableRenderer.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import {
55
Dashboard,
66
StateIndicator,
77
ControlButton,
8-
HtmlEditor
8+
HtmlEditor,
9+
Editor
910
} from './pad';
1011
import { ActionButton } from './pad/buttons';
1112

@@ -20,8 +21,9 @@ export const renderCustomEmbeddable = (
2021

2122
switch (path) {
2223
case 'html':
23-
case 'editor':
2424
return <HtmlEditor element={element} appState={appState} excalidrawAPI={excalidrawAPI} />;
25+
case 'editor':
26+
return <Editor element={element} appState={appState} excalidrawAPI={excalidrawAPI} />;
2527
case 'state':
2628
return <StateIndicator />;
2729
case 'control':
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
import React, { useRef, useState, useEffect, useCallback } from 'react';
2+
import MonacoEditor from '@monaco-editor/react';
3+
import LanguageSelector from './LanguageSelector';
4+
import '../../styles/Editor.scss';
5+
6+
interface EditorProps {
7+
defaultValue?: string;
8+
language?: string;
9+
theme?: string;
10+
height?: string | number;
11+
options?: Record<string, any>;
12+
onChange?: (value: string | undefined) => void;
13+
onMount?: (editor: any) => void;
14+
onLanguageChange?: (language: string) => void; // Callback for language changes
15+
className?: string;
16+
showLanguageSelector?: boolean;
17+
element?: any; // Excalidraw element
18+
excalidrawAPI?: any; // Excalidraw API instance
19+
autoSaveInterval?: number; // Interval in ms to auto-save content to customData
20+
}
21+
22+
const Editor: React.FC<EditorProps> = ({
23+
defaultValue = '',
24+
language = 'javascript',
25+
theme = 'vs-dark',
26+
height = '100%',
27+
options = {
28+
minimap: { enabled: false },
29+
scrollBeyondLastLine: false,
30+
fontSize: 12,
31+
automaticLayout: true
32+
},
33+
onChange,
34+
onMount,
35+
onLanguageChange,
36+
className = 'monaco-editor-container',
37+
showLanguageSelector = true,
38+
element,
39+
excalidrawAPI,
40+
autoSaveInterval = 2000 // Default to 2 seconds
41+
}) => {
42+
const editorRef = useRef<any>(null);
43+
// Initialize currentLanguage from element's customData if available, otherwise use the prop
44+
const [currentLanguage, setCurrentLanguage] = useState(
45+
element?.customData?.editorLanguage || language
46+
);
47+
const contentRef = useRef(defaultValue);
48+
const lastSavedContentRef = useRef('');
49+
const lastSavedLanguageRef = useRef(language);
50+
const elementIdRef = useRef(element?.id);
51+
const isInitialMountRef = useRef(true);
52+
53+
// Special effect to handle initial mount and force language reload
54+
useEffect(() => {
55+
// Only run this effect when the editor is mounted
56+
if (!editorRef.current) return;
57+
58+
if (isInitialMountRef.current && element?.customData?.editorLanguage) {
59+
// Force a language reload after initial mount
60+
const model = editorRef.current.getModel();
61+
if (model) {
62+
// Force Monaco to update the language model immediately
63+
model.setLanguage(element.customData.editorLanguage);
64+
65+
// Set a small timeout to force Monaco to re-process the content with the correct language
66+
setTimeout(() => {
67+
// This triggers Monaco to re-process the content with the correct language
68+
const currentValue = model.getValue();
69+
model.setValue(currentValue);
70+
}, 50);
71+
}
72+
73+
isInitialMountRef.current = false;
74+
}
75+
}, [element?.customData?.editorLanguage]);
76+
77+
// Update editor content when element changes (e.g., when cloned or pasted)
78+
useEffect(() => {
79+
if (!editorRef.current || !element) return;
80+
81+
// Check if element ID has changed (indicating a new element)
82+
if (element.id !== elementIdRef.current) {
83+
elementIdRef.current = element.id;
84+
85+
// First update language if needed - do this before setting content
86+
if (element.customData?.editorLanguage) {
87+
setCurrentLanguage(element.customData.editorLanguage);
88+
lastSavedLanguageRef.current = element.customData.editorLanguage;
89+
90+
// Force Monaco to update the language model immediately
91+
const model = editorRef.current.getModel();
92+
if (model) {
93+
model.setLanguage(element.customData.editorLanguage);
94+
95+
// Then update the editor content after language is set
96+
if (element.customData?.editorContent) {
97+
model.setValue(element.customData.editorContent);
98+
contentRef.current = element.customData.editorContent;
99+
lastSavedContentRef.current = element.customData.editorContent;
100+
101+
// Force a re-processing of the content with the new language after a short delay
102+
// This is crucial for fixing linting errors when pasting/cloning elements
103+
setTimeout(() => {
104+
const currentValue = model.getValue();
105+
model.setValue(currentValue);
106+
}, 50);
107+
}
108+
} else {
109+
// Fallback if model isn't available
110+
if (element.customData?.editorContent) {
111+
editorRef.current.setValue(element.customData.editorContent);
112+
contentRef.current = element.customData.editorContent;
113+
lastSavedContentRef.current = element.customData.editorContent;
114+
}
115+
}
116+
} else if (element.customData?.editorContent) {
117+
// If no language change but content exists
118+
editorRef.current.setValue(element.customData.editorContent);
119+
contentRef.current = element.customData.editorContent;
120+
lastSavedContentRef.current = element.customData.editorContent;
121+
}
122+
}
123+
}, [element, showLanguageSelector]);
124+
125+
const handleEditorDidMount = (editor: any) => {
126+
editorRef.current = editor;
127+
128+
// First check and set the language before setting content
129+
// This ensures Monaco uses the correct language mode from the start
130+
if (element?.customData?.editorLanguage) {
131+
setCurrentLanguage(element.customData.editorLanguage);
132+
lastSavedLanguageRef.current = element.customData.editorLanguage;
133+
134+
const model = editor.getModel();
135+
if (model) {
136+
// Force Monaco to update the language model immediately
137+
model.setLanguage(element.customData.editorLanguage);
138+
139+
// Now set the content after language is properly initialized
140+
if (element?.customData?.editorContent) {
141+
model.setValue(element.customData.editorContent);
142+
contentRef.current = element.customData.editorContent;
143+
lastSavedContentRef.current = element.customData.editorContent;
144+
145+
// Force a re-processing of the content with the correct language after a short delay
146+
setTimeout(() => {
147+
const currentValue = model.getValue();
148+
model.setValue(currentValue);
149+
}, 50);
150+
}
151+
} else {
152+
// Fallback if model isn't available
153+
if (element?.customData?.editorContent) {
154+
editor.setValue(element.customData.editorContent);
155+
contentRef.current = element.customData.editorContent;
156+
lastSavedContentRef.current = element.customData.editorContent;
157+
}
158+
}
159+
} else if (element?.customData?.editorContent) {
160+
// If no language change but content exists
161+
editor.setValue(element.customData.editorContent);
162+
contentRef.current = element.customData.editorContent;
163+
lastSavedContentRef.current = element.customData.editorContent;
164+
}
165+
166+
if (onMount) {
167+
onMount(editor);
168+
}
169+
};
170+
171+
// Update editor content when it changes
172+
const handleEditorChange = (value: string | undefined) => {
173+
if (value !== undefined) {
174+
contentRef.current = value;
175+
}
176+
if (onChange) {
177+
onChange(value);
178+
}
179+
};
180+
181+
// Save editor content to element's customData
182+
const saveContentToCustomData = useCallback(() => {
183+
if (!element || !excalidrawAPI || !editorRef.current) return;
184+
185+
// Get the current content from the editor
186+
const content = editorRef.current.getValue();
187+
188+
// Only save if content or language has changed
189+
if (content === lastSavedContentRef.current &&
190+
currentLanguage === lastSavedLanguageRef.current) {
191+
return;
192+
}
193+
194+
// Update refs to track what we've saved
195+
lastSavedContentRef.current = content;
196+
lastSavedLanguageRef.current = currentLanguage;
197+
198+
// Get all elements from the scene
199+
const elements = excalidrawAPI.getSceneElements();
200+
201+
// Find and update the element
202+
const updatedElements = elements.map(el => {
203+
if (el.id === element.id) {
204+
// Create a new customData object with the updated editorContent
205+
const customData = {
206+
...(el.customData || {}),
207+
editorContent: content,
208+
editorLanguage: currentLanguage
209+
};
210+
211+
return { ...el, customData };
212+
}
213+
return el;
214+
});
215+
216+
// Update the scene with the modified elements
217+
excalidrawAPI.updateScene({
218+
elements: updatedElements
219+
});
220+
}, [element, excalidrawAPI, currentLanguage]);
221+
222+
// Set up auto-save interval
223+
useEffect(() => {
224+
if (!element || !excalidrawAPI) return;
225+
226+
// Set up interval for auto-saving
227+
const intervalId = setInterval(saveContentToCustomData, autoSaveInterval);
228+
229+
// Clean up interval on unmount
230+
return () => {
231+
clearInterval(intervalId);
232+
// Save one last time when unmounting
233+
saveContentToCustomData();
234+
};
235+
}, [element, excalidrawAPI, saveContentToCustomData, autoSaveInterval]);
236+
237+
const handleLanguageChange = (newLanguage: string) => {
238+
setCurrentLanguage(newLanguage);
239+
240+
// Force Monaco to update the language model immediately when language is changed
241+
if (editorRef.current) {
242+
const model = editorRef.current.getModel();
243+
if (model) {
244+
// Update the language
245+
model.setLanguage(newLanguage);
246+
247+
// Force a re-processing of the content with the new language
248+
// This is what fixes the linting errors when manually switching languages
249+
setTimeout(() => {
250+
const currentValue = model.getValue();
251+
model.setValue(currentValue);
252+
}, 10);
253+
}
254+
}
255+
256+
// Notify parent component about language change
257+
if (onLanguageChange) {
258+
onLanguageChange(newLanguage);
259+
}
260+
};
261+
262+
return (
263+
<div className="editor-wrapper">
264+
<MonacoEditor
265+
height={height}
266+
language={currentLanguage}
267+
defaultValue={defaultValue}
268+
theme={theme}
269+
options={options}
270+
onMount={handleEditorDidMount}
271+
onChange={handleEditorChange}
272+
className={className}
273+
/>
274+
{showLanguageSelector && (
275+
<div className="editor-toolbar">
276+
<LanguageSelector
277+
value={currentLanguage}
278+
onChange={handleLanguageChange}
279+
/>
280+
</div>
281+
)}
282+
</div>
283+
);
284+
};
285+
286+
export default Editor;

src/frontend/src/pad/editors/HtmlEditor.tsx

Lines changed: 39 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import React, { useState, useRef } from 'react';
1+
import React, { useState, useRef, useEffect } from 'react';
22
import type { NonDeleted, ExcalidrawEmbeddableElement } from '@atyrode/excalidraw/element/types';
33
import type { AppState } from '@atyrode/excalidraw/types';
4-
import Editor from '@monaco-editor/react';
4+
import Editor from './Editor';
55
import { ExcalidrawElementFactory } from '../../lib/ExcalidrawElementFactory';
66
import '../../styles/HtmlEditor.scss';
77

@@ -17,10 +17,32 @@ export const HtmlEditor: React.FC<HtmlEditorProps> = ({
1717
excalidrawAPI
1818
}) => {
1919
const [createNew, setCreateNew] = useState(true);
20-
const [editorValue, setEditorValue] = useState('<button style="padding: 8px; background: #5294f6; color: white; border: none; border-radius: 4px;">Example Button</button>');
20+
const defaultHtml = '<button style="padding: 8px; background: #5294f6; color: white; border: none; border-radius: 4px;">Example Button</button>';
21+
const [editorValue, setEditorValue] = useState(
22+
element.customData?.editorContent || defaultHtml
23+
);
2124
const editorRef = useRef<any>(null);
25+
const elementIdRef = useRef(element.id);
26+
27+
// Load content from customData when element changes (e.g., when cloned or pasted)
28+
useEffect(() => {
29+
// Check if element ID has changed (indicating a new element)
30+
if (element.id !== elementIdRef.current) {
31+
elementIdRef.current = element.id;
32+
33+
// If element has customData with editorContent, update the state
34+
if (element.customData?.editorContent) {
35+
setEditorValue(element.customData.editorContent);
36+
} else {
37+
setEditorValue(defaultHtml);
38+
}
39+
40+
// Note: We don't need to update language here since HtmlEditor always uses 'html'
41+
// But we still save it in customData for consistency
42+
}
43+
}, [element.id, element.customData, defaultHtml]);
2244

23-
const handleEditorDidMount = (editor: any) => {
45+
const handleEditorMount = (editor: any) => {
2446
editorRef.current = editor;
2547
};
2648

@@ -30,14 +52,21 @@ export const HtmlEditor: React.FC<HtmlEditorProps> = ({
3052
const htmlContent = editorRef.current.getValue();
3153
const elements = excalidrawAPI.getSceneElements();
3254

55+
// Get the current editor content
56+
const currentContent = editorRef.current.getValue();
57+
3358
// Create a new iframe element with the HTML content using our factory
3459
const newElement = ExcalidrawElementFactory.createIframeElement({
3560
x: createNew ? element.x + element.width + 20 : element.x,
3661
y: createNew ? element.y : element.y,
3762
width: element.width,
3863
height: element.height,
3964
htmlContent: htmlContent,
40-
id: createNew ? undefined : element.id
65+
id: createNew ? undefined : element.id,
66+
customData: {
67+
editorContent: currentContent,
68+
editorLanguage: 'html' // Always set to html for HtmlEditor
69+
}
4170
});
4271

4372
// If creating a new element, add it to the scene
@@ -69,18 +98,13 @@ export const HtmlEditor: React.FC<HtmlEditorProps> = ({
6998
<div className="html-editor-content">
7099
<Editor
71100
height="100%"
72-
defaultLanguage="html"
101+
language="html"
73102
defaultValue={editorValue}
74-
theme="vs-dark"
75-
options={{
76-
minimap: { enabled: false },
77-
scrollBeyondLastLine: false,
78-
fontSize: 12,
79-
automaticLayout: true
80-
}}
81-
onMount={handleEditorDidMount}
82103
onChange={(value) => value && setEditorValue(value)}
83-
className="monaco-editor-container"
104+
onMount={handleEditorMount}
105+
element={element}
106+
excalidrawAPI={excalidrawAPI}
107+
showLanguageSelector={false}
84108
/>
85109
<div className="html-editor-controls">
86110
<label>

0 commit comments

Comments
 (0)