diff --git a/components/CodeEditor.tsx b/components/CodeEditor.tsx index 8b91f53..10b8afe 100644 --- a/components/CodeEditor.tsx +++ b/components/CodeEditor.tsx @@ -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; @@ -114,6 +115,7 @@ const toMonacoKeybinding = (monacoApi: any, keys: string[]): number | null => { const CodeEditor = forwardRef(({ content, language, onChange, onScroll, customShortcuts = {}, fontFamily, fontSize }, ref) => { const editorRef = useRef(null); const monacoInstanceRef = useRef(null); + const monacoApiRef = useRef(null); const { theme } = useTheme(); const contentRef = useRef(content); const customShortcutsRef = useRef>({}); @@ -135,7 +137,12 @@ const CodeEditor = forwardRef(({ 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 => { @@ -167,7 +174,8 @@ const CodeEditor = forwardRef(({ content, lan }, []); const applyEditorShortcuts = useCallback(() => { - if (!monacoInstanceRef.current || typeof monaco === 'undefined') { + const monacoApi = monacoApiRef.current; + if (!monacoInstanceRef.current || !monacoApi) { return; } @@ -180,7 +188,7 @@ const CodeEditor = forwardRef(({ content, lan return; } - const keybinding = toMonacoKeybinding(monaco, keys); + const keybinding = toMonacoKeybinding(monacoApi, keys); if (keybinding === null) { return; } @@ -211,77 +219,79 @@ const CodeEditor = forwardRef(({ 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, + 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]); @@ -299,8 +309,8 @@ const CodeEditor = forwardRef(({ 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]); @@ -309,11 +319,11 @@ const CodeEditor = forwardRef(({ 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]); diff --git a/components/MonacoDiffEditor.tsx b/components/MonacoDiffEditor.tsx index 7e4b198..0c3f8da 100644 --- a/components/MonacoDiffEditor.tsx +++ b/components/MonacoDiffEditor.tsx @@ -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; @@ -20,6 +21,7 @@ interface MonacoDiffEditorProps { const MonacoDiffEditor: React.FC = ({ oldText, newText, language, renderMode = 'side-by-side', readOnly = false, onChange, onScroll, fontFamily, fontSize }) => { const editorRef = useRef(null); const editorInstanceRef = useRef(null); + const monacoApiRef = useRef(null); const { theme } = useTheme(); const modelsRef = useRef<{ original: any; modified: any } | null>(null); const changeListenerRef = useRef<{ dispose: () => void } | null>(null); @@ -48,73 +50,85 @@ const MonacoDiffEditor: React.FC = ({ 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; @@ -143,6 +157,7 @@ const MonacoDiffEditor: React.FC = ({ oldText, newText, l modelsRef.current.modified?.dispose(); modelsRef.current = null; } + monacoApiRef.current = null; }; }, [disposeListeners]); diff --git a/services/editor/monacoLoader.ts b/services/editor/monacoLoader.ts new file mode 100644 index 0000000..3dde2ab --- /dev/null +++ b/services/editor/monacoLoader.ts @@ -0,0 +1,91 @@ +declare global { + interface Window { + __monacoLoaderPromise?: Promise; + } +} + +const MONACO_BASE_URL = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.44.0/min/vs'; +const MONACO_LOADER_URL = `${MONACO_BASE_URL}/loader.js`; + +const configureMonacoEnvironment = (win: any) => { + if (!win.MonacoEnvironment) { + win.MonacoEnvironment = { + getWorkerUrl: (_moduleId: unknown, label: string) => { + if (label === 'json') return `${MONACO_BASE_URL}/language/json/json.worker.js`; + if (label === 'css' || label === 'scss' || label === 'less') { + return `${MONACO_BASE_URL}/language/css/css.worker.js`; + } + if (label === 'html' || label === 'handlebars' || label === 'razor') { + return `${MONACO_BASE_URL}/language/html/html.worker.js`; + } + if (label === 'typescript' || label === 'javascript') { + return `${MONACO_BASE_URL}/language/typescript/ts.worker.js`; + } + return `${MONACO_BASE_URL}/editor/editor.worker.js`; + }, + }; + } +}; + +const loadMonacoWithLoader = (win: any) => { + return new Promise((resolve, reject) => { + const onAmdLoaderAvailable = () => { + const amdRequire = win.require; + if (!amdRequire) { + reject(new Error('Monaco AMD loader is unavailable.')); + return; + } + + configureMonacoEnvironment(win); + amdRequire.config({ paths: { vs: MONACO_BASE_URL } }); + amdRequire( + ['vs/editor/editor.main'], + () => { + if (win.monaco) { + resolve(win.monaco); + } else { + reject(new Error('Monaco editor failed to initialize.')); + } + }, + (error: unknown) => reject(error), + ); + }; + + if (win.require) { + onAmdLoaderAvailable(); + return; + } + + const script = document.createElement('script'); + script.src = MONACO_LOADER_URL; + script.async = true; + script.onload = onAmdLoaderAvailable; + script.onerror = () => { + reject(new Error('Failed to load Monaco AMD loader.')); + }; + document.body.appendChild(script); + }); +}; + +export const ensureMonaco = async (): Promise => { + if (typeof window === 'undefined') { + return null; + } + + const win = window as any; + + if (win.monaco) { + configureMonacoEnvironment(win); + return win.monaco; + } + + if (!win.__monacoLoaderPromise) { + win.__monacoLoaderPromise = loadMonacoWithLoader(win).catch((error: unknown) => { + win.__monacoLoaderPromise = undefined; + throw error; + }); + } + + return win.__monacoLoaderPromise; +}; +