From a0f083e2d2cfc6598de82e38f56777eeb4688f71 Mon Sep 17 00:00:00 2001 From: Nicolas Laval Date: Mon, 15 Jun 2026 08:24:42 +0200 Subject: [PATCH 1/2] Add precise focus from external action --- src/Editor.tsx | 1009 +++++++++++++------------ src/__tests__/Editor.test.tsx | 50 +- src/__tests__/setup.ts | 3 + src/__tests__/utils/selection.test.ts | 26 + src/__tests__/utils/testUtils.tsx | 3 + src/index.ts | 1 + src/stories/EditorHandle.stories.tsx | 127 ++++ src/utils/selection.ts | 21 + 8 files changed, 772 insertions(+), 468 deletions(-) create mode 100644 src/stories/EditorHandle.stories.tsx diff --git a/src/Editor.tsx b/src/Editor.tsx index 643fa5d..ab7d416 100644 --- a/src/Editor.tsx +++ b/src/Editor.tsx @@ -1,4 +1,4 @@ -import { FC, useState, useEffect, useRef, useCallback } from "react"; +import { FC, forwardRef, useState, useEffect, useRef, useCallback, useImperativeHandle } from "react"; import { validate } from "./utils/ParserFacade"; import { getEditorWillMount, cleanupProviders } from "./utils/providers"; import { Tools, Error, Variables } from "./model"; @@ -9,6 +9,7 @@ import { buildMonacoSelection, buildTextareaSelection, createSelectionChangeNotifier, + lineColumnToOffset, type EditorSelection, type SelectionChangeNotifier } from "./utils/selection"; @@ -16,6 +17,11 @@ import { IDisposable } from "monaco-editor"; export type { EditorSelection }; +export type EditorHandle = { + revealPosition(line: number, column: number): void; + focus(): void; +}; + // Check if we're in a test environment const isTestEnvironment = typeof globalThis !== "undefined" && @@ -83,508 +89,581 @@ type EditorProps = { onSelectionChange?: (selection: EditorSelection) => void; }; -const Editor = ({ - script, - setScript, - onListErrors, - customFetcher, - variables, - variablesInputURLs, - tools, - height = "50vh", - width = "100%", - theme = "vs-dark", - options, - shortcuts, - FooterComponent, - displayFooter = true, - onSelectionChange -}: EditorProps) => { - const editorRef = useRef(null); - const monacoRef = useRef(null); - const [ready, setReady] = useState(false); - const [vars, setVars] = useState(buildVariables(variables)); - const [isEditorReady, setIsEditorReady] = useState(false); - - const [cursor, setCursor] = useState({ - line: 1, - column: 1, - selectionLength: 0 - }); - - // Cleanup function to properly dispose of Monaco resources - const subscriptionsRef = useRef([]); - const selectionNotifierRef = useRef(null); - - useEffect(() => { - selectionNotifierRef.current = onSelectionChange - ? createSelectionChangeNotifier(onSelectionChange) - : null; - }, [onSelectionChange]); - - const reportSelection = useCallback((editor: Parameters[0]) => { - const notifier = selectionNotifierRef.current; - if (!notifier) return; - const { payload, hasSelection } = buildMonacoSelection(editor); - notifier.notify(payload, hasSelection); - }, []); - - const cleanupMonaco = useCallback(() => { - subscriptionsRef.current.forEach(disposable => { - try { - disposable.dispose(); - } catch { - // Best-effort cleanup: Monaco can already be partially disposed. - } +const Editor = forwardRef( + ( + { + script, + setScript, + onListErrors, + customFetcher, + variables, + variablesInputURLs, + tools, + height = "50vh", + width = "100%", + theme = "vs-dark", + options, + shortcuts, + FooterComponent, + displayFooter = true, + onSelectionChange + }, + ref + ) => { + const editorRef = useRef(null); + const textareaRef = useRef(null); + const monacoRef = useRef(null); + const [ready, setReady] = useState(false); + const [vars, setVars] = useState(buildVariables(variables)); + const [isEditorReady, setIsEditorReady] = useState(false); + + const [cursor, setCursor] = useState({ + line: 1, + column: 1, + selectionLength: 0 }); - subscriptionsRef.current = []; - if (editorRef.current) { - try { - // Get the model before disposing - const model = editorRef.current.getModel(); + // Cleanup function to properly dispose of Monaco resources + const subscriptionsRef = useRef([]); + const selectionNotifierRef = useRef(null); - // Detach the model first to prevent further rendering - editorRef.current.setModel(null); + useEffect(() => { + selectionNotifierRef.current = onSelectionChange + ? createSelectionChangeNotifier(onSelectionChange) + : null; + }, [onSelectionChange]); + + const reportSelection = useCallback((editor: Parameters[0]) => { + const notifier = selectionNotifierRef.current; + if (!notifier) return; + const { payload, hasSelection } = buildMonacoSelection(editor); + notifier.notify(payload, hasSelection); + }, []); - // Dispose the model - if (model) { - model.dispose(); + const cleanupMonaco = useCallback(() => { + subscriptionsRef.current.forEach(disposable => { + try { + disposable.dispose(); + } catch { + // Best-effort cleanup: Monaco can already be partially disposed. } + }); + subscriptionsRef.current = []; - // Dispose the editor instance - editorRef.current.dispose(); - } catch (error) { - // Silently catch dispose errors - they're expected during cleanup - console.debug("Monaco editor disposal (expected):", error); - } - editorRef.current = null; - } - - // Clear Monaco reference - monacoRef.current = null; - setIsEditorReady(false); - - // Cleanup providers - cleanupProviders(); - }, []); - - // Handle Monaco disposal errors gracefully without global monkey patches. - useEffect(() => { - const handleMonacoError = (event: ErrorEvent) => { - if (shouldSuppressMonacoError(event.error ?? event.message)) { - // Suppress Monaco cleanup errors - they're harmless during layout changes - console.debug("Monaco cleanup error suppressed:", event.error?.message || event.message); - event.preventDefault(); - event.stopPropagation(); - return false; - } - return true; - }; - - const handleUnhandledRejection = (event: PromiseRejectionEvent) => { - if (shouldSuppressMonacoError(event.reason)) { - // Suppress Monaco cleanup errors in promises - console.debug( - "Monaco cleanup promise error suppressed:", - event.reason?.message || String(event.reason) - ); - event.preventDefault(); - return false; - } - return true; - }; - - window.addEventListener("error", handleMonacoError, true); - window.addEventListener("unhandledrejection", handleUnhandledRejection); - - return () => { - window.removeEventListener("error", handleMonacoError, true); - window.removeEventListener("unhandledrejection", handleUnhandledRejection); - }; - }, []); - - // Cleanup on unmount - useEffect(() => { - return () => { - cleanupMonaco(); - }; - }, [cleanupMonaco]); - - // Track if component is mounted to prevent layout operations after unmount - const isMountedRef = useRef(true); - - useEffect(() => { - isMountedRef.current = true; - return () => { - isMountedRef.current = false; - }; - }, []); - - const onMount = useCallback( - (editor: any, mon: any, t: Tools) => { - editorRef.current = editor; - monacoRef.current = mon; - setIsEditorReady(true); - - // Wrap setModel to prevent multiple View creations during layout changes - const originalSetModel = editor.setModel.bind(editor); - editor.setModel = function (model: any) { - const currentModel = editor.getModel(); - // Only set model if it's actually different - if (currentModel !== model) { - try { - originalSetModel(model); - } catch (error: any) { - if (!error.message?.includes("InstantiationService has been disposed")) { - throw error; - } - console.debug("Suppressed setModel error during layout change"); + if (editorRef.current) { + try { + // Get the model before disposing + const model = editorRef.current.getModel(); + + // Detach the model first to prevent further rendering + editorRef.current.setModel(null); + + // Dispose the model + if (model) { + model.dispose(); } + + // Dispose the editor instance + editorRef.current.dispose(); + } catch (error) { + // Silently catch dispose errors - they're expected during cleanup + console.debug("Monaco editor disposal (expected):", error); } - }; + editorRef.current = null; + } - // Safe layout wrapper - only layout if mounted - const originalLayout = editor.layout.bind(editor); - editor.layout = function (...args: any[]) { - if (!isMountedRef.current) { - console.debug("Skipped layout call on unmounted editor"); - return; + // Clear Monaco reference + monacoRef.current = null; + setIsEditorReady(false); + + // Cleanup providers + cleanupProviders(); + }, []); + + // Handle Monaco disposal errors gracefully without global monkey patches. + useEffect(() => { + const handleMonacoError = (event: ErrorEvent) => { + if (shouldSuppressMonacoError(event.error ?? event.message)) { + // Suppress Monaco cleanup errors - they're harmless during layout changes + console.debug( + "Monaco cleanup error suppressed:", + event.error?.message || event.message + ); + event.preventDefault(); + event.stopPropagation(); + return false; } - try { - originalLayout(...args); - } catch (error: any) { - if ( - !error.message?.includes("InstantiationService has been disposed") && - !error.message?.includes("domNode") - ) { - throw error; - } - console.debug("Suppressed layout error during cleanup"); + return true; + }; + + const handleUnhandledRejection = (event: PromiseRejectionEvent) => { + if (shouldSuppressMonacoError(event.reason)) { + // Suppress Monaco cleanup errors in promises + console.debug( + "Monaco cleanup promise error suppressed:", + event.reason?.message || String(event.reason) + ); + event.preventDefault(); + return false; } + return true; }; - // Patch the editor's internal rendering to prevent domNode errors - // This is a deep patch to prevent errors from bubbling up - try { - const editorInternal = (editor as any)._view; - if (editorInternal && editorInternal._renderingCoordinator) { - const coordinator = editorInternal._renderingCoordinator; - const originalOnRenderScheduled = coordinator._onRenderScheduled; - if (originalOnRenderScheduled) { - coordinator._onRenderScheduled = function (this: any) { - if (!isMountedRef.current || !editorRef.current) { - console.debug("Skipped render on unmounted editor"); - return; + window.addEventListener("error", handleMonacoError, true); + window.addEventListener("unhandledrejection", handleUnhandledRejection); + + return () => { + window.removeEventListener("error", handleMonacoError, true); + window.removeEventListener("unhandledrejection", handleUnhandledRejection); + }; + }, []); + + // Cleanup on unmount + useEffect(() => { + return () => { + cleanupMonaco(); + }; + }, [cleanupMonaco]); + + // Track if component is mounted to prevent layout operations after unmount + const isMountedRef = useRef(true); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + + const onMount = useCallback( + (editor: any, mon: any, t: Tools) => { + editorRef.current = editor; + monacoRef.current = mon; + setIsEditorReady(true); + + // Wrap setModel to prevent multiple View creations during layout changes + const originalSetModel = editor.setModel.bind(editor); + editor.setModel = function (model: any) { + const currentModel = editor.getModel(); + // Only set model if it's actually different + if (currentModel !== model) { + try { + originalSetModel(model); + } catch (error: any) { + if (!error.message?.includes("InstantiationService has been disposed")) { + throw error; } - try { - originalOnRenderScheduled.call(this); - } catch (error: any) { - if ( - error.message?.includes("domNode") || - error.message?.includes("renderText") - ) { - console.debug("Suppressed Monaco rendering error:", error.message); + console.debug("Suppressed setModel error during layout change"); + } + } + }; + + // Safe layout wrapper - only layout if mounted + const originalLayout = editor.layout.bind(editor); + editor.layout = function (...args: any[]) { + if (!isMountedRef.current) { + console.debug("Skipped layout call on unmounted editor"); + return; + } + try { + originalLayout(...args); + } catch (error: any) { + if ( + !error.message?.includes("InstantiationService has been disposed") && + !error.message?.includes("domNode") + ) { + throw error; + } + console.debug("Suppressed layout error during cleanup"); + } + }; + + // Patch the editor's internal rendering to prevent domNode errors + // This is a deep patch to prevent errors from bubbling up + try { + const editorInternal = (editor as any)._view; + if (editorInternal && editorInternal._renderingCoordinator) { + const coordinator = editorInternal._renderingCoordinator; + const originalOnRenderScheduled = coordinator._onRenderScheduled; + if (originalOnRenderScheduled) { + coordinator._onRenderScheduled = function (this: any) { + if (!isMountedRef.current || !editorRef.current) { + console.debug("Skipped render on unmounted editor"); return; } - throw error; - } - }; + try { + originalOnRenderScheduled.call(this); + } catch (error: any) { + if ( + error.message?.includes("domNode") || + error.message?.includes("renderText") + ) { + console.debug( + "Suppressed Monaco rendering error:", + error.message + ); + return; + } + throw error; + } + }; + } } + } catch { + console.debug("Could not patch Monaco rendering coordinator (non-critical)"); } - } catch { - console.debug("Could not patch Monaco rendering coordinator (non-critical)"); - } - // Monaco Editor markers will automatically show error tooltips on hover - // No need for custom hover provider as it causes duplicates + // Monaco Editor markers will automatically show error tooltips on hover + // No need for custom hover provider as it causes duplicates - // Ensure theme is applied for proper error highlighting - if (!isTestEnvironment && mon?.editor) { - // Force theme application - mon.editor.setTheme(theme || "vs-dark"); - } + // Ensure theme is applied for proper error highlighting + if (!isTestEnvironment && mon?.editor) { + // Force theme application + mon.editor.setTheme(theme || "vs-dark"); + } - let parseContentTO: ReturnType | undefined; - let contentChangeTO: ReturnType | undefined; - parseContent(t); - - subscriptionsRef.current.push( - editor.onDidChangeModelContent(() => { - if (parseContentTO) clearTimeout(parseContentTO); - parseContentTO = setTimeout(() => { - // Always validate the live Monaco buffer to avoid stale-prop races. - parseContent(t); - }, 0); - if (!contentChangeTO) { - if (setScript) { - contentChangeTO = setTimeout(() => { - setScript(editor.getValue()); - contentChangeTO = undefined; - }, 200); + let parseContentTO: ReturnType | undefined; + let contentChangeTO: ReturnType | undefined; + parseContent(t); + + subscriptionsRef.current.push( + editor.onDidChangeModelContent(() => { + if (parseContentTO) clearTimeout(parseContentTO); + parseContentTO = setTimeout(() => { + // Always validate the live Monaco buffer to avoid stale-prop races. + parseContent(t); + }, 0); + if (!contentChangeTO) { + if (setScript) { + contentChangeTO = setTimeout(() => { + setScript(editor.getValue()); + contentChangeTO = undefined; + }, 200); + } } - } - }) - ); - - subscriptionsRef.current.push( - editor.onDidChangeCursorPosition((e: MonacoCursorPositionEvent) => { - setCursor(prev => ({ - ...prev, - line: e.position.lineNumber, - column: e.position.column - })); - }) - ); - - subscriptionsRef.current.push( - editor.onDidChangeCursorSelection((e: MonacoSelectionEvent) => { - const selection = e.selection; - const length = editor?.getModel()?.getValueInRange(selection).length; - setCursor(prev => ({ - ...prev, - selectionLength: length || 0 - })); - reportSelection(editor); - }) - ); - - if (shortcuts) { - Object.entries(shortcuts).forEach(([comboString, action]) => { - comboString.split(",").forEach(combo => { - const keys = combo.trim().toLowerCase().split("+"); - let keyCode = null; - let keyMod = 0; - - keys.forEach(k => { - if (k === "ctrl") keyMod |= mon?.KeyMod?.CtrlCmd || 1; - else if (k === "meta") keyMod |= mon?.KeyMod?.CtrlCmd || 1; - else if (k === "shift") keyMod |= mon?.KeyMod?.Shift || 2; - else if (k === "alt") keyMod |= mon?.KeyMod?.Alt || 4; - else { - const upper = k.length === 1 ? k.toUpperCase() : k; - if (mon?.KeyCode && `Key${upper}` in mon.KeyCode) { - keyCode = mon.KeyCode[`Key${upper}` as keyof typeof mon.KeyCode]; - } else if (mon?.KeyCode && upper in mon.KeyCode) { - keyCode = mon.KeyCode[upper as keyof typeof mon.KeyCode]; - } else { - keyCode = null; + }) + ); + + subscriptionsRef.current.push( + editor.onDidChangeCursorPosition((e: MonacoCursorPositionEvent) => { + setCursor(prev => ({ + ...prev, + line: e.position.lineNumber, + column: e.position.column + })); + }) + ); + + subscriptionsRef.current.push( + editor.onDidChangeCursorSelection((e: MonacoSelectionEvent) => { + const selection = e.selection; + const length = editor?.getModel()?.getValueInRange(selection).length; + setCursor(prev => ({ + ...prev, + selectionLength: length || 0 + })); + reportSelection(editor); + }) + ); + + if (shortcuts) { + Object.entries(shortcuts).forEach(([comboString, action]) => { + comboString.split(",").forEach(combo => { + const keys = combo.trim().toLowerCase().split("+"); + let keyCode = null; + let keyMod = 0; + + keys.forEach(k => { + if (k === "ctrl") keyMod |= mon?.KeyMod?.CtrlCmd || 1; + else if (k === "meta") keyMod |= mon?.KeyMod?.CtrlCmd || 1; + else if (k === "shift") keyMod |= mon?.KeyMod?.Shift || 2; + else if (k === "alt") keyMod |= mon?.KeyMod?.Alt || 4; + else { + const upper = k.length === 1 ? k.toUpperCase() : k; + if (mon?.KeyCode && `Key${upper}` in mon.KeyCode) { + keyCode = mon.KeyCode[`Key${upper}` as keyof typeof mon.KeyCode]; + } else if (mon?.KeyCode && upper in mon.KeyCode) { + keyCode = mon.KeyCode[upper as keyof typeof mon.KeyCode]; + } else { + keyCode = null; + } } + }); + + if (keyCode !== null) { + editor.addCommand(keyMod | keyCode, (e: any) => { + e?.preventDefault?.(); + action(); + }); } }); + }); + } - if (keyCode !== null) { - editor.addCommand(keyMod | keyCode, (e: any) => { - e?.preventDefault?.(); - action(); - }); + subscriptionsRef.current.push( + editor.onKeyDown((e: MonacoKeyDownEvent) => { + const isMac = /Mac/.test(navigator.userAgent); + const metaPressed = e.metaKey; + const ctrlPressed = e.ctrlKey; + + if ( + (isMac && metaPressed && e.code === "Enter") || + (!isMac && ctrlPressed && e.code === "Enter") + ) { + e.preventDefault(); + e.stopPropagation(); + shortcuts["ctrl+enter, meta+enter"]?.(); } - }); - }); - } + }) + ); + }, + [shortcuts, reportSelection] + ); + + const parseContent = useCallback( + (t: Tools, str?: string) => { + const editor = editorRef.current; + if (!editor) return; + + // Check if model exists before parsing + const model = editor?.getModel(); + if (!model) { + console.debug("parseContent: model not ready yet"); + return; + } - subscriptionsRef.current.push( - editor.onKeyDown((e: MonacoKeyDownEvent) => { - const isMac = /Mac/.test(navigator.userAgent); - const metaPressed = e.metaKey; - const ctrlPressed = e.ctrlKey; - - if ( - (isMac && metaPressed && e.code === "Enter") || - (!isMac && ctrlPressed && e.code === "Enter") - ) { - e.preventDefault(); - e.stopPropagation(); - shortcuts["ctrl+enter, meta+enter"]?.(); - } - }) - ); - }, - [shortcuts, reportSelection] - ); - - const parseContent = useCallback( - (t: Tools, str?: string) => { - const editor = editorRef.current; - if (!editor) return; - - // Check if model exists before parsing - const model = editor?.getModel(); - if (!model) { - console.debug("parseContent: model not ready yet"); - return; - } + // Use provided string or get value from editor + const content = str !== undefined ? str : editor.getValue(); + const monacoErrors: any[] = validate(t)(content).map(error => { + return { + startLineNumber: error.startLine, + startColumn: error.startCol, + endLineNumber: error.endLine, + endColumn: error.endCol, + message: error.message, + severity: isTestEnvironment + ? 1 + : monacoRef.current?.editor?.MarkerSeverity?.Error || 8 + }; + }); - // Use provided string or get value from editor - const content = str !== undefined ? str : editor.getValue(); - const monacoErrors: any[] = validate(t)(content).map(error => { - return { - startLineNumber: error.startLine, - startColumn: error.startCol, - endLineNumber: error.endLine, - endColumn: error.endCol, - message: error.message, - severity: isTestEnvironment - ? 1 - : monacoRef.current?.editor?.MarkerSeverity?.Error || 8 - }; - }); + if (!isTestEnvironment && monacoRef.current?.editor) { + // Clear existing markers first + monacoRef.current.editor.setModelMarkers(model, "owner", []); + // Set new markers + monacoRef.current.editor.setModelMarkers(model, "owner", monacoErrors); + } - if (!isTestEnvironment && monacoRef.current?.editor) { - // Clear existing markers first - monacoRef.current.editor.setModelMarkers(model, "owner", []); - // Set new markers - monacoRef.current.editor.setModelMarkers(model, "owner", monacoErrors); + if (onListErrors) { + onListErrors( + monacoErrors.map(error => { + return { + line: error.startLineNumber, + column: error.startColumn, + message: error.message + } as Error; + }) + ); + } + }, + [onListErrors] + ); + + useEffect(() => { + if (!Array.isArray(variablesInputURLs) || variablesInputURLs.length === 0) setReady(true); + const f = customFetcher || fetch; + if (variablesInputURLs && variablesInputURLs.length > 0 && !ready) { + Promise.all(variablesInputURLs.map(v => f(v))) + .then(res => + Promise.all(res.map(r => r.json())).then(res => { + const uniqueVars = buildUniqueVariables(res); + setVars(v => [...v, ...uniqueVars]); + setReady(true); + }) + ) + .catch(() => { + setReady(true); + }); } + }, [variablesInputURLs]); - if (onListErrors) { - onListErrors( - monacoErrors.map(error => { - return { - line: error.startLineNumber, - column: error.startColumn, - message: error.message - } as Error; - }) - ); + useEffect(() => { + if (isEditorReady) { + parseContent(tools); } - }, - [onListErrors] - ); - - useEffect(() => { - if (!Array.isArray(variablesInputURLs) || variablesInputURLs.length === 0) setReady(true); - const f = customFetcher || fetch; - if (variablesInputURLs && variablesInputURLs.length > 0 && !ready) { - Promise.all(variablesInputURLs.map(v => f(v))) - .then(res => - Promise.all(res.map(r => r.json())).then(res => { - const uniqueVars = buildUniqueVariables(res); - setVars(v => [...v, ...uniqueVars]); - setReady(true); - }) - ) - .catch(() => { - setReady(true); + }, [tools.initialRule, isEditorReady, parseContent, tools]); + + const isDark = theme.includes("dark"); + + useEffect(() => { + if (!ready || !isTestEnvironment || !onSelectionChange) return; + const { payload, hasSelection } = buildTextareaSelection(script || "", 0, 0); + selectionNotifierRef.current?.notify(payload, hasSelection); + }, [ready, onSelectionChange]); + + const handleTextareaSelect = useCallback( + (value: string, selectionStart: number, selectionEnd: number) => { + const lines = value.substring(0, selectionStart).split("\n"); + setCursor({ + line: lines.length, + column: lines[lines.length - 1].length + 1, + selectionLength: Math.abs(selectionEnd - selectionStart) }); - } - }, [variablesInputURLs]); - - useEffect(() => { - if (isEditorReady) { - parseContent(tools); - } - }, [tools.initialRule, isEditorReady, parseContent, tools]); - - const isDark = theme.includes("dark"); - - useEffect(() => { - if (!ready || !isTestEnvironment || !onSelectionChange) return; - const { payload, hasSelection } = buildTextareaSelection(script || "", 0, 0); - selectionNotifierRef.current?.notify(payload, hasSelection); - }, [ready, onSelectionChange]); - - const handleTextareaSelect = useCallback( - (value: string, selectionStart: number, selectionEnd: number) => { - const lines = value.substring(0, selectionStart).split("\n"); - setCursor({ - line: lines.length, - column: lines[lines.length - 1].length + 1, - selectionLength: Math.abs(selectionEnd - selectionStart) - }); - const notifier = selectionNotifierRef.current; - if (!notifier) return; - const { payload, hasSelection } = buildTextareaSelection( - value, - selectionStart, - selectionEnd - ); - notifier.notify(payload, hasSelection); - }, - [] - ); - - if (!ready) return null; - - const bannerHeight = displayFooter ? 22 : 0; - - return ( -
-
- {isTestEnvironment ? ( - // Test environment - render a simple textarea -