211 lines
8 KiB
TypeScript
211 lines
8 KiB
TypeScript
import "../index.css";
|
|
import "./App.css";
|
|
|
|
import { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
import { connectionsEqual, detectConnections, reducedConcreteSyntaxEqual } from "@/statecharts/detect_connections";
|
|
import { parseStatechart } from "../statecharts/parser";
|
|
import { BottomPanel } from "./BottomPanel/BottomPanel";
|
|
import { defaultSideBarState, SideBar, SideBarState } from "./SideBar/SideBar";
|
|
import { InsertMode } from "./TopPanel/InsertModes";
|
|
import { TopPanel } from "./TopPanel/TopPanel";
|
|
import { VisualEditor, VisualEditorState } from "./VisualEditor/VisualEditor";
|
|
import { makeAllSetters } from "./makePartialSetter";
|
|
import { useEditor } from "./hooks/useEditor";
|
|
import { useSimulator } from "./hooks/useSimulator";
|
|
import { useUrlHashState } from "../hooks/useUrlHashState";
|
|
import { plants } from "./plants";
|
|
import { emptyState } from "@/statecharts/concrete_syntax";
|
|
import { ModalOverlay } from "./Overlays/ModalOverlay";
|
|
import { FindReplace } from "./BottomPanel/FindReplace";
|
|
import { useCustomMemo } from "@/hooks/useCustomMemo";
|
|
|
|
export type EditHistory = {
|
|
current: VisualEditorState,
|
|
history: VisualEditorState[],
|
|
future: VisualEditorState[],
|
|
}
|
|
|
|
export type AppState = {
|
|
showKeys: boolean,
|
|
zoom: number,
|
|
insertMode: InsertMode,
|
|
showFindReplace: boolean,
|
|
findText: string,
|
|
replaceText: string,
|
|
} & SideBarState;
|
|
|
|
const defaultAppState: AppState = {
|
|
showKeys: true,
|
|
zoom: 1,
|
|
insertMode: 'and',
|
|
showFindReplace: false,
|
|
findText: "",
|
|
replaceText: "",
|
|
...defaultSideBarState,
|
|
}
|
|
|
|
export type LightMode = "light" | "auto" | "dark";
|
|
|
|
export function App() {
|
|
const [editHistory, setEditHistory] = useState<EditHistory|null>(null);
|
|
const [modal, setModal] = useState<ReactElement|null>(null);
|
|
|
|
const {commitState, replaceState, onRedo, onUndo, onRotate} = useEditor(setEditHistory);
|
|
|
|
const editorState = editHistory && editHistory.current;
|
|
const setEditorState = useCallback((cb: (value: VisualEditorState) => VisualEditorState) => {
|
|
setEditHistory(historyState => historyState && ({...historyState, current: cb(historyState.current)}));
|
|
}, [setEditHistory]);
|
|
|
|
// parse concrete syntax always:
|
|
const conns = useMemo(() => editorState && detectConnections(editorState), [editorState]);
|
|
const parsed = useCustomMemo(() => editorState && conns && parseStatechart(editorState, conns),
|
|
[editorState, conns] as const,
|
|
// only parse again if anything changed to the connectedness / insideness...
|
|
// parsing is fast, BUT re-rendering everything that depends on the AST is slow, and it's difficult to check if the AST changed because AST objects have recursive structure.
|
|
([prevState, prevConns], [nextState, nextConns]) => {
|
|
if ((prevState === null) !== (nextState === null)) return false;
|
|
if ((prevConns === null) !== (nextConns === null)) return false;
|
|
// the following check is much cheaper than re-rendering everything that depends on
|
|
return connectionsEqual(prevConns!, nextConns!)
|
|
&& reducedConcreteSyntaxEqual(prevState!, nextState!);
|
|
});
|
|
const ast = parsed && parsed[0];
|
|
|
|
const [appState, setAppState] = useState<AppState>(defaultAppState);
|
|
|
|
const persist = useUrlHashState<VisualEditorState | AppState & {editorState: VisualEditorState}>(
|
|
recoveredState => {
|
|
if (recoveredState === null) {
|
|
setEditHistory(() => ({current: emptyState, history: [], future: []}));
|
|
}
|
|
// we support two formats
|
|
// @ts-ignore
|
|
else if (recoveredState.nextID) {
|
|
// old format
|
|
setEditHistory(() => ({current: recoveredState as VisualEditorState, history: [], future: []}));
|
|
}
|
|
else {
|
|
// new format
|
|
// @ts-ignore
|
|
if (recoveredState.editorState !== undefined) {
|
|
const {editorState, ...appState} = recoveredState as AppState & {editorState: VisualEditorState};
|
|
setEditHistory(() => ({current: editorState, history: [], future: []}));
|
|
setAppState(defaultAppState => Object.assign({}, defaultAppState, appState));
|
|
}
|
|
}
|
|
},
|
|
);
|
|
|
|
useEffect(() => {
|
|
const timeout = setTimeout(() => {
|
|
if (editorState !== null) {
|
|
console.log('persisting state to url');
|
|
persist({editorState, ...appState});
|
|
}
|
|
}, 100);
|
|
return () => clearTimeout(timeout);
|
|
}, [editorState, appState]);
|
|
|
|
const {
|
|
autoScroll,
|
|
plantConns,
|
|
plantName,
|
|
} = appState;
|
|
|
|
const plant = plants.find(([pn, p]) => pn === plantName)![1];
|
|
|
|
const refRightSideBar = useRef<HTMLDivElement>(null);
|
|
const scrollDownSidebar = useCallback(() => {
|
|
if (autoScroll && refRightSideBar.current) {
|
|
const el = refRightSideBar.current;
|
|
// hack: we want to scroll to the new element, but we have to wait until it is rendered...
|
|
setTimeout(() => {
|
|
el.scrollIntoView({block: "end", behavior: "smooth"});
|
|
}, 50);
|
|
}
|
|
}, [refRightSideBar.current, autoScroll]);
|
|
|
|
const simulator = useSimulator(ast, plant, plantConns, scrollDownSidebar);
|
|
|
|
const setters = makeAllSetters(setAppState, Object.keys(appState) as (keyof AppState)[]);
|
|
|
|
const syntaxErrors = parsed && parsed[1] || [];
|
|
const currentTraceItem = simulator.trace && simulator.trace.trace[simulator.trace.idx];
|
|
const currentBigStep = currentTraceItem && currentTraceItem.kind === "bigstep" && currentTraceItem;
|
|
const allErrors = [
|
|
...syntaxErrors,
|
|
...(currentTraceItem && currentTraceItem.kind === "error") ? [{
|
|
message: currentTraceItem.error.message,
|
|
shapeUid: currentTraceItem.error.highlight[0],
|
|
}] : [],
|
|
];
|
|
const highlightActive = (currentBigStep && currentBigStep.state.sc.mode) || new Set();
|
|
const highlightTransitions = currentBigStep && currentBigStep.state.sc.firedTransitions || [];
|
|
|
|
const plantState = useMemo(() =>
|
|
currentBigStep && currentBigStep.state.plant || plant.execution.initial()[1],
|
|
[currentBigStep, plant]);
|
|
|
|
return <div style={{
|
|
height:'100%',
|
|
// doesn't work:
|
|
// colorScheme: lightMode !== "auto" ? lightMode : undefined,
|
|
}}>
|
|
<ModalOverlay modal={modal} setModal={setModal}>
|
|
{/* top-to-bottom: everything -> bottom panel */}
|
|
<div className="stackVertical" style={{height:'100%'}}>
|
|
|
|
{/* left-to-right: main -> sidebar */}
|
|
<div className="stackHorizontal" style={{flexGrow:1, overflow: "auto"}}>
|
|
|
|
{/* top-to-bottom: top bar, editor */}
|
|
<div className="stackVertical" style={{flexGrow:1, overflow: "auto"}}>
|
|
{/* Top bar */}
|
|
<div
|
|
className="shadowBelow"
|
|
style={{flex: '0 0 content'}}
|
|
>
|
|
{editHistory && <TopPanel
|
|
{...{onUndo, onRedo, onRotate, setModal, editHistory, ...simulator, ...setters, ...appState, setEditorState}}
|
|
/>}
|
|
</div>
|
|
{/* Editor */}
|
|
<div style={{flexGrow: 1, overflow: "auto"}}>
|
|
{editorState && conns && syntaxErrors &&
|
|
<VisualEditor {...{state: editorState, commitState, replaceState, conns, syntaxErrors: allErrors, highlightActive, highlightTransitions, setModal, ...appState}}/>}
|
|
</div>
|
|
|
|
{appState.showFindReplace &&
|
|
<div>
|
|
<FindReplace setCS={setEditorState} hide={() => setters.setShowFindReplace(false)}/>
|
|
</div>
|
|
}
|
|
|
|
</div>
|
|
|
|
{/* Right: sidebar */}
|
|
<div style={{
|
|
flex: '0 0 content',
|
|
borderLeft: '1px solid var(--separator-color)',
|
|
overflowY: "auto",
|
|
overflowX: "auto",
|
|
maxWidth: 'min(400px, 50vw)',
|
|
}}>
|
|
<div className="stackVertical" style={{height:'100%'}}>
|
|
<SideBar {...{...appState, refRightSideBar, ast, plantState, ...simulator, ...setters}} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bottom panel */}
|
|
<div style={{flex: '0 0 content'}}>
|
|
{syntaxErrors && <BottomPanel {...{errors: syntaxErrors, ...appState, setEditorState, ...setters}}/>}
|
|
</div>
|
|
</div>
|
|
</ModalOverlay>
|
|
</div>;
|
|
}
|
|
|
|
export default App;
|