import { useState, useCallback, useMemo, useRef, useId, type UIEvent } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { FileEdit, Eye, ListOrdered, WrapText, ChevronDown, ChevronUp } from "lucide-react"; interface FileEditorProps { content: string; onChange: (content: string) => void; readOnly?: boolean; filePath?: string; showLineNumbers?: boolean; onToggleLineNumbers?: () => void; canToggleLineNumbers?: boolean; } 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"; } export function FileEditor({ content, onChange, readOnly, filePath, showLineNumbers = false, onToggleLineNumbers, canToggleLineNumbers = true, }: FileEditorProps) { const [showPreview, setShowPreview] = useState(false); const [wordWrap, setWordWrap] = useState(true); const [toolbarActionsExpanded, setToolbarActionsExpanded] = useState(false); const lineNumbersRef = useRef(null); const isMarkdown = isMarkdownFile(filePath); const toolbarActionsId = useId(); // For markdown files in readOnly mode, default to preview const effectiveShowPreview = isMarkdown && (readOnly ? true : showPreview); const shouldRenderLineNumbers = showLineNumbers && !readOnly && !effectiveShowPreview; const shouldShowLineNumbersToggle = Boolean(onToggleLineNumbers) && canToggleLineNumbers && !readOnly && !effectiveShowPreview; const hasSecondaryActions = shouldShowLineNumbersToggle || !readOnly; const lineCount = useMemo(() => { if (!shouldRenderLineNumbers) { return 0; } 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(() => { setToolbarActionsExpanded((prev) => !prev); }, []); const handleTextareaScroll = useCallback((event: UIEvent) => { if (!lineNumbersRef.current) { return; } const themeOverlay = EditorView.theme({ "&": { height: "100%", fontFamily: "var(--font-mono)", backgroundColor: "var(--bg)", color: "var(--text)", }, ".cm-scroller": { fontFamily: "var(--font-mono)", fontSize: "calc(var(--space-md) + var(--space-xs) * 0.5)", }, ".cm-content": { caretColor: "var(--text)", }, ".cm-gutters": { backgroundColor: "var(--surface)", color: "var(--text-muted)", borderRight: "calc(var(--space-xs) * 0.25) solid var(--border)", }, ".cm-activeLineGutter": { backgroundColor: "color-mix(in srgb, var(--surface) 80%, transparent)", }, ".cm-selectionBackground, .cm-content ::selection": { backgroundColor: "color-mix(in srgb, var(--todo) 30%, transparent)", }, "&.cm-focused": { outline: "none", }, }); const lineNumbersExtension = shouldRenderLineNumbers ? lineNumbers() : []; const wrapExtension = wordWrap ? EditorView.lineWrapping : []; const readOnlyExtensions = readOnly ? [EditorState.readOnly.of(true), EditorView.editable.of(false)] : []; const themeExtension = darkThemeActive ? [oneDark] : []; const state = EditorState.create({ doc: content, extensions: [ lineNumbersCompartmentRef.current.of(lineNumbersExtension), wordWrapCompartmentRef.current.of(wrapExtension), readOnlyCompartmentRef.current.of(readOnlyExtensions), languageCompartmentRef.current.of(languageExtension ?? []), themeCompartmentRef.current.of(themeExtension), themeOverlay, EditorView.updateListener.of((update) => { if (!update.docChanged) { return; } if (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(); }; }, [effectiveShowPreview]); useEffect(() => { const view = editorViewRef.current; if (!view) { return; } const extensions = shouldRenderLineNumbers ? lineNumbers() : []; view.dispatch({ effects: lineNumbersCompartmentRef.current.reconfigure(extensions) }); }, [shouldRenderLineNumbers]); useEffect(() => { const view = editorViewRef.current; if (!view) { return; } const extension = wordWrap ? EditorView.lineWrapping : []; view.dispatch({ effects: wordWrapCompartmentRef.current.reconfigure(extension) }); }, [wordWrap]); useEffect(() => { const view = editorViewRef.current; if (!view) { return; } const extensions = readOnly ? [EditorState.readOnly.of(true), EditorView.editable.of(false)] : []; view.dispatch({ effects: readOnlyCompartmentRef.current.reconfigure(extensions) }); }, [readOnly]); useEffect(() => { const view = editorViewRef.current; if (!view) { return; } view.dispatch({ effects: languageCompartmentRef.current.reconfigure(languageExtension ?? []) }); }, [languageExtension]); useEffect(() => { const view = editorViewRef.current; if (!view) { return; } view.dispatch({ effects: themeCompartmentRef.current.reconfigure(darkThemeActive ? [oneDark] : []) }); }, [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 (
{isMarkdown ? (
{!readOnly && ( )}
{!readOnly && (
{hasSecondaryActions && ( <> )}
)}
) : ( !readOnly && (
{hasSecondaryActions && ( <> )}
) )} {effectiveShowPreview ? (
{content}
) : (
)}
); }