some refactoring and performance optimization
This commit is contained in:
parent
0fac3977b3
commit
41b2de7529
4 changed files with 219 additions and 255 deletions
|
|
@ -71,7 +71,7 @@ function getPlantState<T>(plant: Plant<T>, trace: TraceItem[], idx: number): T |
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [insertMode, setInsertMode] = useState<InsertMode>("and");
|
const [insertMode, setInsertMode] = useState<InsertMode>("and");
|
||||||
const [historyState, setHistoryState] = useState<EditHistory>({current: emptyState, history: [], future: []});
|
const [editHistory, setEditHistory] = useState<EditHistory>({current: emptyState, history: [], future: []});
|
||||||
const [trace, setTrace] = useState<TraceState|null>(null);
|
const [trace, setTrace] = useState<TraceState|null>(null);
|
||||||
const [time, setTime] = useState<TimeMode>({kind: "paused", simtime: 0});
|
const [time, setTime] = useState<TimeMode>({kind: "paused", simtime: 0});
|
||||||
const [modal, setModal] = useState<ReactElement|null>(null);
|
const [modal, setModal] = useState<ReactElement|null>(null);
|
||||||
|
|
@ -82,10 +82,57 @@ export function App() {
|
||||||
|
|
||||||
const plant = plants.find(([pn, p]) => pn === plantName)![1];
|
const plant = plants.find(([pn, p]) => pn === plantName)![1];
|
||||||
|
|
||||||
const editorState = historyState.current;
|
const editorState = editHistory.current;
|
||||||
const setEditorState = useCallback((cb: (value: VisualEditorState) => VisualEditorState) => {
|
const setEditorState = useCallback((cb: (value: VisualEditorState) => VisualEditorState) => {
|
||||||
setHistoryState(historyState => ({...historyState, current: cb(historyState.current)}));
|
setEditHistory(historyState => ({...historyState, current: cb(historyState.current)}));
|
||||||
}, [setHistoryState]);
|
}, [setEditHistory]);
|
||||||
|
|
||||||
|
// recover editor state from URL - we need an effect here because decompression is asynchronous
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const compressedState = window.location.hash.slice(1);
|
||||||
|
const ds = new DecompressionStream("deflate");
|
||||||
|
const writer = ds.writable.getWriter();
|
||||||
|
writer.write(Uint8Array.fromBase64(compressedState)).catch(e => {
|
||||||
|
console.error("could not recover state:", e);
|
||||||
|
});
|
||||||
|
writer.close().catch(e => {
|
||||||
|
console.error("could not recover state:", e);
|
||||||
|
});
|
||||||
|
new Response(ds.readable).arrayBuffer().then(decompressedBuffer => {
|
||||||
|
try {
|
||||||
|
const recoveredState = JSON.parse(new TextDecoder().decode(decompressedBuffer));
|
||||||
|
setEditorState(() => recoveredState);
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
console.error("could not recover state:", e);
|
||||||
|
}
|
||||||
|
}).catch(e => {
|
||||||
|
console.error("could not recover state:", e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
console.error("could not recover state:", e);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// save editor state in URL
|
||||||
|
useEffect(() => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
const serializedState = JSON.stringify(editorState);
|
||||||
|
const stateBuffer = new TextEncoder().encode(serializedState);
|
||||||
|
const cs = new CompressionStream("deflate");
|
||||||
|
const writer = cs.writable.getWriter();
|
||||||
|
writer.write(stateBuffer);
|
||||||
|
writer.close();
|
||||||
|
// todo: cancel this promise handler when concurrently starting another compression job
|
||||||
|
new Response(cs.readable).arrayBuffer().then(compressedStateBuffer => {
|
||||||
|
const compressedStateString = new Uint8Array(compressedStateBuffer).toBase64();
|
||||||
|
window.location.hash = "#"+compressedStateString;
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [editorState]);
|
||||||
|
|
||||||
const refRightSideBar = useRef<HTMLDivElement>(null);
|
const refRightSideBar = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
|
@ -93,16 +140,18 @@ export function App() {
|
||||||
const conns = useMemo(() => detectConnections(editorState), [editorState]);
|
const conns = useMemo(() => detectConnections(editorState), [editorState]);
|
||||||
const [ast, syntaxErrors] = useMemo(() => parseStatechart(editorState, conns), [editorState, conns]);
|
const [ast, syntaxErrors] = useMemo(() => parseStatechart(editorState, conns), [editorState, conns]);
|
||||||
|
|
||||||
|
console.log('render App', ast);
|
||||||
|
|
||||||
// append editor state to undo history
|
// append editor state to undo history
|
||||||
const makeCheckPoint = useCallback(() => {
|
const makeCheckPoint = useCallback(() => {
|
||||||
setHistoryState(historyState => ({
|
setEditHistory(historyState => ({
|
||||||
...historyState,
|
...historyState,
|
||||||
history: [...historyState.history, historyState.current],
|
history: [...historyState.history, historyState.current],
|
||||||
future: [],
|
future: [],
|
||||||
}));
|
}));
|
||||||
}, [setHistoryState]);
|
}, [setEditHistory]);
|
||||||
const onUndo = useCallback(() => {
|
const onUndo = useCallback(() => {
|
||||||
setHistoryState(historyState => {
|
setEditHistory(historyState => {
|
||||||
if (historyState.history.length === 0) {
|
if (historyState.history.length === 0) {
|
||||||
return historyState; // no change
|
return historyState; // no change
|
||||||
}
|
}
|
||||||
|
|
@ -112,9 +161,9 @@ export function App() {
|
||||||
future: [...historyState.future, historyState.current],
|
future: [...historyState.future, historyState.current],
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [setHistoryState]);
|
}, [setEditHistory]);
|
||||||
const onRedo = useCallback(() => {
|
const onRedo = useCallback(() => {
|
||||||
setHistoryState(historyState => {
|
setEditHistory(historyState => {
|
||||||
if (historyState.future.length === 0) {
|
if (historyState.future.length === 0) {
|
||||||
return historyState; // no change
|
return historyState; // no change
|
||||||
}
|
}
|
||||||
|
|
@ -124,9 +173,19 @@ export function App() {
|
||||||
future: historyState.future.slice(0,-1),
|
future: historyState.future.slice(0,-1),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [setHistoryState]);
|
}, [setEditHistory]);
|
||||||
|
|
||||||
function onInit() {
|
const scrollDownSidebar = useCallback(() => {
|
||||||
|
if (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]);
|
||||||
|
|
||||||
|
const onInit = useCallback(() => {
|
||||||
const timestampedEvent = {simtime: 0, inputEvent: "<init>"};
|
const timestampedEvent = {simtime: 0, inputEvent: "<init>"};
|
||||||
let config;
|
let config;
|
||||||
try {
|
try {
|
||||||
|
|
@ -145,7 +204,8 @@ export function App() {
|
||||||
}
|
}
|
||||||
setTime({kind: "paused", simtime: 0});
|
setTime({kind: "paused", simtime: 0});
|
||||||
scrollDownSidebar();
|
scrollDownSidebar();
|
||||||
}
|
}, [ast, scrollDownSidebar, setTime, setTrace]);
|
||||||
|
|
||||||
const onClear = useCallback(() => {
|
const onClear = useCallback(() => {
|
||||||
setTrace(null);
|
setTrace(null);
|
||||||
setTime({kind: "paused", simtime: 0});
|
setTime({kind: "paused", simtime: 0});
|
||||||
|
|
@ -245,16 +305,6 @@ export function App() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollDownSidebar = useCallback(() => {
|
|
||||||
if (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);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log("Welcome to StateBuddy!");
|
console.log("Welcome to StateBuddy!");
|
||||||
() => {
|
() => {
|
||||||
|
|
@ -324,7 +374,7 @@ export function App() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TopPanel
|
<TopPanel
|
||||||
{...{trace, ast, time, setTime, onUndo, onRedo, onInit, onClear, onRaise, onBack, insertMode, setInsertMode, setModal, zoom, setZoom, showKeys, setShowKeys, history: historyState}}
|
{...{trace, time, setTime, onUndo, onRedo, onInit, onClear, onBack, insertMode, setInsertMode, setModal, zoom, setZoom, showKeys, setShowKeys, editHistory}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
{/* Below the top bar: Editor */}
|
{/* Below the top bar: Editor */}
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,23 @@
|
||||||
import { Dispatch, memo, ReactElement, SetStateAction, useEffect, useState } from "react";
|
import { Dispatch, memo, ReactElement, SetStateAction, useCallback, useEffect, useState } from "react";
|
||||||
import { BigStep, TimerElapseEvent, Timers } from "../statecharts/runtime_types";
|
import { TimerElapseEvent, Timers } from "../statecharts/runtime_types";
|
||||||
import { getSimTime, setPaused, setRealtime, TimeMode } from "../statecharts/time";
|
import { getSimTime, setPaused, setRealtime, TimeMode } from "../statecharts/time";
|
||||||
import { Statechart } from "../statecharts/abstract_syntax";
|
import { InsertMode } from "../VisualEditor/VisualEditor";
|
||||||
|
import { About } from "./About";
|
||||||
|
import { EditHistory, TraceState } from "./App";
|
||||||
|
import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo";
|
||||||
|
import { UndoRedoButtons } from "./TopPanel/UndoRedoButtons";
|
||||||
|
import { ZoomButtons } from "./TopPanel/ZoomButtons";
|
||||||
|
import { formatTime } from "./util";
|
||||||
|
|
||||||
import CachedIcon from '@mui/icons-material/Cached';
|
|
||||||
import PauseIcon from '@mui/icons-material/Pause';
|
|
||||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
|
||||||
import BoltIcon from '@mui/icons-material/Bolt';
|
|
||||||
import SkipNextIcon from '@mui/icons-material/SkipNext';
|
|
||||||
import SkipPreviousIcon from '@mui/icons-material/SkipPrevious';import TrendingFlatIcon from '@mui/icons-material/TrendingFlat';
|
|
||||||
import AccessAlarmIcon from '@mui/icons-material/AccessAlarm';
|
import AccessAlarmIcon from '@mui/icons-material/AccessAlarm';
|
||||||
import StopIcon from '@mui/icons-material/Stop';
|
import CachedIcon from '@mui/icons-material/Cached';
|
||||||
import InfoOutlineIcon from '@mui/icons-material/InfoOutline';
|
import InfoOutlineIcon from '@mui/icons-material/InfoOutline';
|
||||||
import KeyboardIcon from '@mui/icons-material/Keyboard';
|
import KeyboardIcon from '@mui/icons-material/Keyboard';
|
||||||
|
import PauseIcon from '@mui/icons-material/Pause';
|
||||||
import { formatTime } from "./util";
|
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||||
import { InsertMode } from "../VisualEditor/VisualEditor";
|
import SkipNextIcon from '@mui/icons-material/SkipNext';
|
||||||
import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo";
|
import StopIcon from '@mui/icons-material/Stop';
|
||||||
import { About } from "./About";
|
import { InsertModes } from "./TopPanel/InsertModes";
|
||||||
import { RountangleIcon, PseudoStateIcon, HistoryIcon } from "./Icons";
|
|
||||||
import { EditHistory, TraceState } from "./App";
|
|
||||||
import { ZoomButtons } from "./TopPanel/ZoomButtons";
|
|
||||||
import { UndoRedoButtons } from "./TopPanel/UndoRedoButtons";
|
|
||||||
|
|
||||||
export type TopPanelProps = {
|
export type TopPanelProps = {
|
||||||
trace: TraceState | null,
|
trace: TraceState | null,
|
||||||
|
|
@ -31,9 +27,7 @@ export type TopPanelProps = {
|
||||||
onRedo: () => void,
|
onRedo: () => void,
|
||||||
onInit: () => void,
|
onInit: () => void,
|
||||||
onClear: () => void,
|
onClear: () => void,
|
||||||
// onRaise: (e: string, p: any) => void,
|
|
||||||
onBack: () => void,
|
onBack: () => void,
|
||||||
// ast: Statechart,
|
|
||||||
insertMode: InsertMode,
|
insertMode: InsertMode,
|
||||||
setInsertMode: Dispatch<SetStateAction<InsertMode>>,
|
setInsertMode: Dispatch<SetStateAction<InsertMode>>,
|
||||||
setModal: Dispatch<SetStateAction<ReactElement|null>>,
|
setModal: Dispatch<SetStateAction<ReactElement|null>>,
|
||||||
|
|
@ -41,22 +35,12 @@ export type TopPanelProps = {
|
||||||
setZoom: Dispatch<SetStateAction<number>>,
|
setZoom: Dispatch<SetStateAction<number>>,
|
||||||
showKeys: boolean,
|
showKeys: boolean,
|
||||||
setShowKeys: Dispatch<SetStateAction<boolean>>,
|
setShowKeys: Dispatch<SetStateAction<boolean>>,
|
||||||
history: EditHistory,
|
editHistory: EditHistory,
|
||||||
}
|
}
|
||||||
|
|
||||||
const ShortCutShowKeys = <kbd>~</kbd>;
|
const ShortCutShowKeys = <kbd>~</kbd>;
|
||||||
|
|
||||||
const insertModes: [InsertMode, string, ReactElement, ReactElement][] = [
|
export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, onRedo, onInit, onClear, onBack, insertMode, setInsertMode, setModal, zoom, setZoom, showKeys, setShowKeys, editHistory}: TopPanelProps) {
|
||||||
["and", "AND-states", <RountangleIcon kind="and"/>, <kbd>A</kbd>],
|
|
||||||
["or", "OR-states", <RountangleIcon kind="or"/>, <kbd>O</kbd>],
|
|
||||||
["pseudo", "pseudo-states", <PseudoStateIcon/>, <kbd>P</kbd>],
|
|
||||||
["shallow", "shallow history", <HistoryIcon kind="shallow"/>, <kbd>H</kbd>],
|
|
||||||
["deep", "deep history", <HistoryIcon kind="deep"/>, <></>],
|
|
||||||
["transition", "transitions", <TrendingFlatIcon fontSize="small"/>, <kbd>T</kbd>],
|
|
||||||
["text", "text", <> T </>, <kbd>X</kbd>],
|
|
||||||
];
|
|
||||||
|
|
||||||
export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, onRedo, onInit, onClear, onBack, insertMode, setInsertMode, setModal, zoom, setZoom, showKeys, setShowKeys, history}: TopPanelProps) {
|
|
||||||
const [displayTime, setDisplayTime] = useState("0.000");
|
const [displayTime, setDisplayTime] = useState("0.000");
|
||||||
const [timescale, setTimescale] = useState(1);
|
const [timescale, setTimescale] = useState(1);
|
||||||
|
|
||||||
|
|
@ -64,6 +48,77 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on
|
||||||
|
|
||||||
const KeyInfo = showKeys ? KeyInfoVisible : KeyInfoHidden;
|
const KeyInfo = showKeys ? KeyInfoVisible : KeyInfoHidden;
|
||||||
|
|
||||||
|
const updateDisplayedTime = useCallback(() => {
|
||||||
|
const now = Math.round(performance.now());
|
||||||
|
const timeMs = getSimTime(time, now);
|
||||||
|
setDisplayTime(formatTime(timeMs));
|
||||||
|
}, [time, setDisplayTime]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// This has no effect on statechart execution. In between events, the statechart is doing nothing. However, by updating the displayed time, we give the illusion of continuous progress.
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
updateDisplayedTime();
|
||||||
|
}, 43); // every X ms -> we want a value that makes the numbers 'dance' while not using too much CPU
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, [time, updateDisplayedTime]);
|
||||||
|
|
||||||
|
const onChangePaused = useCallback((paused: boolean, wallclktime: number) => {
|
||||||
|
setTime(time => {
|
||||||
|
if (paused) {
|
||||||
|
return setPaused(time, wallclktime);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return setRealtime(time, timescale, wallclktime);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
updateDisplayedTime();
|
||||||
|
}, [setTime, updateDisplayedTime]);
|
||||||
|
|
||||||
|
const onTimeScaleChange = useCallback((newValue: string, wallclktime: number) => {
|
||||||
|
const asFloat = parseFloat(newValue);
|
||||||
|
if (Number.isNaN(asFloat)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const maxed = Math.min(asFloat, 64);
|
||||||
|
const mined = Math.max(maxed, 1/64);
|
||||||
|
setTimescale(mined);
|
||||||
|
setTime(time => {
|
||||||
|
if (time.kind === "paused") {
|
||||||
|
return time;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return setRealtime(time, mined, wallclktime);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [setTime, setTimescale]);
|
||||||
|
|
||||||
|
// timestamp of next timed transition, in simulated time
|
||||||
|
const timers: Timers = config?.kind === "bigstep" && config.environment.get("_timers") || [];
|
||||||
|
const nextTimedTransition: [number, TimerElapseEvent] | undefined = timers[0];
|
||||||
|
|
||||||
|
const onSkip = useCallback(() => {
|
||||||
|
const now = Math.round(performance.now());
|
||||||
|
if (nextTimedTransition) {
|
||||||
|
setTime(time => {
|
||||||
|
if (time.kind === "paused") {
|
||||||
|
return {kind: "paused", simtime: nextTimedTransition[0]};
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return {kind: "realtime", scale: time.scale, since: {simtime: nextTimedTransition[0], wallclktime: now}};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [nextTimedTransition, setTime]);
|
||||||
|
|
||||||
|
const onSlower = useCallback(() => {
|
||||||
|
onTimeScaleChange((timescale/2).toString(), Math.round(performance.now()));
|
||||||
|
}, [onTimeScaleChange]);
|
||||||
|
const onFaster = useCallback(() => {
|
||||||
|
onTimeScaleChange((timescale*2).toString(), Math.round(performance.now()));
|
||||||
|
}, [onTimeScaleChange, timescale]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
if (!e.ctrlKey) {
|
if (!e.ctrlKey) {
|
||||||
|
|
@ -123,86 +178,13 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("keydown", onKeyDown);
|
window.removeEventListener("keydown", onKeyDown);
|
||||||
};
|
};
|
||||||
}, [time, onInit, timescale]);
|
}, [trace, config, time, onInit, timescale, onChangePaused, setShowKeys, onUndo, onRedo, onSlower, onFaster, onSkip, onBack, onClear]);
|
||||||
|
|
||||||
function updateDisplayedTime() {
|
|
||||||
const now = Math.round(performance.now());
|
|
||||||
const timeMs = getSimTime(time, now);
|
|
||||||
setDisplayTime(formatTime(timeMs));
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// This has no effect on statechart execution. In between events, the statechart is doing nothing. However, by updating the displayed time, we give the illusion of continuous progress.
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
updateDisplayedTime();
|
|
||||||
}, 43); // every X ms -> we want a value that makes the numbers 'dance' while not using too much CPU
|
|
||||||
return () => {
|
|
||||||
clearInterval(interval);
|
|
||||||
}
|
|
||||||
}, [time]);
|
|
||||||
|
|
||||||
|
|
||||||
function onChangePaused(paused: boolean, wallclktime: number) {
|
|
||||||
setTime(time => {
|
|
||||||
if (paused) {
|
|
||||||
return setPaused(time, wallclktime);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
return setRealtime(time, timescale, wallclktime);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
updateDisplayedTime();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onTimeScaleChange(newValue: string, wallclktime: number) {
|
|
||||||
const asFloat = parseFloat(newValue);
|
|
||||||
if (Number.isNaN(asFloat)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const maxed = Math.min(asFloat, 64);
|
|
||||||
const mined = Math.max(maxed, 1/64);
|
|
||||||
setTimescale(mined);
|
|
||||||
setTime(time => {
|
|
||||||
if (time.kind === "paused") {
|
|
||||||
return time;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
return setRealtime(time, mined, wallclktime);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// timestamp of next timed transition, in simulated time
|
|
||||||
const timers: Timers = config?.kind === "bigstep" && config.environment.get("_timers") || [];
|
|
||||||
const nextTimedTransition: [number, TimerElapseEvent] | undefined = timers[0];
|
|
||||||
|
|
||||||
function onSkip() {
|
|
||||||
const now = Math.round(performance.now());
|
|
||||||
if (nextTimedTransition) {
|
|
||||||
setTime(time => {
|
|
||||||
if (time.kind === "paused") {
|
|
||||||
return {kind: "paused", simtime: nextTimedTransition[0]};
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
return {kind: "realtime", scale: time.scale, since: {simtime: nextTimedTransition[0], wallclktime: now}};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onSlower() {
|
|
||||||
onTimeScaleChange((timescale/2).toString(), Math.round(performance.now()));
|
|
||||||
}
|
|
||||||
function onFaster() {
|
|
||||||
onTimeScaleChange((timescale*2).toString(), Math.round(performance.now()));
|
|
||||||
}
|
|
||||||
|
|
||||||
return <div className="toolbar">
|
return <div className="toolbar">
|
||||||
|
|
||||||
{/* shortcuts / about */}
|
{/* shortcuts / about */}
|
||||||
<div className="toolbarGroup">
|
<div className="toolbarGroup">
|
||||||
<KeyInfo keyInfo={ShortCutShowKeys}>
|
<KeyInfo keyInfo={ShortCutShowKeys}>
|
||||||
<button title="show/hide keyboard shortcuts" className={showKeys?"active":""} onClick={() => setShowKeys(s => !s)}><KeyboardIcon fontSize="small"/></button>
|
<button title="show/hide keyboard shortcuts" className={showKeys?"active":""} onClick={useCallback(() => setShowKeys(s => !s), [setShowKeys])}><KeyboardIcon fontSize="small"/></button>
|
||||||
</KeyInfo>
|
</KeyInfo>
|
||||||
<button title="about StateBuddy" onClick={() => setModal(<About setModal={setModal}/>)}><InfoOutlineIcon fontSize="small"/></button>
|
<button title="about StateBuddy" onClick={() => setModal(<About setModal={setModal}/>)}><InfoOutlineIcon fontSize="small"/></button>
|
||||||
 
|
 
|
||||||
|
|
@ -216,28 +198,21 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on
|
||||||
|
|
||||||
{/* undo / redo */}
|
{/* undo / redo */}
|
||||||
<div className="toolbarGroup">
|
<div className="toolbarGroup">
|
||||||
<UndoRedoButtons showKeys={showKeys} onUndo={onUndo} onRedo={onRedo} historyLength={history.history.length} futureLength={history.future.length}/>
|
<UndoRedoButtons showKeys={showKeys} onUndo={onUndo} onRedo={onRedo} historyLength={editHistory.history.length} futureLength={editHistory.future.length}/>
|
||||||
 
|
 
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* insert rountangle / arrow / ... */}
|
{/* insert rountangle / arrow / ... */}
|
||||||
<div className="toolbarGroup">
|
<div className="toolbarGroup">
|
||||||
{insertModes.map(([m, hint, buttonTxt, keyInfo]) =>
|
<InsertModes insertMode={insertMode} setInsertMode={setInsertMode} showKeys={showKeys}/>
|
||||||
<KeyInfo key={m} keyInfo={keyInfo}>
|
|
||||||
<button
|
|
||||||
title={"insert "+hint}
|
|
||||||
disabled={insertMode===m}
|
|
||||||
className={insertMode===m ? "active":""}
|
|
||||||
onClick={() => setInsertMode(m)}
|
|
||||||
>{buttonTxt}</button></KeyInfo>)}
|
|
||||||
 
|
 
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* execution */}
|
{/* execution */}
|
||||||
<div className="toolbarGroup">
|
<div className="toolbarGroup">
|
||||||
|
|
||||||
{/* init / clear / pause / real time */}
|
|
||||||
<div className="toolbarGroup">
|
<div className="toolbarGroup">
|
||||||
|
{/* init / clear */}
|
||||||
<KeyInfo keyInfo={<kbd>I</kbd>}>
|
<KeyInfo keyInfo={<kbd>I</kbd>}>
|
||||||
<button title="(re)initialize simulation" onClick={onInit} ><PlayArrowIcon fontSize="small"/><CachedIcon fontSize="small"/></button>
|
<button title="(re)initialize simulation" onClick={onInit} ><PlayArrowIcon fontSize="small"/><CachedIcon fontSize="small"/></button>
|
||||||
</KeyInfo>
|
</KeyInfo>
|
||||||
|
|
@ -245,6 +220,7 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on
|
||||||
<button title="clear the simulation" onClick={onClear} disabled={!config}><StopIcon fontSize="small"/></button>
|
<button title="clear the simulation" onClick={onClear} disabled={!config}><StopIcon fontSize="small"/></button>
|
||||||
</KeyInfo>
|
</KeyInfo>
|
||||||
 
|
 
|
||||||
|
{/* pause / real time */}
|
||||||
<KeyInfo keyInfo={<><kbd>Space</kbd> toggles</>}>
|
<KeyInfo keyInfo={<><kbd>Space</kbd> toggles</>}>
|
||||||
<button title="pause the simulation" disabled={!config || time.kind==="paused"} className={(config && time.kind==="paused") ? "active":""} onClick={() => onChangePaused(true, Math.round(performance.now()))}><PauseIcon fontSize="small"/></button>
|
<button title="pause the simulation" disabled={!config || time.kind==="paused"} className={(config && time.kind==="paused") ? "active":""} onClick={() => onChangePaused(true, Math.round(performance.now()))}><PauseIcon fontSize="small"/></button>
|
||||||
<button title="run the simulation in real time" disabled={!config || time.kind==="realtime"} className={(config && time.kind==="realtime") ? "active":""} onClick={() => onChangePaused(false, Math.round(performance.now()))}><PlayArrowIcon fontSize="small"/></button>
|
<button title="run the simulation in real time" disabled={!config || time.kind==="realtime"} className={(config && time.kind==="realtime") ? "active":""} onClick={() => onChangePaused(false, Math.round(performance.now()))}><PlayArrowIcon fontSize="small"/></button>
|
||||||
|
|
@ -282,44 +258,5 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* input events */}
|
|
||||||
{/* <div className="toolbarGroup">
|
|
||||||
{ast.inputEvents &&
|
|
||||||
<>
|
|
||||||
{ast.inputEvents.map(({event, paramName}) =>
|
|
||||||
<div key={event+'/'+paramName} className="toolbarGroup">
|
|
||||||
<button
|
|
||||||
className="inputEvent"
|
|
||||||
title={`raise this input event`}
|
|
||||||
disabled={!rt}
|
|
||||||
onClick={() => {
|
|
||||||
// @ts-ignore
|
|
||||||
const param = document.getElementById(`input-${event}-param`)?.value;
|
|
||||||
let paramParsed;
|
|
||||||
try {
|
|
||||||
if (param) {
|
|
||||||
paramParsed = JSON.parse(param); // may throw
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (e) {
|
|
||||||
alert("invalid json");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onRaise(event, paramParsed);
|
|
||||||
}}>
|
|
||||||
<BoltIcon fontSize="small"/>
|
|
||||||
{event}
|
|
||||||
</button>
|
|
||||||
{paramName &&
|
|
||||||
<><input id={`input-${event}-param`} style={{width: 20}} placeholder={paramName}/></>
|
|
||||||
}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
</div> */}
|
|
||||||
|
|
||||||
</div>;
|
</div>;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
28
src/App/TopPanel/InsertModes.tsx
Normal file
28
src/App/TopPanel/InsertModes.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { Dispatch, memo, ReactElement, SetStateAction } from "react";
|
||||||
|
import { KeyInfoHidden, KeyInfoVisible } from "../KeyInfo";
|
||||||
|
import { InsertMode } from "@/VisualEditor/VisualEditor";
|
||||||
|
import { HistoryIcon, PseudoStateIcon, RountangleIcon } from "../Icons";
|
||||||
|
|
||||||
|
import TrendingFlatIcon from '@mui/icons-material/TrendingFlat';
|
||||||
|
|
||||||
|
const insertModes: [InsertMode, string, ReactElement, ReactElement][] = [
|
||||||
|
["and", "AND-states", <RountangleIcon kind="and"/>, <kbd>A</kbd>],
|
||||||
|
["or", "OR-states", <RountangleIcon kind="or"/>, <kbd>O</kbd>],
|
||||||
|
["pseudo", "pseudo-states", <PseudoStateIcon/>, <kbd>P</kbd>],
|
||||||
|
["shallow", "shallow history", <HistoryIcon kind="shallow"/>, <kbd>H</kbd>],
|
||||||
|
["deep", "deep history", <HistoryIcon kind="deep"/>, <></>],
|
||||||
|
["transition", "transitions", <TrendingFlatIcon fontSize="small"/>, <kbd>T</kbd>],
|
||||||
|
["text", "text", <> T </>, <kbd>X</kbd>],
|
||||||
|
];
|
||||||
|
|
||||||
|
export const InsertModes = memo(function InsertModes({showKeys, insertMode, setInsertMode}: {showKeys: boolean, insertMode: InsertMode, setInsertMode: Dispatch<SetStateAction<InsertMode>>}) {
|
||||||
|
const KeyInfo = showKeys ? KeyInfoVisible : KeyInfoHidden;
|
||||||
|
return <>{insertModes.map(([m, hint, buttonTxt, keyInfo]) => <KeyInfo key={m} keyInfo={keyInfo}>
|
||||||
|
<button
|
||||||
|
title={"insert "+hint}
|
||||||
|
disabled={insertMode===m}
|
||||||
|
className={insertMode===m ? "active":""}
|
||||||
|
onClick={() => setInsertMode(m)}
|
||||||
|
>{buttonTxt}</button>
|
||||||
|
</KeyInfo>)}</>;
|
||||||
|
})
|
||||||
|
|
@ -81,45 +81,15 @@ export const VisualEditor = memo(function VisualEditor({state, setState, trace,
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
|
|
||||||
// uid's of selected rountangles
|
// uid's of selected rountangles
|
||||||
// const [selection, setSelection] = useState<Selection>([]);
|
|
||||||
const selection = state.selection || [];
|
const selection = state.selection || [];
|
||||||
const setSelection = (cb: (oldSelection: Selection) => Selection) =>
|
const setSelection = useCallback((cb: (oldSelection: Selection) => Selection) =>
|
||||||
setState(oldState => ({...oldState, selection: cb(oldState.selection)}));
|
setState(oldState => ({...oldState, selection: cb(oldState.selection)})),[setState]);
|
||||||
|
|
||||||
// not null while the user is making a selection
|
// not null while the user is making a selection
|
||||||
const [selectingState, setSelectingState] = useState<SelectingState>(null);
|
const [selectingState, setSelectingState] = useState<SelectingState>(null);
|
||||||
|
|
||||||
const refSVG = useRef<SVGSVGElement>(null);
|
const refSVG = useRef<SVGSVGElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
try {
|
|
||||||
const compressedState = window.location.hash.slice(1);
|
|
||||||
const ds = new DecompressionStream("deflate");
|
|
||||||
const writer = ds.writable.getWriter();
|
|
||||||
writer.write(Uint8Array.fromBase64(compressedState)).catch(e => {
|
|
||||||
console.error("could not recover state:", e);
|
|
||||||
});
|
|
||||||
writer.close().catch(e => {
|
|
||||||
console.error("could not recover state:", e);
|
|
||||||
});
|
|
||||||
|
|
||||||
new Response(ds.readable).arrayBuffer().then(decompressedBuffer => {
|
|
||||||
try {
|
|
||||||
const recoveredState = JSON.parse(new TextDecoder().decode(decompressedBuffer));
|
|
||||||
setState(() => recoveredState);
|
|
||||||
}
|
|
||||||
catch (e) {
|
|
||||||
console.error("could not recover state:", e);
|
|
||||||
}
|
|
||||||
}).catch(e => {
|
|
||||||
console.error("could not recover state:", e);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (e) {
|
|
||||||
console.error("could not recover state:", e);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// bit of a hacky way to force the animation on fired transitions to replay, if the new 'rt' contains the same fired transitions as the previous one
|
// bit of a hacky way to force the animation on fired transitions to replay, if the new 'rt' contains the same fired transitions as the previous one
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
|
|
@ -134,25 +104,6 @@ export const VisualEditor = memo(function VisualEditor({state, setState, trace,
|
||||||
})
|
})
|
||||||
}, [trace && trace.idx]);
|
}, [trace && trace.idx]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
const serializedState = JSON.stringify(state);
|
|
||||||
const stateBuffer = new TextEncoder().encode(serializedState);
|
|
||||||
|
|
||||||
const cs = new CompressionStream("deflate");
|
|
||||||
const writer = cs.writable.getWriter();
|
|
||||||
writer.write(stateBuffer);
|
|
||||||
writer.close();
|
|
||||||
|
|
||||||
// todo: cancel this promise handler when concurrently starting another compression job
|
|
||||||
new Response(cs.readable).arrayBuffer().then(compressedStateBuffer => {
|
|
||||||
const compressedStateString = new Uint8Array(compressedStateBuffer).toBase64();
|
|
||||||
window.location.hash = "#"+compressedStateString;
|
|
||||||
});
|
|
||||||
}, 100);
|
|
||||||
return () => clearTimeout(timeout);
|
|
||||||
}, [state]);
|
|
||||||
|
|
||||||
const getCurrentPointer = useCallback((e: {pageX: number, pageY: number}) => {
|
const getCurrentPointer = useCallback((e: {pageX: number, pageY: number}) => {
|
||||||
const bbox = refSVG.current!.getBoundingClientRect();
|
const bbox = refSVG.current!.getBoundingClientRect();
|
||||||
return {
|
return {
|
||||||
|
|
@ -417,7 +368,7 @@ export const VisualEditor = memo(function VisualEditor({state, setState, trace,
|
||||||
setSelectingState(null); // no longer making a selection
|
setSelectingState(null); // no longer making a selection
|
||||||
}, [dragging, selectingState, refSVG.current]);
|
}, [dragging, selectingState, refSVG.current]);
|
||||||
|
|
||||||
function deleteSelection() {
|
const deleteSelection = useCallback(() => {
|
||||||
setState(state => ({
|
setState(state => ({
|
||||||
...state,
|
...state,
|
||||||
rountangles: state.rountangles.filter(r => !state.selection.some(rs => rs.uid === r.uid)),
|
rountangles: state.rountangles.filter(r => !state.selection.some(rs => rs.uid === r.uid)),
|
||||||
|
|
@ -427,9 +378,9 @@ export const VisualEditor = memo(function VisualEditor({state, setState, trace,
|
||||||
texts: state.texts.filter(t => !state.selection.some(ts => ts.uid === t.uid)),
|
texts: state.texts.filter(t => !state.selection.some(ts => ts.uid === t.uid)),
|
||||||
selection: [],
|
selection: [],
|
||||||
}));
|
}));
|
||||||
}
|
}, [setState]);
|
||||||
|
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = useCallback((e: KeyboardEvent) => {
|
||||||
if (e.key === "Delete") {
|
if (e.key === "Delete") {
|
||||||
// delete selection
|
// delete selection
|
||||||
makeCheckPoint();
|
makeCheckPoint();
|
||||||
|
|
@ -475,7 +426,7 @@ export const VisualEditor = memo(function VisualEditor({state, setState, trace,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}, [makeCheckPoint, deleteSelection, setState, setDragging]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// mousemove and mouseup are global event handlers so they keep working when pointer is outside of browser window
|
// mousemove and mouseup are global event handlers so they keep working when pointer is outside of browser window
|
||||||
|
|
@ -535,7 +486,7 @@ export const VisualEditor = memo(function VisualEditor({state, setState, trace,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPaste(e: ClipboardEvent) {
|
const onPaste = useCallback((e: ClipboardEvent) => {
|
||||||
const data = e.clipboardData?.getData("text/plain");
|
const data = e.clipboardData?.getData("text/plain");
|
||||||
if (data) {
|
if (data) {
|
||||||
let parsed;
|
let parsed;
|
||||||
|
|
@ -547,8 +498,8 @@ export const VisualEditor = memo(function VisualEditor({state, setState, trace,
|
||||||
}
|
}
|
||||||
// const offset = {x: 40, y: 40};
|
// const offset = {x: 40, y: 40};
|
||||||
const offset = {x: 0, y: 0};
|
const offset = {x: 0, y: 0};
|
||||||
let nextID = state.nextID;
|
setState(state => {
|
||||||
try {
|
let nextID = state.nextID;
|
||||||
const copiedRountangles: Rountangle[] = parsed.rountangles.map((r: Rountangle) => ({
|
const copiedRountangles: Rountangle[] = parsed.rountangles.map((r: Rountangle) => ({
|
||||||
...r,
|
...r,
|
||||||
uid: (nextID++).toString(),
|
uid: (nextID++).toString(),
|
||||||
|
|
@ -583,7 +534,7 @@ export const VisualEditor = memo(function VisualEditor({state, setState, trace,
|
||||||
...copiedTexts.map(t => ({uid: t.uid, parts: ["text"]})),
|
...copiedTexts.map(t => ({uid: t.uid, parts: ["text"]})),
|
||||||
...copiedHistories.map(h => ({uid: h.uid, parts: ["history"]})),
|
...copiedHistories.map(h => ({uid: h.uid, parts: ["history"]})),
|
||||||
];
|
];
|
||||||
setState(state => ({
|
return {
|
||||||
...state,
|
...state,
|
||||||
rountangles: [...state.rountangles, ...copiedRountangles],
|
rountangles: [...state.rountangles, ...copiedRountangles],
|
||||||
diamonds: [...state.diamonds, ...copiedDiamonds],
|
diamonds: [...state.diamonds, ...copiedDiamonds],
|
||||||
|
|
@ -592,16 +543,14 @@ export const VisualEditor = memo(function VisualEditor({state, setState, trace,
|
||||||
history: [...state.history, ...copiedHistories],
|
history: [...state.history, ...copiedHistories],
|
||||||
nextID: nextID,
|
nextID: nextID,
|
||||||
selection: newSelection,
|
selection: newSelection,
|
||||||
}));
|
};
|
||||||
// copyInternal(newSelection, e); // doesn't work
|
});
|
||||||
e.preventDefault();
|
// copyInternal(newSelection, e); // doesn't work
|
||||||
}
|
e.preventDefault();
|
||||||
catch (e) {
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}, [setState]);
|
||||||
|
|
||||||
function copyInternal(selection: Selection, e: ClipboardEvent) {
|
const copyInternal = useCallback((state: VisualEditorState, selection: Selection, e: ClipboardEvent) => {
|
||||||
const uidsToCopy = new Set(selection.map(shape => shape.uid));
|
const uidsToCopy = new Set(selection.map(shape => shape.uid));
|
||||||
const rountanglesToCopy = state.rountangles.filter(r => uidsToCopy.has(r.uid));
|
const rountanglesToCopy = state.rountangles.filter(r => uidsToCopy.has(r.uid));
|
||||||
const diamondsToCopy = state.diamonds.filter(d => uidsToCopy.has(d.uid));
|
const diamondsToCopy = state.diamonds.filter(d => uidsToCopy.has(d.uid));
|
||||||
|
|
@ -615,22 +564,22 @@ export const VisualEditor = memo(function VisualEditor({state, setState, trace,
|
||||||
arrows: arrowsToCopy,
|
arrows: arrowsToCopy,
|
||||||
texts: textsToCopy,
|
texts: textsToCopy,
|
||||||
}));
|
}));
|
||||||
}
|
}, []);
|
||||||
|
|
||||||
function onCopy(e: ClipboardEvent) {
|
const onCopy = useCallback((e: ClipboardEvent) => {
|
||||||
if (selection.length > 0) {
|
if (selection.length > 0) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
copyInternal(selection, e);
|
copyInternal(state, selection, e);
|
||||||
}
|
}
|
||||||
}
|
}, [state, selection]);
|
||||||
|
|
||||||
function onCut(e: ClipboardEvent) {
|
const onCut = useCallback((e: ClipboardEvent) => {
|
||||||
if (selection.length > 0) {
|
if (selection.length > 0) {
|
||||||
copyInternal(selection, e);
|
copyInternal(state, selection, e);
|
||||||
deleteSelection();
|
deleteSelection();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
}
|
}, [state, selection]);
|
||||||
|
|
||||||
const onEditText = useCallback((text: Text, newText: string) => {
|
const onEditText = useCallback((text: Text, newText: string) => {
|
||||||
if (newText === "") {
|
if (newText === "") {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue