Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/frontend/src/CustomEmbeddableRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import {
Dashboard,
StateIndicator,
ControlButton,
HtmlEditor
HtmlEditor,
Editor
} from './pad';
import { ActionButton } from './pad/buttons';

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

switch (path) {
case 'html':
case 'editor':
return <HtmlEditor element={element} appState={appState} excalidrawAPI={excalidrawAPI} />;
case 'editor':
return <Editor element={element} appState={appState} excalidrawAPI={excalidrawAPI} />;
case 'state':
return <StateIndicator />;
case 'control':
Expand Down
286 changes: 286 additions & 0 deletions src/frontend/src/pad/editors/Editor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,286 @@
import React, { useRef, useState, useEffect, useCallback } from 'react';
import MonacoEditor from '@monaco-editor/react';
import LanguageSelector from './LanguageSelector';
import '../../styles/Editor.scss';

interface EditorProps {
defaultValue?: string;
language?: string;
theme?: string;
height?: string | number;
options?: Record<string, any>;
onChange?: (value: string | undefined) => void;
onMount?: (editor: any) => void;
onLanguageChange?: (language: string) => void; // Callback for language changes
className?: string;
showLanguageSelector?: boolean;
element?: any; // Excalidraw element
excalidrawAPI?: any; // Excalidraw API instance
autoSaveInterval?: number; // Interval in ms to auto-save content to customData
}

const Editor: React.FC<EditorProps> = ({
defaultValue = '',
language = 'javascript',
theme = 'vs-dark',
height = '100%',
options = {
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontSize: 12,
automaticLayout: true
},
onChange,
onMount,
onLanguageChange,
className = 'monaco-editor-container',
showLanguageSelector = true,
element,
excalidrawAPI,
autoSaveInterval = 2000 // Default to 2 seconds
}) => {
const editorRef = useRef<any>(null);
// Initialize currentLanguage from element's customData if available, otherwise use the prop
const [currentLanguage, setCurrentLanguage] = useState(
element?.customData?.editorLanguage || language
);
const contentRef = useRef(defaultValue);
const lastSavedContentRef = useRef('');
const lastSavedLanguageRef = useRef(language);
const elementIdRef = useRef(element?.id);
const isInitialMountRef = useRef(true);

// Special effect to handle initial mount and force language reload
useEffect(() => {
// Only run this effect when the editor is mounted
if (!editorRef.current) return;

if (isInitialMountRef.current && element?.customData?.editorLanguage) {
// Force a language reload after initial mount
const model = editorRef.current.getModel();
if (model) {
// Force Monaco to update the language model immediately
model.setLanguage(element.customData.editorLanguage);

// Set a small timeout to force Monaco to re-process the content with the correct language
setTimeout(() => {
// This triggers Monaco to re-process the content with the correct language
const currentValue = model.getValue();
model.setValue(currentValue);
}, 50);
}

isInitialMountRef.current = false;
}
}, [element?.customData?.editorLanguage]);

// Update editor content when element changes (e.g., when cloned or pasted)
useEffect(() => {
if (!editorRef.current || !element) return;

// Check if element ID has changed (indicating a new element)
if (element.id !== elementIdRef.current) {
elementIdRef.current = element.id;

// First update language if needed - do this before setting content
if (element.customData?.editorLanguage) {
setCurrentLanguage(element.customData.editorLanguage);
lastSavedLanguageRef.current = element.customData.editorLanguage;

// Force Monaco to update the language model immediately
const model = editorRef.current.getModel();
if (model) {
model.setLanguage(element.customData.editorLanguage);

// Then update the editor content after language is set
if (element.customData?.editorContent) {
model.setValue(element.customData.editorContent);
contentRef.current = element.customData.editorContent;
lastSavedContentRef.current = element.customData.editorContent;

// Force a re-processing of the content with the new language after a short delay
// This is crucial for fixing linting errors when pasting/cloning elements
setTimeout(() => {
const currentValue = model.getValue();
model.setValue(currentValue);
}, 50);
}
} else {
// Fallback if model isn't available
if (element.customData?.editorContent) {
editorRef.current.setValue(element.customData.editorContent);
contentRef.current = element.customData.editorContent;
lastSavedContentRef.current = element.customData.editorContent;
}
}
} else if (element.customData?.editorContent) {
// If no language change but content exists
editorRef.current.setValue(element.customData.editorContent);
contentRef.current = element.customData.editorContent;
lastSavedContentRef.current = element.customData.editorContent;
}
}
}, [element, showLanguageSelector]);

const handleEditorDidMount = (editor: any) => {
editorRef.current = editor;

// First check and set the language before setting content
// This ensures Monaco uses the correct language mode from the start
if (element?.customData?.editorLanguage) {
setCurrentLanguage(element.customData.editorLanguage);
lastSavedLanguageRef.current = element.customData.editorLanguage;

const model = editor.getModel();
if (model) {
// Force Monaco to update the language model immediately
model.setLanguage(element.customData.editorLanguage);

// Now set the content after language is properly initialized
if (element?.customData?.editorContent) {
model.setValue(element.customData.editorContent);
contentRef.current = element.customData.editorContent;
lastSavedContentRef.current = element.customData.editorContent;

// Force a re-processing of the content with the correct language after a short delay
setTimeout(() => {
const currentValue = model.getValue();
model.setValue(currentValue);
}, 50);
}
} else {
// Fallback if model isn't available
if (element?.customData?.editorContent) {
editor.setValue(element.customData.editorContent);
contentRef.current = element.customData.editorContent;
lastSavedContentRef.current = element.customData.editorContent;
}
}
} else if (element?.customData?.editorContent) {
// If no language change but content exists
editor.setValue(element.customData.editorContent);
contentRef.current = element.customData.editorContent;
lastSavedContentRef.current = element.customData.editorContent;
}

if (onMount) {
onMount(editor);
}
};

// Update editor content when it changes
const handleEditorChange = (value: string | undefined) => {
if (value !== undefined) {
contentRef.current = value;
}
if (onChange) {
onChange(value);
}
};

// Save editor content to element's customData
const saveContentToCustomData = useCallback(() => {
if (!element || !excalidrawAPI || !editorRef.current) return;

// Get the current content from the editor
const content = editorRef.current.getValue();

// Only save if content or language has changed
if (content === lastSavedContentRef.current &&
currentLanguage === lastSavedLanguageRef.current) {
return;
}

// Update refs to track what we've saved
lastSavedContentRef.current = content;
lastSavedLanguageRef.current = currentLanguage;

// Get all elements from the scene
const elements = excalidrawAPI.getSceneElements();

// Find and update the element
const updatedElements = elements.map(el => {
if (el.id === element.id) {
// Create a new customData object with the updated editorContent
const customData = {
...(el.customData || {}),
editorContent: content,
editorLanguage: currentLanguage
};

return { ...el, customData };
}
return el;
});

// Update the scene with the modified elements
excalidrawAPI.updateScene({
elements: updatedElements
});
}, [element, excalidrawAPI, currentLanguage]);

// Set up auto-save interval
useEffect(() => {
if (!element || !excalidrawAPI) return;

// Set up interval for auto-saving
const intervalId = setInterval(saveContentToCustomData, autoSaveInterval);

// Clean up interval on unmount
return () => {
clearInterval(intervalId);
// Save one last time when unmounting
saveContentToCustomData();
};
}, [element, excalidrawAPI, saveContentToCustomData, autoSaveInterval]);

const handleLanguageChange = (newLanguage: string) => {
setCurrentLanguage(newLanguage);

// Force Monaco to update the language model immediately when language is changed
if (editorRef.current) {
const model = editorRef.current.getModel();
if (model) {
// Update the language
model.setLanguage(newLanguage);

// Force a re-processing of the content with the new language
// This is what fixes the linting errors when manually switching languages
setTimeout(() => {
const currentValue = model.getValue();
model.setValue(currentValue);
}, 10);
}
}

// Notify parent component about language change
if (onLanguageChange) {
onLanguageChange(newLanguage);
}
};

return (
<div className="editor-wrapper">
<MonacoEditor
height={height}
language={currentLanguage}
defaultValue={defaultValue}
theme={theme}
options={options}
onMount={handleEditorDidMount}
onChange={handleEditorChange}
className={className}
/>
{showLanguageSelector && (
<div className="editor-toolbar">
<LanguageSelector
value={currentLanguage}
onChange={handleLanguageChange}
/>
</div>
)}
</div>
);
};

export default Editor;
54 changes: 39 additions & 15 deletions src/frontend/src/pad/editors/HtmlEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { useState, useRef } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import type { NonDeleted, ExcalidrawEmbeddableElement } from '@atyrode/excalidraw/element/types';
import type { AppState } from '@atyrode/excalidraw/types';
import Editor from '@monaco-editor/react';
import Editor from './Editor';
import { ExcalidrawElementFactory } from '../../lib/ExcalidrawElementFactory';
import '../../styles/HtmlEditor.scss';

Expand All @@ -17,10 +17,32 @@ export const HtmlEditor: React.FC<HtmlEditorProps> = ({
excalidrawAPI
}) => {
const [createNew, setCreateNew] = useState(true);
const [editorValue, setEditorValue] = useState('<button style="padding: 8px; background: #5294f6; color: white; border: none; border-radius: 4px;">Example Button</button>');
const defaultHtml = '<button style="padding: 8px; background: #5294f6; color: white; border: none; border-radius: 4px;">Example Button</button>';
const [editorValue, setEditorValue] = useState(
element.customData?.editorContent || defaultHtml
);
const editorRef = useRef<any>(null);
const elementIdRef = useRef(element.id);

// Load content from customData when element changes (e.g., when cloned or pasted)
useEffect(() => {
// Check if element ID has changed (indicating a new element)
if (element.id !== elementIdRef.current) {
elementIdRef.current = element.id;

// If element has customData with editorContent, update the state
if (element.customData?.editorContent) {
setEditorValue(element.customData.editorContent);
} else {
setEditorValue(defaultHtml);
}

// Note: We don't need to update language here since HtmlEditor always uses 'html'
// But we still save it in customData for consistency
}
}, [element.id, element.customData, defaultHtml]);

const handleEditorDidMount = (editor: any) => {
const handleEditorMount = (editor: any) => {
editorRef.current = editor;
};

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

// Get the current editor content
const currentContent = editorRef.current.getValue();

// Create a new iframe element with the HTML content using our factory
const newElement = ExcalidrawElementFactory.createIframeElement({
x: createNew ? element.x + element.width + 20 : element.x,
y: createNew ? element.y : element.y,
width: element.width,
height: element.height,
htmlContent: htmlContent,
id: createNew ? undefined : element.id
id: createNew ? undefined : element.id,
customData: {
editorContent: currentContent,
editorLanguage: 'html' // Always set to html for HtmlEditor
}
});

// If creating a new element, add it to the scene
Expand Down Expand Up @@ -69,18 +98,13 @@ export const HtmlEditor: React.FC<HtmlEditorProps> = ({
<div className="html-editor-content">
<Editor
height="100%"
defaultLanguage="html"
language="html"
defaultValue={editorValue}
theme="vs-dark"
options={{
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontSize: 12,
automaticLayout: true
}}
onMount={handleEditorDidMount}
onChange={(value) => value && setEditorValue(value)}
className="monaco-editor-container"
onMount={handleEditorMount}
element={element}
excalidrawAPI={excalidrawAPI}
showLanguageSelector={false}
/>
<div className="html-editor-controls">
<label>
Expand Down
Loading