import { useState, useCallback, useMemo, useRef, useId, useEffect } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { FileEdit, Eye, ListOrdered, WrapText, ChevronDown, ChevronUp } from "lucide-react"; import { EditorView, lineNumbers } from "@codemirror/view"; import { EditorState, Compartment, type Extension } from "@codemirror/state"; import { syntaxHighlighting, defaultHighlightStyle } from "@codemirror/language"; import { oneDark } from "@codemirror/theme-one-dark"; import { resolveCodeMirrorLanguage } from "../utils/codemirror-language"; interface FileEditorProps { content: string; onChange: (content: string) => void; readOnly?: boolean; filePath?: string; showLineNumbers?: boolean; onToggleLineNumbers?: () => void; canToggleLineNumbers?: boolean; toolbarExpanded?: boolean; toolbarActionsId?: string; } function isMarkdownFile(filePath?: string): boolean { if (!filePath) return false; const lowerPath = filePath.toLowerCase(); return lowerPath.endsWith(".md") || lowerPath.endsWith(".markdown") || lowerPath.endsWith(".mdx"); } function isDarkTheme(): boolean { return document.documentElement.dataset.theme !== "light"; } function buildThemeExtension(isDark: boolean): Extension[] { return isDark ? [oneDark] : [syntaxHighlighting(defaultHighlightStyle)]; } export function FileEditor({ content, onChange, readOnly, filePath, showLineNumbers = false, onToggleLineNumbers, canToggleLineNumbers = true, toolbarExpanded, toolbarActionsId: externalToolbarActionsId, }: FileEditorProps) { const [showPreview, setShowPreview] = useState(false); const [wordWrap, setWordWrap] = useState(true); const [internalExpanded, setInternalExpanded] = useState(false); const isControlled = toolbarExpanded !== undefined; const expanded = isControlled ? toolbarExpanded : internalExpanded; const editorHostRef = useRef(null); const editorViewRef = useRef(null); const syncingFromPropsRef = useRef(false); const onChangeRef = useRef(onChange); onChangeRef.current = onChange; const lineNumbersCompartmentRef = useRef(new Compartment()); const wordWrapCompartmentRef = useRef(new Compartment()); const readOnlyCompartmentRef = useRef(new Compartment()); const languageCompartmentRef = useRef(new Compartment()); const themeCompartmentRef = useRef(new Compartment()); const isMarkdown = isMarkdownFile(filePath); const generatedToolbarActionsId = useId(); const toolbarActionsId = externalToolbarActionsId ?? generatedToolbarActionsId; const [darkThemeActive, setDarkThemeActive] = useState(() => isDarkTheme()); const effectiveShowPreview = isMarkdown && (readOnly ? true : showPreview); const shouldRenderLineNumbers = showLineNumbers && !readOnly && !effectiveShowPreview; const shouldShowLineNumbersToggle = Boolean(onToggleLineNumbers) && canToggleLineNumbers && !readOnly && !effectiveShowPreview; const hasToolbarActions = isMarkdown || !readOnly || shouldShowLineNumbersToggle; const languageExtension = useMemo(() => resolveCodeMirrorLanguage(filePath), [filePath]); const handleEditClick = useCallback(() => setShowPreview(false), []); const handlePreviewClick = useCallback(() => setShowPreview(true), []); const handleWordWrapToggle = useCallback(() => setWordWrap((prev) => !prev), []); const handleToolbarActionsToggle = useCallback(() => { if (!isControlled) { setInternalExpanded((prev) => !prev); } }, [isControlled]); useEffect(() => { if (!editorHostRef.current || effectiveShowPreview) { return; } const themeOverlay = EditorView.theme({ "&": { height: "100%", fontFamily: "var(--font-mono)", backgroundColor: "var(--bg)", color: "var(--text)" }, ".cm-gutters": { backgroundColor: "var(--surface)", color: "var(--text-muted)", borderRight: "calc(var(--space-xs) * 0.25) solid var(--border)" }, "&.cm-focused": { outline: "none" }, }); const state = EditorState.create({ doc: content, extensions: [ lineNumbersCompartmentRef.current.of(shouldRenderLineNumbers ? lineNumbers() : []), wordWrapCompartmentRef.current.of(wordWrap ? EditorView.lineWrapping : []), readOnlyCompartmentRef.current.of(readOnly ? [EditorState.readOnly.of(true), EditorView.editable.of(false)] : []), languageCompartmentRef.current.of(languageExtension ?? []), themeCompartmentRef.current.of(buildThemeExtension(darkThemeActive)), themeOverlay, EditorView.updateListener.of((update) => { if (!update.docChanged || syncingFromPropsRef.current) return; onChangeRef.current(update.state.doc.toString()); }), ], }); const view = new EditorView({ state, parent: editorHostRef.current }); editorViewRef.current = view; return () => { editorViewRef.current = null; view.destroy(); }; }, [content, effectiveShowPreview, languageExtension, readOnly, shouldRenderLineNumbers, wordWrap]); useEffect(() => { const observer = new MutationObserver(() => { setDarkThemeActive(isDarkTheme()); }); observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"], }); return () => observer.disconnect(); }, []); useEffect(() => { const view = editorViewRef.current; if (!view) return; view.dispatch({ effects: themeCompartmentRef.current.reconfigure(buildThemeExtension(darkThemeActive)), }); }, [darkThemeActive]); useEffect(() => { const view = editorViewRef.current; if (!view) return; const currentContent = view.state.doc.toString(); if (currentContent === content) return; syncingFromPropsRef.current = true; view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: content } }); syncingFromPropsRef.current = false; }, [content]); return (
{hasToolbarActions && (expanded || !isControlled) ? (
{!isControlled && ( )}
) : null} {effectiveShowPreview ? (
{content}
) : (
)}
); }