/ proof-of-concept / plate-dynamics-lab-2
Sign in
proof-of-concept / plate-dynamics-lab-2
plate-dynamics-lab-2 / packages / components / src / code / CodeView.tsx
162 lines · 5.3 KBCodeBlameHistory
2004981Plate dynamics lab 2: a hammer on a hung plate by Rayleigh-Ritz and by Mindlin-Reissner finite elementsJeremy Magland 1import { useEffect, useMemo, useRef, useState } from "react";
2import { EditorView, basicSetup } from "codemirror";
3import { EditorState, Compartment } from "@codemirror/state";
4import { StreamLanguage } from "@codemirror/language";
5import { octave } from "@codemirror/legacy-modes/mode/octave";
6import type { SourceFile } from "@plate-dyn/core";
8export interface CodeViewProps {
9 /** The generated sources */
10 files: SourceFile[];
11 /** User edits by file name; a file absent here shows its generated text */
12 edits: Record<string, string>;
13 onEditsChange: (edits: Record<string, string>) => void;
14 /** Tab order; files not listed follow in their original order */
15 order?: string[];
16 /** One-line descriptions shown under the tabs */
17 summaries?: Record<string, string>;
18 readOnly?: boolean;
19 /** Called with the active file name */
20 onActiveChange?: (name: string) => void;
21 initialFile?: string;
22 height?: number | string;
25/**
26 * Tabbed source view of exactly the `.m` files a run executes. Editing a file
27 * records an edit; the run then uses the edited text, and a modified tab shows
28 * an asterisk with a way back to the generated version.
29 */
30export function CodeView(props: CodeViewProps) {
31 const {
32 files,
33 edits,
34 onEditsChange,
35 order,
36 summaries,
37 readOnly,
38 onActiveChange,
39 initialFile,
40 height,
41 } = props;
42 const ordered = useMemo(() => {
43 const byName = new Map(files.map((f) => [f.name, f]));
44 const out: SourceFile[] = [];
45 for (const n of order ?? []) if (byName.has(n)) out.push(byName.get(n)!);
46 for (const f of files) if (!out.includes(f)) out.push(f);
47 return out;
48 }, [files, order]);
50 const [active, setActive] = useState(initialFile ?? ordered[0]?.name ?? "");
51 useEffect(() => {
52 if (!ordered.some((f) => f.name === active) && ordered[0]) setActive(ordered[0].name);
53 }, [ordered, active]);
54 useEffect(() => onActiveChange?.(active), [active, onActiveChange]);
56 const hostRef = useRef<HTMLDivElement>(null);
57 const viewRef = useRef<EditorView | null>(null);
58 const editsRef = useRef(edits);
59 editsRef.current = edits;
60 const filesRef = useRef(files);
61 filesRef.current = files;
62 const activeRef = useRef(active);
63 activeRef.current = active;
64 const onEditsRef = useRef(onEditsChange);
65 onEditsRef.current = onEditsChange;
66 const readOnlyCompartment = useMemo(() => new Compartment(), []);
68 // One editor for the lifetime of the component
69 useEffect(() => {
70 if (!hostRef.current) return;
71 const view = new EditorView({
72 parent: hostRef.current,
73 state: EditorState.create({
74 doc: "",
75 extensions: [
76 basicSetup,
77 StreamLanguage.define(octave),
78 readOnlyCompartment.of(EditorState.readOnly.of(!!readOnly)),
79 EditorView.updateListener.of((u) => {
80 if (!u.docChanged) return;
81 const name = activeRef.current;
82 const text = u.state.doc.toString();
83 const original = filesRef.current.find((f) => f.name === name)?.source;
84 const next = { ...editsRef.current };
85 if (text === original) delete next[name];
86 else next[name] = text;
87 onEditsRef.current(next);
88 }),
89 ],
90 }),
91 });
92 viewRef.current = view;
93 return () => {
94 view.destroy();
95 viewRef.current = null;
96 };
97 // eslint-disable-next-line react-hooks/exhaustive-deps
98 }, []);
100 useEffect(() => {
101 viewRef.current?.dispatch({
102 effects: readOnlyCompartment.reconfigure(EditorState.readOnly.of(!!readOnly)),
103 });
104 }, [readOnly, readOnlyCompartment]);
106 // Load the active file's text (edited or generated) into the editor whenever it changes externally
107 const shown = edits[active] ?? files.find((f) => f.name === active)?.source ?? "";
108 useEffect(() => {
109 const view = viewRef.current;
110 if (!view) return;
111 const current = view.state.doc.toString();
112 if (current !== shown) {
113 view.dispatch({ changes: { from: 0, to: current.length, insert: shown } });
114 }
115 }, [shown, active]);
117 const modified = active in edits;
118 const restore = () => {
119 const next = { ...edits };
120 delete next[active];
121 onEditsChange(next);
122 };
123 const restoreAll = () => onEditsChange({});
124 const nModified = Object.keys(edits).length;
126 return (
127 <div className="pl-code" style={{ height }}>
128 <div className="pl-code-tabs" role="tablist">
129 {ordered.map((f) => (
130 <button
131 key={f.name}
132 role="tab"
133 aria-selected={f.name === active}
134 className={`pl-code-tab${f.name === active ? " pl-active" : ""}${f.name in edits ? " pl-modified" : ""}`}
135 onClick={() => setActive(f.name)}
136 title={summaries?.[f.name]}
137 >
138 {f.name}
139 </button>
140 ))}
141 </div>
142 <div className="pl-code-toolbar">
143 <span>{summaries?.[active] ?? ""}</span>
144 <span style={{ flex: 1 }} />
145 {modified && (
146 <>
147 <span className="pl-badge pl-badge-warn">edited</span>
148 <button className="pl-btn" onClick={restore}>
149 Restore this file
150 </button>
151 </>
152 )}
153 {nModified > 1 && (
154 <button className="pl-btn" onClick={restoreAll}>
155 Restore all ({nModified})
156 </button>
157 )}
158 </div>
159 <div className="pl-code-editor" ref={hostRef} />
160 </div>
161 );
↑↓ move↵openescclose