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
142 changes: 76 additions & 66 deletions components/CodeEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { useRef, useEffect, forwardRef, useImperativeHandle, useCallback,
import { useTheme } from '../hooks/useTheme';
import { MONACO_KEYBINDING_DEFINITIONS } from '../services/editor/monacoKeybindings';
import { DEFAULT_SETTINGS } from '../constants';
import { ensureMonaco } from '../services/editor/monacoLoader';

// Let TypeScript know monaco is available on the window
declare const monaco: any;
Expand Down Expand Up @@ -114,6 +115,7 @@ const toMonacoKeybinding = (monacoApi: any, keys: string[]): number | null => {
const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(({ content, language, onChange, onScroll, customShortcuts = {}, fontFamily, fontSize }, ref) => {
const editorRef = useRef<HTMLDivElement>(null);
const monacoInstanceRef = useRef<any>(null);
const monacoApiRef = useRef<any>(null);
const { theme } = useTheme();
const contentRef = useRef(content);
const customShortcutsRef = useRef<Record<string, string[]>>({});
Expand All @@ -135,7 +137,12 @@ const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(({ content, lan
monacoInstanceRef.current?.getAction('editor.action.formatDocument')?.run();
},
setScrollTop(scrollTop: number) {
monacoInstanceRef.current?.setScrollTop(scrollTop, monaco.editor.ScrollType.Immediate);
const scrollType = monacoApiRef.current?.editor?.ScrollType?.Immediate;
if (scrollType) {
monacoInstanceRef.current?.setScrollTop(scrollTop, scrollType);
} else {
monacoInstanceRef.current?.setScrollTop(scrollTop);
}
},
getScrollInfo() {
return new Promise(resolve => {
Expand Down Expand Up @@ -167,7 +174,8 @@ const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(({ content, lan
}, []);

const applyEditorShortcuts = useCallback(() => {
if (!monacoInstanceRef.current || typeof monaco === 'undefined') {
const monacoApi = monacoApiRef.current;
if (!monacoInstanceRef.current || !monacoApi) {
return;
}

Expand All @@ -180,7 +188,7 @@ const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(({ content, lan
return;
}

const keybinding = toMonacoKeybinding(monaco, keys);
const keybinding = toMonacoKeybinding(monacoApi, keys);
if (keybinding === null) {
return;
}
Expand Down Expand Up @@ -211,77 +219,79 @@ const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(({ content, lan
}, [content]);

useEffect(() => {
if (editorRef.current && typeof ((window as any).require) !== 'undefined') {
// Configure Monaco Environment to load workers from CDN. This is crucial for syntax highlighting.
if (!(window as any).MonacoEnvironment) {
(window as any).MonacoEnvironment = {
getWorkerUrl: function (_moduleId: any, label: string) {
const CDN_PATH = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.44.0/min/vs';
if (label === 'json') return `${CDN_PATH}/language/json/json.worker.js`;
if (label === 'css' || label === 'scss' || label === 'less') return `${CDN_PATH}/language/css/css.worker.js`;
if (label === 'html' || label === 'handlebars' || label === 'razor') return `${CDN_PATH}/language/html/html.worker.js`;
if (label === 'typescript' || label === 'javascript') return `${CDN_PATH}/language/typescript/ts.worker.js`;
return `${CDN_PATH}/editor/editor.worker.js`;
},
};
let isCancelled = false;

const initializeEditor = async () => {
if (!editorRef.current) {
return;
}

(window as any).require.config({ paths: { 'vs': 'https://cdn.jsdelivr.net/npm/monaco-editor@0.44.0/min/vs' }});
(window as any).require(['vs/editor/editor.main'], () => {
if (editorRef.current) {
// Ensure any previous instance is disposed
if (monacoInstanceRef.current) {
disposeEditorShortcuts();
monacoInstanceRef.current.dispose();
}
try {
const monacoApi = await ensureMonaco();
if (!monacoApi || isCancelled || !editorRef.current) {
return;
}

const editorInstance = monaco.editor.create(editorRef.current, {
value: content,
language: language || 'plaintext',
theme: theme === 'dark' ? 'vs-dark' : 'vs',
automaticLayout: true,
fontSize: computedFontSize,
fontFamily: computedFontFamily,
minimap: {
enabled: true,
},
wordWrap: 'on',
folding: true,
showFoldingControls: 'always',
bracketPairColorization: {
enabled: true,
},
});
monacoApiRef.current = monacoApi;

editorInstance.onDidChangeModelContent(() => {
const currentValue = editorInstance.getValue();
if (currentValue !== contentRef.current) {
onChange(currentValue);
}
});
if (monacoInstanceRef.current) {
disposeEditorShortcuts();
monacoInstanceRef.current.dispose();
}

editorInstance.onDidScrollChange((e: any) => {
if (e.scrollTopChanged) {
onScroll?.({
scrollTop: e.scrollTop,
scrollHeight: e.scrollHeight,
clientHeight: editorInstance.getLayoutInfo().height
});
}
});
const editorInstance = monacoApi.editor.create(editorRef.current, {
value: content,
language: language || 'plaintext',
theme: theme === 'dark' ? 'vs-dark' : 'vs',
automaticLayout: true,
Comment on lines 221 to +246
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Initialize editor with latest props when loader resolves

The async loader now waits for ensureMonaco() before creating the editor, but the initialization closure captures content, language, and theme from the first render and these props are not part of the effect’s dependency list. If the component mounts with placeholder values (e.g., empty string) and the real content/language/theme arrive before the Monaco loader finishes downloading, the editor will be created with the stale initial props and the subsequent effects ([content], [language], [theme]) never rerun because they already executed earlier when monacoInstanceRef.current was still null. This leaves the editor showing outdated content or incorrect language/theme until those props change again. Use the ref values (contentRef.current) or include the props in the effect dependencies so the editor initializes with the most recent data.

Useful? React with 👍 / 👎.

fontSize: computedFontSize,
fontFamily: computedFontFamily,
minimap: {
enabled: true,
},
wordWrap: 'on',
folding: true,
showFoldingControls: 'always',
bracketPairColorization: {
enabled: true,
},
});

monacoInstanceRef.current = editorInstance;
applyEditorShortcuts();
}
});
}
editorInstance.onDidChangeModelContent(() => {
const currentValue = editorInstance.getValue();
if (currentValue !== contentRef.current) {
onChange(currentValue);
}
});

editorInstance.onDidScrollChange((e: any) => {
if (e.scrollTopChanged) {
onScroll?.({
scrollTop: e.scrollTop,
scrollHeight: e.scrollHeight,
clientHeight: editorInstance.getLayoutInfo().height
});
}
});

monacoInstanceRef.current = editorInstance;
applyEditorShortcuts();
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to initialize Monaco editor', error);
}
};

initializeEditor();

return () => {
isCancelled = true;
disposeEditorShortcuts();
if (monacoInstanceRef.current) {
monacoInstanceRef.current.dispose();
monacoInstanceRef.current = null;
}
monacoApiRef.current = null;
};
}, [onChange, onScroll, applyEditorShortcuts, disposeEditorShortcuts, computedFontFamily, computedFontSize]);

Expand All @@ -299,8 +309,8 @@ const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(({ content, lan

// Effect to update theme
useEffect(() => {
if (monacoInstanceRef.current) {
monaco.editor.setTheme(theme === 'dark' ? 'vs-dark' : 'vs');
if (monacoInstanceRef.current && monacoApiRef.current) {
monacoApiRef.current.editor.setTheme(theme === 'dark' ? 'vs-dark' : 'vs');
}
}, [theme]);

Expand All @@ -309,11 +319,11 @@ const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(({ content, lan
monacoInstanceRef.current.updateOptions({ fontFamily: computedFontFamily, fontSize: computedFontSize });
}
}, [computedFontFamily, computedFontSize]);

// Effect to update language
useEffect(() => {
if (monacoInstanceRef.current && monacoInstanceRef.current.getModel()) {
monaco.editor.setModelLanguage(monacoInstanceRef.current.getModel(), language || 'plaintext');
if (monacoInstanceRef.current && monacoInstanceRef.current.getModel() && monacoApiRef.current) {
monacoApiRef.current.editor.setModelLanguage(monacoInstanceRef.current.getModel(), language || 'plaintext');
}
}, [language]);

Expand Down
111 changes: 63 additions & 48 deletions components/MonacoDiffEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useRef, useEffect, useCallback, useMemo } from 'react';
import { useTheme } from '../hooks/useTheme';
import { DEFAULT_SETTINGS } from '../constants';
import { ensureMonaco } from '../services/editor/monacoLoader';

// Let TypeScript know monaco is available on the window
declare const monaco: any;
Expand All @@ -20,6 +21,7 @@ interface MonacoDiffEditorProps {
const MonacoDiffEditor: React.FC<MonacoDiffEditorProps> = ({ oldText, newText, language, renderMode = 'side-by-side', readOnly = false, onChange, onScroll, fontFamily, fontSize }) => {
const editorRef = useRef<HTMLDivElement>(null);
const editorInstanceRef = useRef<any>(null);
const monacoApiRef = useRef<any>(null);
const { theme } = useTheme();
const modelsRef = useRef<{ original: any; modified: any } | null>(null);
const changeListenerRef = useRef<{ dispose: () => void } | null>(null);
Expand Down Expand Up @@ -48,73 +50,85 @@ const MonacoDiffEditor: React.FC<MonacoDiffEditorProps> = ({ oldText, newText, l
}, []);

useEffect(() => {
if (!editorRef.current || typeof ((window as any).require) === 'undefined') {
if (!editorRef.current) {
return;
}

let isCancelled = false;

(window as any).require(['vs/editor/editor.main'], () => {
if (!editorRef.current || isCancelled) return;
const initializeDiffEditor = async () => {
try {
const monacoApi = await ensureMonaco();
if (!monacoApi || isCancelled || !editorRef.current) {
return;
}

monacoApiRef.current = monacoApi;

if (!editorInstanceRef.current) {
editorInstanceRef.current = monacoApi.editor.createDiffEditor(editorRef.current, {
originalEditable: false,
readOnly,
automaticLayout: true,
fontSize: computedFontSize,
fontFamily: computedFontFamily,
wordWrap: 'on',
renderSideBySide: renderMode !== 'inline',
minimap: { enabled: false },
diffWordWrap: 'on',
});
}

if (!editorInstanceRef.current) {
editorInstanceRef.current = monaco.editor.createDiffEditor(editorRef.current, {
originalEditable: false,
const editor = editorInstanceRef.current;
editor.updateOptions({
readOnly,
automaticLayout: true,
fontSize: computedFontSize,
fontFamily: computedFontFamily,
wordWrap: 'on',
renderSideBySide: renderMode !== 'inline',
minimap: { enabled: false },
diffWordWrap: 'on',
fontFamily: computedFontFamily,
fontSize: computedFontSize,
});
}

const editor = editorInstanceRef.current;
editor.updateOptions({
readOnly,
renderSideBySide: renderMode !== 'inline',
diffWordWrap: 'on',
fontFamily: computedFontFamily,
fontSize: computedFontSize,
});

monaco.editor.setTheme(theme === 'dark' ? 'vs-dark' : 'vs');

const originalModel = monaco.editor.createModel(oldText ?? '', language);
const modifiedModel = monaco.editor.createModel(newText ?? '', language);
monacoApi.editor.setTheme(theme === 'dark' ? 'vs-dark' : 'vs');

editor.setModel({
original: originalModel,
modified: modifiedModel,
});
const originalModel = monacoApi.editor.createModel(oldText ?? '', language);
const modifiedModel = monacoApi.editor.createModel(newText ?? '', language);

const previousModels = modelsRef.current;
modelsRef.current = { original: originalModel, modified: modifiedModel };
previousModels?.original?.dispose();
previousModels?.modified?.dispose();
editor.setModel({
original: originalModel,
modified: modifiedModel,
});

disposeListeners();
const previousModels = modelsRef.current;
modelsRef.current = { original: originalModel, modified: modifiedModel };
previousModels?.original?.dispose();
previousModels?.modified?.dispose();

const modifiedEditor = editor.getModifiedEditor();
disposeListeners();

if (onChange && !readOnly) {
changeListenerRef.current = modifiedEditor.onDidChangeModelContent(() => {
onChange(modifiedEditor.getValue());
});
}
const modifiedEditor = editor.getModifiedEditor();

if (onScroll) {
scrollListenerRef.current = modifiedEditor.onDidScrollChange(() => {
onScroll({
scrollTop: modifiedEditor.getScrollTop(),
scrollHeight: modifiedEditor.getScrollHeight(),
clientHeight: modifiedEditor.getLayoutInfo().height,
if (onChange && !readOnly) {
changeListenerRef.current = modifiedEditor.onDidChangeModelContent(() => {
onChange(modifiedEditor.getValue());
});
});
}

if (onScroll) {
scrollListenerRef.current = modifiedEditor.onDidScrollChange(() => {
onScroll({
scrollTop: modifiedEditor.getScrollTop(),
scrollHeight: modifiedEditor.getScrollHeight(),
clientHeight: modifiedEditor.getLayoutInfo().height,
});
});
}
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to initialize Monaco diff editor', error);
}
});
};

initializeDiffEditor();

return () => {
isCancelled = true;
Expand Down Expand Up @@ -143,6 +157,7 @@ const MonacoDiffEditor: React.FC<MonacoDiffEditorProps> = ({ oldText, newText, l
modelsRef.current.modified?.dispose();
modelsRef.current = null;
}
monacoApiRef.current = null;
};
}, [disposeListeners]);

Expand Down
Loading