feat(FN-4411): complete Step 3 — replace textarea with CodeMirror

Fusion-Task-Id: FN-4411
Fusion-Task-Lineage: d90785f2-b828-4fa0-b330-ede7f3c14349
This commit is contained in:
Fusion
2026-05-14 01:56:57 -07:00
committed by gsxdsm
parent 280466a163
commit 873f4ac2fd
2 changed files with 163 additions and 25 deletions

View File

@@ -669,10 +669,21 @@
min-height: 0;
}
.file-editor-container .cm-editor {
.file-editor-codemirror {
flex: 1;
min-height: 0;
overflow: hidden;
}
.file-editor-codemirror .cm-editor {
height: 100%;
}
.file-editor-codemirror .cm-scroller {
font-family: var(--font-mono);
font-size: calc(var(--space-md) + var(--space-xs) * 0.5);
}
/* File Editor Toolbar */
.file-editor-toolbar {
display: flex;

View File

@@ -19,6 +19,10 @@ function isMarkdownFile(filePath?: string): boolean {
return lowerPath.endsWith(".md") || lowerPath.endsWith(".markdown") || lowerPath.endsWith(".mdx");
}
function isDarkTheme(): boolean {
return document.documentElement.dataset.theme !== "light";
}
export function FileEditor({
content,
onChange,
@@ -45,8 +49,7 @@ export function FileEditor({
return 0;
}
return content.split("\n").length;
}, [content, shouldRenderLineNumbers]);
const languageExtension = useMemo(() => resolveCodeMirrorLanguage(filePath), [filePath]);
const handleEditClick = useCallback(() => {
setShowPreview(false);
@@ -69,8 +72,147 @@ export function FileEditor({
return;
}
lineNumbersRef.current.scrollTop = event.currentTarget.scrollTop;
}, []);
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();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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 (
<div className="file-editor-container">
@@ -190,26 +332,11 @@ export function FileEditor({
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
) : (
<div className={`file-editor-textarea-shell ${shouldRenderLineNumbers ? "file-editor-textarea-shell--line-numbers" : ""}`}>
{shouldRenderLineNumbers && (
<div className="file-editor-line-numbers" ref={lineNumbersRef} aria-hidden="true">
{Array.from({ length: lineCount }, (_, index) => (
<div key={`line-${index + 1}`} className="file-editor-line-number">
{index + 1}
</div>
))}
</div>
)}
<textarea
className={`file-editor-textarea ${wordWrap ? "file-editor-textarea--wrap" : ""}`}
value={content}
onChange={(e) => onChange(e.target.value)}
onScroll={handleTextareaScroll}
readOnly={readOnly}
spellCheck={false}
aria-label={filePath ? `Editor for ${filePath}` : "File editor"}
/>
</div>
<div
className="file-editor-codemirror"
ref={editorHostRef}
aria-label={filePath ? `Editor for ${filePath}` : "File editor"}
/>
)}
</div>
);