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:
@@ -669,10 +669,21 @@
|
|||||||
min-height: 0;
|
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%;
|
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 */
|
||||||
.file-editor-toolbar {
|
.file-editor-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ function isMarkdownFile(filePath?: string): boolean {
|
|||||||
return lowerPath.endsWith(".md") || lowerPath.endsWith(".markdown") || lowerPath.endsWith(".mdx");
|
return lowerPath.endsWith(".md") || lowerPath.endsWith(".markdown") || lowerPath.endsWith(".mdx");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDarkTheme(): boolean {
|
||||||
|
return document.documentElement.dataset.theme !== "light";
|
||||||
|
}
|
||||||
|
|
||||||
export function FileEditor({
|
export function FileEditor({
|
||||||
content,
|
content,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -45,8 +49,7 @@ export function FileEditor({
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return content.split("\n").length;
|
const languageExtension = useMemo(() => resolveCodeMirrorLanguage(filePath), [filePath]);
|
||||||
}, [content, shouldRenderLineNumbers]);
|
|
||||||
|
|
||||||
const handleEditClick = useCallback(() => {
|
const handleEditClick = useCallback(() => {
|
||||||
setShowPreview(false);
|
setShowPreview(false);
|
||||||
@@ -69,8 +72,147 @@ export function FileEditor({
|
|||||||
return;
|
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 (
|
return (
|
||||||
<div className="file-editor-container">
|
<div className="file-editor-container">
|
||||||
@@ -190,26 +332,11 @@ export function FileEditor({
|
|||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={`file-editor-textarea-shell ${shouldRenderLineNumbers ? "file-editor-textarea-shell--line-numbers" : ""}`}>
|
<div
|
||||||
{shouldRenderLineNumbers && (
|
className="file-editor-codemirror"
|
||||||
<div className="file-editor-line-numbers" ref={lineNumbersRef} aria-hidden="true">
|
ref={editorHostRef}
|
||||||
{Array.from({ length: lineCount }, (_, index) => (
|
aria-label={filePath ? `Editor for ${filePath}` : "File editor"}
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user