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 (
+
+ );
+ },
+);