diff --git a/src/App/App.tsx b/src/App/App.tsx index 34a25bb..e9d23f6 100644 --- a/src/App/App.tsx +++ b/src/App/App.tsx @@ -71,7 +71,7 @@ function getPlantState(plant: Plant, trace: TraceItem[], idx: number): T | export function App() { const [insertMode, setInsertMode] = useState("and"); - const [historyState, setHistoryState] = useState({current: emptyState, history: [], future: []}); + const [editHistory, setEditHistory] = useState({current: emptyState, history: [], future: []}); const [trace, setTrace] = useState(null); const [time, setTime] = useState({kind: "paused", simtime: 0}); const [modal, setModal] = useState(null); @@ -82,10 +82,57 @@ export function App() { const plant = plants.find(([pn, p]) => pn === plantName)![1]; - const editorState = historyState.current; + const editorState = editHistory.current; const setEditorState = useCallback((cb: (value: VisualEditorState) => VisualEditorState) => { - setHistoryState(historyState => ({...historyState, current: cb(historyState.current)})); - }, [setHistoryState]); + setEditHistory(historyState => ({...historyState, current: cb(historyState.current)})); + }, [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(null); @@ -93,16 +140,18 @@ export function App() { const conns = useMemo(() => detectConnections(editorState), [editorState]); const [ast, syntaxErrors] = useMemo(() => parseStatechart(editorState, conns), [editorState, conns]); + console.log('render App', ast); + // append editor state to undo history const makeCheckPoint = useCallback(() => { - setHistoryState(historyState => ({ + setEditHistory(historyState => ({ ...historyState, history: [...historyState.history, historyState.current], future: [], })); - }, [setHistoryState]); + }, [setEditHistory]); const onUndo = useCallback(() => { - setHistoryState(historyState => { + setEditHistory(historyState => { if (historyState.history.length === 0) { return historyState; // no change } @@ -112,9 +161,9 @@ export function App() { future: [...historyState.future, historyState.current], } }) - }, [setHistoryState]); + }, [setEditHistory]); const onRedo = useCallback(() => { - setHistoryState(historyState => { + setEditHistory(historyState => { if (historyState.future.length === 0) { return historyState; // no change } @@ -124,9 +173,19 @@ export function App() { future: historyState.future.slice(0,-1), } }); - }, [setHistoryState]); - - function onInit() { + }, [setEditHistory]); + + 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: ""}; let config; try { @@ -145,7 +204,8 @@ export function App() { } setTime({kind: "paused", simtime: 0}); scrollDownSidebar(); - } + }, [ast, scrollDownSidebar, setTime, setTrace]); + const onClear = useCallback(() => { setTrace(null); 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(() => { console.log("Welcome to StateBuddy!"); () => { @@ -324,7 +374,7 @@ export function App() { }} > {/* Below the top bar: Editor */} diff --git a/src/App/TopPanel.tsx b/src/App/TopPanel.tsx index 415e9f6..c3bcdf6 100644 --- a/src/App/TopPanel.tsx +++ b/src/App/TopPanel.tsx @@ -1,27 +1,23 @@ -import { Dispatch, memo, ReactElement, SetStateAction, useEffect, useState } from "react"; -import { BigStep, TimerElapseEvent, Timers } from "../statecharts/runtime_types"; +import { Dispatch, memo, ReactElement, SetStateAction, useCallback, useEffect, useState } from "react"; +import { TimerElapseEvent, Timers } from "../statecharts/runtime_types"; 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 StopIcon from '@mui/icons-material/Stop'; +import CachedIcon from '@mui/icons-material/Cached'; import InfoOutlineIcon from '@mui/icons-material/InfoOutline'; import KeyboardIcon from '@mui/icons-material/Keyboard'; - -import { formatTime } from "./util"; -import { InsertMode } from "../VisualEditor/VisualEditor"; -import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo"; -import { About } from "./About"; -import { RountangleIcon, PseudoStateIcon, HistoryIcon } from "./Icons"; -import { EditHistory, TraceState } from "./App"; -import { ZoomButtons } from "./TopPanel/ZoomButtons"; -import { UndoRedoButtons } from "./TopPanel/UndoRedoButtons"; +import PauseIcon from '@mui/icons-material/Pause'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import SkipNextIcon from '@mui/icons-material/SkipNext'; +import StopIcon from '@mui/icons-material/Stop'; +import { InsertModes } from "./TopPanel/InsertModes"; export type TopPanelProps = { trace: TraceState | null, @@ -31,9 +27,7 @@ export type TopPanelProps = { onRedo: () => void, onInit: () => void, onClear: () => void, - // onRaise: (e: string, p: any) => void, onBack: () => void, - // ast: Statechart, insertMode: InsertMode, setInsertMode: Dispatch>, setModal: Dispatch>, @@ -41,22 +35,12 @@ export type TopPanelProps = { setZoom: Dispatch>, showKeys: boolean, setShowKeys: Dispatch>, - history: EditHistory, + editHistory: EditHistory, } const ShortCutShowKeys = ~; -const insertModes: [InsertMode, string, ReactElement, ReactElement][] = [ - ["and", "AND-states", , A], - ["or", "OR-states", , O], - ["pseudo", "pseudo-states", , P], - ["shallow", "shallow history", , H], - ["deep", "deep history", , <>], - ["transition", "transitions", , T], - ["text", "text", <> T , X], -]; - -export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, onRedo, onInit, onClear, onBack, insertMode, setInsertMode, setModal, zoom, setZoom, showKeys, setShowKeys, history}: TopPanelProps) { +export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, onRedo, onInit, onClear, onBack, insertMode, setInsertMode, setModal, zoom, setZoom, showKeys, setShowKeys, editHistory}: TopPanelProps) { const [displayTime, setDisplayTime] = useState("0.000"); 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 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(() => { const onKeyDown = (e: KeyboardEvent) => { if (!e.ctrlKey) { @@ -123,86 +178,13 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on return () => { window.removeEventListener("keydown", onKeyDown); }; - }, [time, onInit, timescale]); - - 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())); - } + }, [trace, config, time, onInit, timescale, onChangePaused, setShowKeys, onUndo, onRedo, onSlower, onFaster, onSkip, onBack, onClear]); return
- {/* shortcuts / about */}
- +   @@ -216,28 +198,21 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on {/* undo / redo */}
- +
{/* insert rountangle / arrow / ... */}
- {insertModes.map(([m, hint, buttonTxt, keyInfo]) => - - )} +
{/* execution */}
- {/* init / clear / pause / real time */}
+ {/* init / clear */} I}> @@ -245,6 +220,7 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on   + {/* pause / real time */} Space toggles}> @@ -282,44 +258,5 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on
- - {/* input events */} - {/*
- {ast.inputEvents && - <> - {ast.inputEvents.map(({event, paramName}) => -
- - {paramName && - <> - } -   -
- )} - - } -
*/} -
; }); diff --git a/src/App/TopPanel/InsertModes.tsx b/src/App/TopPanel/InsertModes.tsx new file mode 100644 index 0000000..61d0953 --- /dev/null +++ b/src/App/TopPanel/InsertModes.tsx @@ -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", , A], + ["or", "OR-states", , O], + ["pseudo", "pseudo-states", , P], + ["shallow", "shallow history", , H], + ["deep", "deep history", , <>], + ["transition", "transitions", , T], + ["text", "text", <> T , X], +]; + +export const InsertModes = memo(function InsertModes({showKeys, insertMode, setInsertMode}: {showKeys: boolean, insertMode: InsertMode, setInsertMode: Dispatch>}) { + const KeyInfo = showKeys ? KeyInfoVisible : KeyInfoHidden; + return <>{insertModes.map(([m, hint, buttonTxt, keyInfo]) => + + )}; +}) \ No newline at end of file diff --git a/src/VisualEditor/VisualEditor.tsx b/src/VisualEditor/VisualEditor.tsx index f70ae2e..654a0f8 100644 --- a/src/VisualEditor/VisualEditor.tsx +++ b/src/VisualEditor/VisualEditor.tsx @@ -81,45 +81,15 @@ export const VisualEditor = memo(function VisualEditor({state, setState, trace, const [dragging, setDragging] = useState(false); // uid's of selected rountangles - // const [selection, setSelection] = useState([]); const selection = state.selection || []; - const setSelection = (cb: (oldSelection: Selection) => Selection) => - setState(oldState => ({...oldState, selection: cb(oldState.selection)})); + const setSelection = useCallback((cb: (oldSelection: Selection) => Selection) => + setState(oldState => ({...oldState, selection: cb(oldState.selection)})),[setState]); // not null while the user is making a selection const [selectingState, setSelectingState] = useState(null); const refSVG = useRef(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(() => { // 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(() => { @@ -134,25 +104,6 @@ export const VisualEditor = memo(function VisualEditor({state, setState, trace, }) }, [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 bbox = refSVG.current!.getBoundingClientRect(); return { @@ -417,7 +368,7 @@ export const VisualEditor = memo(function VisualEditor({state, setState, trace, setSelectingState(null); // no longer making a selection }, [dragging, selectingState, refSVG.current]); - function deleteSelection() { + const deleteSelection = useCallback(() => { setState(state => ({ ...state, 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)), selection: [], })); - } + }, [setState]); - const onKeyDown = (e: KeyboardEvent) => { + const onKeyDown = useCallback((e: KeyboardEvent) => { if (e.key === "Delete") { // delete selection makeCheckPoint(); @@ -475,7 +426,7 @@ export const VisualEditor = memo(function VisualEditor({state, setState, trace, })) } } - }; + }, [makeCheckPoint, deleteSelection, setState, setDragging]); useEffect(() => { // 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"); if (data) { let parsed; @@ -547,8 +498,8 @@ export const VisualEditor = memo(function VisualEditor({state, setState, trace, } // const offset = {x: 40, y: 40}; const offset = {x: 0, y: 0}; - let nextID = state.nextID; - try { + setState(state => { + let nextID = state.nextID; const copiedRountangles: Rountangle[] = parsed.rountangles.map((r: Rountangle) => ({ ...r, uid: (nextID++).toString(), @@ -583,7 +534,7 @@ export const VisualEditor = memo(function VisualEditor({state, setState, trace, ...copiedTexts.map(t => ({uid: t.uid, parts: ["text"]})), ...copiedHistories.map(h => ({uid: h.uid, parts: ["history"]})), ]; - setState(state => ({ + return { ...state, rountangles: [...state.rountangles, ...copiedRountangles], diamonds: [...state.diamonds, ...copiedDiamonds], @@ -592,16 +543,14 @@ export const VisualEditor = memo(function VisualEditor({state, setState, trace, history: [...state.history, ...copiedHistories], nextID: nextID, selection: newSelection, - })); - // copyInternal(newSelection, e); // doesn't work - e.preventDefault(); - } - catch (e) { - } + }; + }); + // copyInternal(newSelection, e); // doesn't work + e.preventDefault(); } - } + }, [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 rountanglesToCopy = state.rountangles.filter(r => uidsToCopy.has(r.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, texts: textsToCopy, })); - } + }, []); - function onCopy(e: ClipboardEvent) { + const onCopy = useCallback((e: ClipboardEvent) => { if (selection.length > 0) { e.preventDefault(); - copyInternal(selection, e); + copyInternal(state, selection, e); } - } + }, [state, selection]); - function onCut(e: ClipboardEvent) { + const onCut = useCallback((e: ClipboardEvent) => { if (selection.length > 0) { - copyInternal(selection, e); + copyInternal(state, selection, e); deleteSelection(); e.preventDefault(); } - } + }, [state, selection]); const onEditText = useCallback((text: Text, newText: string) => { if (newText === "") {