From 407d22ead45596d36ca80649742da1626b3c78dd Mon Sep 17 00:00:00 2001 From: Thanan Traiongthawon <95660+nullcoder@users.noreply.github.com> Date: Fri, 6 Jun 2025 19:00:36 -0700 Subject: [PATCH] fix: export code editor handle --- components/ui/code-editor.test.tsx | 34 ++- components/ui/code-editor.tsx | 450 ++++++++++++++++------------- 2 files changed, 278 insertions(+), 206 deletions(-) diff --git a/components/ui/code-editor.test.tsx b/components/ui/code-editor.test.tsx index e454030..f25127d 100644 --- a/components/ui/code-editor.test.tsx +++ b/components/ui/code-editor.test.tsx @@ -24,7 +24,7 @@ describe("CodeEditor", () => { }); }); - it("calls onChange when content is modified", async () => { + it("calls onChange on blur after content is modified", async () => { const handleChange = vi.fn(); const user = userEvent.setup(); @@ -41,8 +41,14 @@ describe("CodeEditor", () => { await user.click(editor); await user.type(editor, "test"); + // onChange shouldn't be called yet + expect(handleChange).not.toHaveBeenCalled(); + + // Blur the editor + await user.tab(); + await waitFor(() => { - expect(handleChange).toHaveBeenCalled(); + expect(handleChange).toHaveBeenCalledWith("test"); }); }); @@ -50,7 +56,7 @@ describe("CodeEditor", () => { const handleChange = vi.fn(); render( - + , ); await waitFor(() => { @@ -73,7 +79,7 @@ describe("CodeEditor", () => { it("applies custom className", () => { const { container } = render( - + , ); expect(container.firstChild).toHaveClass("custom-editor-class"); @@ -140,6 +146,26 @@ describe("CodeEditor", () => { }); }); + it("exposes an imperative API to get the current value", async () => { + const ref = { current: null } as React.MutableRefObject; + const user = userEvent.setup(); + + render(); + + await waitFor(() => { + const editor = document.querySelector(".cm-content"); + expect(editor).toBeInTheDocument(); + }); + + const editor = document.querySelector(".cm-content") as HTMLElement; + await user.click(editor); + await user.type(editor, "value"); + + await user.tab(); + + expect(ref.current?.getValue()).toBe("value"); + }); + it("renders loading state during SSR", () => { // The component should render during SSR with loading state const { container } = render(); diff --git a/components/ui/code-editor.tsx b/components/ui/code-editor.tsx index 18fa169..74c3457 100644 --- a/components/ui/code-editor.tsx +++ b/components/ui/code-editor.tsx @@ -1,6 +1,13 @@ "use client"; -import { useEffect, useRef, useMemo, useCallback } from "react"; +import { + useEffect, + useRef, + useMemo, + useCallback, + useImperativeHandle, + forwardRef, +} from "react"; import { EditorState, Extension, Compartment } from "@codemirror/state"; import { EditorView, keymap, lineNumbers, placeholder } from "@codemirror/view"; import { defaultKeymap, indentWithTab } from "@codemirror/commands"; @@ -72,6 +79,15 @@ export interface CodeEditorProps { theme?: "light" | "dark"; } +export interface CodeEditorHandle { + /** Get the current editor value */ + getValue: () => string; + /** Replace the editor content */ + setValue: (val: string) => void; + /** Focus the editor */ + focus: () => void; +} + // Create compartments for dynamic configuration const languageCompartment = new Compartment(); const themeCompartment = new Compartment(); @@ -79,210 +95,240 @@ const readOnlyCompartment = new Compartment(); const lineNumbersCompartment = new Compartment(); const lineWrappingCompartment = new Compartment(); -export function CodeEditor({ - value = "", - onChange, - language = "javascript", - placeholder: placeholderText = "Enter your code here...", - readOnly = false, - showLineNumbers = true, - wordWrap = false, - className, - height = "400px", - theme: themeOverride, -}: CodeEditorProps) { - const containerRef = useRef(null); - const viewRef = useRef(null); - const { theme: systemTheme } = useTheme(); - - // Store the onChange callback in a ref to avoid stale closures - const onChangeRef = useRef(onChange); - onChangeRef.current = onChange; - - // Determine the active theme - const activeTheme = themeOverride || systemTheme || "light"; - - // Get language extension - const getLanguageExtension = useCallback((lang: string) => { - const langKey = lang.toLowerCase(); - const langFunc = languageModes[langKey]; - return langFunc ? langFunc() : javascript(); - }, []); - - // Get theme extension - const getThemeExtension = useCallback((theme: string) => { - return theme === "dark" ? oneDark : githubLight; - }, []); - - // Create base extensions that don't change - const baseExtensions = useMemo( - () => [ - indentOnInput(), - bracketMatching(), - closeBrackets(), - autocompletion(), - highlightActiveLine(), - highlightSelectionMatches(), - keymap.of([...defaultKeymap, ...searchKeymap, indentWithTab]), - placeholder(placeholderText), - EditorView.theme({ - "&": { - fontSize: "14px", - height: height, - fontFamily: - "var(--font-geist-mono), ui-monospace, SFMono-Regular, 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace", - }, - ".cm-content": { - padding: "12px", - fontFamily: "inherit", - }, - ".cm-focused": { - outline: "none", +export const CodeEditor = forwardRef( + function CodeEditor( + { + value = "", + onChange, + language = "javascript", + placeholder: placeholderText = "Enter your code here...", + readOnly = false, + showLineNumbers = true, + wordWrap = false, + className, + height = "400px", + theme: themeOverride, + }: CodeEditorProps, + ref, + ) { + const containerRef = useRef(null); + const viewRef = useRef(null); + const { theme: systemTheme } = useTheme(); + + // Store the onChange callback in a ref to avoid stale closures + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + + useImperativeHandle( + ref, + () => ({ + getValue: () => viewRef.current?.state.doc.toString() ?? "", + setValue: (val: string) => { + if (!viewRef.current) return; + const current = viewRef.current.state.doc.toString(); + if (val !== current) { + viewRef.current.dispatch({ + changes: { from: 0, to: current.length, insert: val }, + }); + } }, - "&.cm-editor.cm-focused": { - outline: "2px solid var(--color-ring)", - outlineOffset: "2px", - }, - ".cm-placeholder": { - color: "var(--color-muted-foreground)", - fontStyle: "italic", - }, - ".cm-cursor": { - borderLeftWidth: "2px", - }, - ".cm-scroller": { - fontFamily: "inherit", - lineHeight: "1.5", - }, - ".cm-gutters": { - fontFamily: "inherit", + focus: () => { + viewRef.current?.focus(); }, }), - ], - [placeholderText, height] - ); - - // Initialize the editor - useEffect(() => { - if (!containerRef.current || viewRef.current) return; - - // Create extensions with compartments - const extensions = [ - ...baseExtensions, - languageCompartment.of(getLanguageExtension(language)), - themeCompartment.of(getThemeExtension(activeTheme)), - readOnlyCompartment.of(EditorState.readOnly.of(readOnly)), - lineNumbersCompartment.of( - showLineNumbers ? [lineNumbers(), foldGutter()] : [] - ), - lineWrappingCompartment.of(wordWrap ? EditorView.lineWrapping : []), - EditorView.updateListener.of((update) => { - if (update.docChanged && onChangeRef.current) { - const newValue = update.state.doc.toString(); - onChangeRef.current(newValue); + [], + ); + + // Determine the active theme + const activeTheme = themeOverride || systemTheme || "light"; + + // Get language extension + const getLanguageExtension = useCallback((lang: string) => { + const langKey = lang.toLowerCase(); + const langFunc = languageModes[langKey]; + return langFunc ? langFunc() : javascript(); + }, []); + + // Get theme extension + const getThemeExtension = useCallback((theme: string) => { + return theme === "dark" ? oneDark : githubLight; + }, []); + + // Create base extensions that don't change + const baseExtensions = useMemo( + () => [ + indentOnInput(), + bracketMatching(), + closeBrackets(), + autocompletion(), + highlightActiveLine(), + highlightSelectionMatches(), + keymap.of([...defaultKeymap, ...searchKeymap, indentWithTab]), + placeholder(placeholderText), + EditorView.theme({ + "&": { + fontSize: "14px", + height: height, + fontFamily: + "var(--font-geist-mono), ui-monospace, SFMono-Regular, 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace", + }, + ".cm-content": { + padding: "12px", + fontFamily: "inherit", + }, + ".cm-focused": { + outline: "none", + }, + "&.cm-editor.cm-focused": { + outline: "2px solid var(--color-ring)", + outlineOffset: "2px", + }, + ".cm-placeholder": { + color: "var(--color-muted-foreground)", + fontStyle: "italic", + }, + ".cm-cursor": { + borderLeftWidth: "2px", + }, + ".cm-scroller": { + fontFamily: "inherit", + lineHeight: "1.5", + }, + ".cm-gutters": { + fontFamily: "inherit", + }, + }), + ], + [placeholderText, height], + ); + + // Initialize the editor + useEffect(() => { + if (!containerRef.current || viewRef.current) return; + + // Create extensions with compartments + const extensions = [ + ...baseExtensions, + languageCompartment.of(getLanguageExtension(language)), + themeCompartment.of(getThemeExtension(activeTheme)), + readOnlyCompartment.of(EditorState.readOnly.of(readOnly)), + lineNumbersCompartment.of( + showLineNumbers ? [lineNumbers(), foldGutter()] : [], + ), + lineWrappingCompartment.of(wordWrap ? EditorView.lineWrapping : []), + ]; + + // Create editor state + const state = EditorState.create({ + doc: value, + extensions, + }); + + // Create editor view + const view = new EditorView({ + state, + parent: containerRef.current, + }); + + const handleBlur = () => { + if (onChangeRef.current) { + onChangeRef.current(view.state.doc.toString()); } - }), - ]; - - // Create editor state - const state = EditorState.create({ - doc: value, - extensions, - }); - - // Create editor view - const view = new EditorView({ - state, - parent: containerRef.current, - }); - - viewRef.current = view; - - // Cleanup - return () => { - view.destroy(); - viewRef.current = null; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); // Only run once on mount - we handle all updates through compartments - - // Update language - useEffect(() => { - if (!viewRef.current) return; - - viewRef.current.dispatch({ - effects: languageCompartment.reconfigure(getLanguageExtension(language)), - }); - }, [language, getLanguageExtension]); - - // Update theme - useEffect(() => { - if (!viewRef.current) return; - - viewRef.current.dispatch({ - effects: themeCompartment.reconfigure(getThemeExtension(activeTheme)), - }); - }, [activeTheme, getThemeExtension]); - - // Update read-only state - useEffect(() => { - if (!viewRef.current) return; - - viewRef.current.dispatch({ - effects: readOnlyCompartment.reconfigure( - EditorState.readOnly.of(readOnly) - ), - }); - }, [readOnly]); - - // Update line numbers - useEffect(() => { - if (!viewRef.current) return; - - viewRef.current.dispatch({ - effects: lineNumbersCompartment.reconfigure( - showLineNumbers ? [lineNumbers(), foldGutter()] : [] - ), - }); - }, [showLineNumbers]); - - // Update line wrapping - useEffect(() => { - if (!viewRef.current) return; - - viewRef.current.dispatch({ - effects: lineWrappingCompartment.reconfigure( - wordWrap ? EditorView.lineWrapping : [] - ), - }); - }, [wordWrap]); - - // Update content when value changes from outside - useEffect(() => { - if (!viewRef.current) return; - - const currentValue = viewRef.current.state.doc.toString(); - if (value !== currentValue) { - // Use transaction to update the document + }; + + view.dom.addEventListener("blur", handleBlur); + + viewRef.current = view; + + // Cleanup + return () => { + view.dom.removeEventListener("blur", handleBlur); + view.destroy(); + viewRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // Only run once on mount - we handle all updates through compartments + + // Update language + useEffect(() => { + if (!viewRef.current) return; + viewRef.current.dispatch({ - changes: { - from: 0, - to: currentValue.length, - insert: value, - }, + effects: languageCompartment.reconfigure( + getLanguageExtension(language), + ), }); - } - }, [value]); - - return ( -
- ); -} + }, [language, getLanguageExtension]); + + // Update theme + useEffect(() => { + if (!viewRef.current) return; + + viewRef.current.dispatch({ + effects: themeCompartment.reconfigure(getThemeExtension(activeTheme)), + }); + }, [activeTheme, getThemeExtension]); + + // Update read-only state + useEffect(() => { + if (!viewRef.current) return; + + viewRef.current.dispatch({ + effects: readOnlyCompartment.reconfigure( + EditorState.readOnly.of(readOnly), + ), + }); + }, [readOnly]); + + // Update line numbers + useEffect(() => { + if (!viewRef.current) return; + + viewRef.current.dispatch({ + effects: lineNumbersCompartment.reconfigure( + showLineNumbers ? [lineNumbers(), foldGutter()] : [], + ), + }); + }, [showLineNumbers]); + + // Update line wrapping + useEffect(() => { + if (!viewRef.current) return; + + viewRef.current.dispatch({ + effects: lineWrappingCompartment.reconfigure( + wordWrap ? EditorView.lineWrapping : [], + ), + }); + }, [wordWrap]); + + // Update content when value changes from outside + useEffect(() => { + if (!viewRef.current) return; + + const currentValue = viewRef.current.state.doc.toString(); + if (value !== currentValue) { + // Use transaction to update the document + viewRef.current.dispatch({ + changes: { + from: 0, + to: currentValue.length, + insert: value, + }, + }); + } + }, [value]); + + return ( +
+ ); + }, +);