From 1f9379df7f16e559527fa371a59a487c5fe8d149 Mon Sep 17 00:00:00 2001 From: Joeri Exelmans Date: Mon, 20 Oct 2025 16:29:48 +0200 Subject: [PATCH] better UI --- src/App/AST.css | 92 ++++++++++++--- src/App/About.tsx | 3 +- src/App/App.css | 29 +++-- src/App/App.tsx | 179 +++++++++++++++++++++-------- src/App/BottomPanel.tsx | 4 +- src/App/Icons.tsx | 29 +++++ src/App/RTHistory.tsx | 12 +- src/App/ShowAST.tsx | 73 +++++++++--- src/App/TextDialog.tsx | 3 +- src/App/TopPanel.tsx | 126 +++++++++----------- src/VisualEditor/RectHelpers.tsx | 4 +- src/VisualEditor/RountangleSVG.tsx | 2 - src/VisualEditor/VisualEditor.css | 9 +- src/VisualEditor/VisualEditor.tsx | 84 ++------------ src/util/persistent_state.ts | 38 ++++++ tsconfig.json | 1 - 16 files changed, 440 insertions(+), 248 deletions(-) create mode 100644 src/App/Icons.tsx create mode 100644 src/util/persistent_state.ts diff --git a/src/App/AST.css b/src/App/AST.css index f6782b2..399cd24 100644 --- a/src/App/AST.css +++ b/src/App/AST.css @@ -1,22 +1,42 @@ details.active { - /* background-color: rgba(128, 72, 0, 0.855); - color: white; */ border: rgb(192, 125, 0); background-color:rgb(255, 251, 244); filter: drop-shadow( 0px 0px 3px rgba(192, 125, 0, 0.856)); } -details { - border: 1px black solid; - /* border-radius: 5px; */ - background-color: white; +details > summary { + padding: 2px; +} + +/* these two rules add a bit of padding to an opened
node */ +details:open > summary { margin-bottom: 4px; - padding-right: 2px; - padding-top: 2px; - padding-bottom: 2px; - color: black; - width: fit-content; - border-radius: 10px; +} +details:open { + padding-bottom: 8px; +} + +details > summary:hover { + background-color: #eee; + cursor: pointer; +} + +.stateTree > * { + padding-left: 10px; + /* border: 1px black solid; */ + background-color: white; + /* margin-bottom: 4px; */ + /* padding-right: 2px; */ + /* padding-top: 2px; */ + /* padding-bottom: 2px; */ + /* color: black; */ + /* width: fit-content; */ + /* border-radius: 10px; */ +} + +/* if
has no children (besides the obvious child), then hide the marker */ +details:not(:has(:not(summary))) > summary::marker { + content: " "; } .outputEvent { @@ -29,6 +49,25 @@ details { display: inline-block; } +.inputEvent { + border: 1px black solid; + border-radius: 6px; + margin-left: 4px; + padding-left: 2px; + padding-right: 2px; + background-color: rgb(224, 247, 209); + display: inline-block; +} +.inputEvent * { + vertical-align: middle; +} +button.inputEvent:hover:not(:disabled) { + background-color: rgb(195, 224, 176); +} +button.inputEvent:active:not(:disabled) { + background-color: rgb(176, 204, 158); +} + .activeState { border: rgb(192, 125, 0); background-color:rgb(255, 251, 244); @@ -46,6 +85,33 @@ hr { border: 0; border-top: 1px solid #ccc; margin: 0; - margin-bottom: -3px; + margin-bottom: -1px; padding: 0; } + +ul { + list-style-type: circle; + margin-block-start: 0; + margin-block-end: 0; + padding-inline-start: 24px; + /* list-style-position: ; */ +} + +.insetParent { + position: relative; +} + +.insetChild { + position: absolute; + box-shadow: inset 0 10px 10px -10px rgba(0, 0, 0, 0.75); + height: 20px; + z-index: 10; + pointer-events: none; + inset: 0; +} + + +.onTop { + box-shadow: 0 -10px 10px 10px rgba(0, 0, 0, 0.75); + z-index: 1; +} \ No newline at end of file diff --git a/src/App/About.tsx b/src/App/About.tsx index a3826d4..d164f6f 100644 --- a/src/App/About.tsx +++ b/src/App/About.tsx @@ -1,6 +1,7 @@ +import { Dispatch, ReactElement, SetStateAction } from "react"; import logo from "../../artwork/logo.svg"; -export function About(props: {setModal}) { +export function About(props: {setModal: Dispatch>}) { return

diff --git a/src/App/App.css b/src/App/App.css index 8ad7d75..059bc42 100644 --- a/src/App/App.css +++ b/src/App/App.css @@ -1,10 +1,9 @@ -details { +/* details { padding-left: 20; - /* margin-left: 30; */ } summary { margin-left: -20; -} +} */ .runtimeState { padding-left: 4px; @@ -39,10 +38,6 @@ summary { vertical-align: middle; } -.toolbar *:not(label) { - /* vertical-align: bottom; */ -} - .toolbar input { height: 20px; } @@ -67,15 +62,11 @@ button.active { position: absolute; width: 100%; height: 100%; - display: flex; justify-content: center; align-items: center; - text-align: center; - background-color: rgba(200,200,200,0.7); - /* backdrop-filter: blur(2px) */ } .modalInner { @@ -86,3 +77,19 @@ button.active { max-height: 100vh; overflow: auto; } + + + +.line { + border-bottom: solid 1px #000; + height: 10px; + line-height: 20px; + text-align: left; + margin-bottom: 14px; +} +.line .content { + background-color: #FFF; + display: inline; + padding: 0 10px; + margin-left: 10px; +} \ No newline at end of file diff --git a/src/App/App.tsx b/src/App/App.tsx index 58007a5..b320e0d 100644 --- a/src/App/App.tsx +++ b/src/App/App.tsx @@ -1,4 +1,4 @@ -import { ReactElement, useEffect, useRef, useState } from "react"; +import { Dispatch, ReactElement, SetStateAction, useEffect, useRef, useState } from "react"; import { emptyStatechart, Statechart } from "../statecharts/abstract_syntax"; import { handleInputEvent, initialize } from "../statecharts/interpreter"; @@ -9,26 +9,73 @@ import { getSimTime, getWallClkDelay, TimeMode } from "../statecharts/time"; import "../index.css"; import "./App.css"; -import { Box, Stack } from "@mui/material"; +import Stack from "@mui/material/Stack"; +import Box from "@mui/material/Box"; import { TopPanel } from "./TopPanel"; import { RTHistory } from "./RTHistory"; -import { ShowAST, ShowOutputEvents } from "./ShowAST"; +import { ShowAST, ShowInputEvents, ShowOutputEvents } from "./ShowAST"; import { TraceableError } from "../statecharts/parser"; import { getKeyHandler } from "./shortcut_handler"; import { BottomPanel } from "./BottomPanel"; +import { emptyState, VisualEditorState } from "@/statecharts/concrete_syntax"; +import { usePersistentState } from "@/util/persistent_state"; + +type EditHistory = { + current: VisualEditorState, + history: VisualEditorState[], + future: VisualEditorState[], +} export function App() { const [mode, setMode] = useState("and"); + const [historyState, setHistoryState] = useState({current: emptyState, history: [], future: []}); const [ast, setAST] = useState(emptyStatechart); const [errors, setErrors] = useState([]); const [rt, setRT] = useState([]); const [rtIdx, setRTIdx] = useState(); const [time, setTime] = useState({kind: "paused", simtime: 0}); - const [modal, setModal] = useState(null); + const editorState = historyState.current; + const setEditorState = (cb: (value: VisualEditorState) => VisualEditorState) => { + setHistoryState(historyState => ({...historyState, current: cb(historyState.current)})); + } + const refRightSideBar = useRef(null); + + function makeCheckPoint() { + setHistoryState(historyState => ({ + ...historyState, + history: [...historyState.history, historyState.current], + future: [], + })); + } + function onUndo() { + setHistoryState(historyState => { + if (historyState.history.length === 0) { + return historyState; // no change + } + return { + current: historyState.history.at(-1)!, + history: historyState.history.slice(0,-1), + future: [...historyState.future, historyState.current], + } + }) + } + function onRedo() { + setHistoryState(historyState => { + if (historyState.future.length === 0) { + return historyState; // no change + } + return { + current: historyState.future.at(-1)!, + history: [...historyState.history, historyState.current], + future: historyState.future.slice(0,-1), + } + }); + } + function onInit() { const config = initialize(ast); setRT([{inputEvent: null, simtime: 0, ...config}]); @@ -140,13 +187,16 @@ export function App() { // return state && state.parent?.kind !== "and"; // })) || new Set(); - const highlightActive = (rtIdx === undefined) ? new Set() : rt[rtIdx].mode; + const highlightActive: Set = (rtIdx === undefined) ? new Set() : rt[rtIdx].mode; const highlightTransitions = (rtIdx === undefined) ? [] : rt[rtIdx].firedTransitions; - console.log(ast); + const [showStateTree, setShowStateTree] = usePersistentState("showStateTree", true); + const [showInputEvents, setShowInputEvents] = usePersistentState("showInputEvents", true); + const [showOutputEvents, setShowOutputEvents] = usePersistentState("showOutputEvents", true); return <> + {/* Modal dialog */} {modal &&
} - - {/* Top bar */} - - - - {/* Everything below the top bar */} - + + - {/* main */} - - + {/* Left: top bar and main editor */} + + + {/* Top bar */} + + + + {/* Below the top bar: Editor */} + + + + - {/* right sidebar */} - - - -
-
- -
-
+ {/* Right: sidebar */} + + + +
setShowStateTree(e.newState === "open")}> + state tree +
    + +
+
+
+
setShowInputEvents(e.newState === "open")}> + input events + +
+
+
setShowOutputEvents(e.newState === "open")}> + output events + +
+
+ + +
+ +
+
+
+
+
+ +
- + + {/* Bottom panel */} +
diff --git a/src/App/BottomPanel.tsx b/src/App/BottomPanel.tsx index 595a7ad..996abb3 100644 --- a/src/App/BottomPanel.tsx +++ b/src/App/BottomPanel.tsx @@ -6,11 +6,11 @@ import "./BottomPanel.css"; import head from "../head.svg" ; export function BottomPanel(props: {errors: TraceableError[]}) { - const [greeting, setGreeting] = useState(<> "Welcome to StateBuddy, buddy!"); + const [greeting, setGreeting] = useState(<> "Welcome to StateBuddy, buddy!"); useEffect(() => { setTimeout(() => { - setGreeting(""); + setGreeting(<>); }, 2000); }, []); diff --git a/src/App/Icons.tsx b/src/App/Icons.tsx new file mode 100644 index 0000000..97ae50c --- /dev/null +++ b/src/App/Icons.tsx @@ -0,0 +1,29 @@ + +export function RountangleIcon(props: { kind: string; }) { + return + + ; +} + +export function PseudoStateIcon(props: {}) { + const w = 20, h = 20; + return + + ; +} + +export function HistoryIcon(props: { kind: "shallow" | "deep"; }) { + const w = 20, h = 20; + const text = props.kind === "shallow" ? "H" : "H*"; + return {text}; +} diff --git a/src/App/RTHistory.tsx b/src/App/RTHistory.tsx index c88780c..41f1a34 100644 --- a/src/App/RTHistory.tsx +++ b/src/App/RTHistory.tsx @@ -23,8 +23,12 @@ export function RTHistory({rt, rtIdx, ast, setRTIdx, setTime, refRightSideBar}: {rt.map((r, idx) => <>
gotoRt(idx, r.simtime)}> -
{formatTime(r.simtime)}, {r.inputEvent || ""}
- +
+ {formatTime(r.simtime)} +   +
{r.inputEvent || ""}
+
+ {r.outputEvents.length>0 && <>^ {r.outputEvents.map((e:RaisedEvent) => {e.name})} @@ -49,5 +53,7 @@ function ShowMode(props: {mode: Mode, statechart: Statechart}) { } function getActiveLeafs(mode: Mode, sc: Statechart) { - return new Set([...mode].filter(uid => sc.uid2State.get(uid)?.children?.length === 0)); + return new Set([...mode].filter(uid => + sc.uid2State.get(uid)?.children?.length === 0 + )); } diff --git a/src/App/ShowAST.tsx b/src/App/ShowAST.tsx index 0a5208e..9c18fe2 100644 --- a/src/App/ShowAST.tsx +++ b/src/App/ShowAST.tsx @@ -1,5 +1,5 @@ import { ConcreteState, PseudoState, stateDescription, Transition } from "../statecharts/abstract_syntax"; -import { Action, Expression } from "../statecharts/label_ast"; +import { Action, EventTrigger, Expression } from "../statecharts/label_ast"; import { RT_Statechart } from "../statecharts/runtime_types"; import "./AST.css"; @@ -34,12 +34,22 @@ export function ShowAction(props: {action: Action}) { export function ShowAST(props: {root: ConcreteState | PseudoState, transitions: Map, rt: RT_Statechart | undefined, highlightActive: Set}) { const description = stateDescription(props.root); - const outgoing = props.transitions.get(props.root.uid) || []; + // const outgoing = props.transitions.get(props.root.uid) || []; - return
+ return
  • {props.root.kind}: {description} + {props.root.kind !== "pseudo" && props.root.children.length>0 && +
      + {props.root.children.map(child => + + )} +
    + } +
  • ; + + return
    {props.root.kind}: {description} - {props.root.kind !== "pseudo" && props.root.entryActions.length>0 && + {/* {props.root.kind !== "pseudo" && props.root.entryActions.length>0 && props.root.entryActions.map(action =>
     entry /
    ) @@ -48,23 +58,56 @@ export function ShowAST(props: {root: ConcreteState | PseudoState, transitions: props.root.exitActions.map(action =>
     exit /
    ) - } + } */} + {props.root.kind !== "pseudo" && props.root.children.length>0 && props.root.children.map(child => - + ) } - {outgoing.length>0 && + {/* {outgoing.length>0 && outgoing.map(transition => <> 
    ) - } -
    + } */} +
    ; +} + +import BoltIcon from '@mui/icons-material/Bolt'; + +export function ShowInputEvents({inputEvents, onRaise, disabled}: {inputEvents: EventTrigger[], onRaise: (e: string, p: any) => void, disabled: boolean}) { + return inputEvents.map(({event, paramName}) => +
    + + {paramName && + <> + } +   +
    + ) } export function ShowOutputEvents(props: {outputEvents: Set}) { - return
    - out: - {[...props.outputEvents].map(eventName => { - return <>{eventName} ; - })} -
    ; + return [...props.outputEvents].map(eventName => { + return <>
    {eventName}
    ; + }); } diff --git a/src/App/TextDialog.tsx b/src/App/TextDialog.tsx index 082073f..b10d5ab 100644 --- a/src/App/TextDialog.tsx +++ b/src/App/TextDialog.tsx @@ -1,4 +1,4 @@ -import { Dispatch, ReactElement, SetStateAction, useState } from "react"; +import { Dispatch, ReactElement, SetStateAction, useState, KeyboardEvent } from "react"; import { parse as parseLabel } from "../statecharts/label_parser"; @@ -23,6 +23,7 @@ export function TextDialog(props: {setModal: Dispatch>, + onUndo: () => void, + onRedo: () => void, onInit: () => void, onClear: () => void, onRaise: (e: string, p: any) => void, @@ -34,44 +37,13 @@ export type TopPanelProps = { ast: Statechart, mode: InsertMode, setMode: Dispatch>, - setModal: Dispatch>, + setModal: Dispatch>, } -function RountangleIcon(props: {kind: string}) { - return - - ; -} - -function PseudoStateIcon(props: {}) { - const w=20, h=20; - return - - ; -} - -function HistoryIcon(props: {kind: "shallow"|"deep"}) { - const w=20, h=20; - const text = props.kind === "shallow" ? "H" : "H*"; - return {text}; -} - - -export function TopPanel({rt, rtIdx, time, setTime, onInit, onClear, onRaise, onBack, ast, mode, setMode, setModal}: TopPanelProps) { +export function TopPanel({rt, rtIdx, time, setTime, onUndo, onRedo, onInit, onClear, onRaise, onBack, ast, mode, setMode, setModal}: TopPanelProps) { const [displayTime, setDisplayTime] = useState("0.000"); const [timescale, setTimescale] = useState(1); - const [showKeys, setShowKeys] = useState(true); + const [showKeys, setShowKeys] = usePersistentState("shortcuts", true); const KeyInfo = showKeys ? KeyInfoVisible : KeyInfoHidden; @@ -92,8 +64,13 @@ export function TopPanel({rt, rtIdx, time, setTime, onInit, onClear, onRaise, on onClear(); } if (e.key === "Tab") { + if (rtIdx === undefined) { + onInit(); + } + else { + onSkip(); + } e.preventDefault(); - onSkip(); } if (e.key === "s") { e.preventDefault(); @@ -112,6 +89,17 @@ export function TopPanel({rt, rtIdx, time, setTime, onInit, onClear, onRaise, on onBack(); } } + else { + // ctrl is down + if (e.key === "z") { + e.preventDefault(); + onUndo(); + } + if (e.key === "Z") { + e.preventDefault(); + onRedo(); + } + } }; window.addEventListener("keydown", onKeyDown); return () => { @@ -119,15 +107,6 @@ export function TopPanel({rt, rtIdx, time, setTime, onInit, onClear, onRaise, on }; }, [time, onInit, timescale]); - useEffect(() => { - setTimeout(() => localStorage.setItem("showKeys", showKeys?"1":"0"), 100); - }, [showKeys]) - - useEffect(() => { - const show = localStorage.getItem("showKeys") || "1"; - setShowKeys(show==="1") - }, []) - function updateDisplayedTime() { const now = Math.round(performance.now()); const timeMs = getSimTime(time, now); @@ -214,10 +193,10 @@ export function TopPanel({rt, rtIdx, time, setTime, onInit, onClear, onRaise, on {/* undo / redo */}
    Ctrl+Z}> - + Ctrl+Shift+Z}> - +
    @@ -233,7 +212,7 @@ export function TopPanel({rt, rtIdx, time, setTime, onInit, onClear, onRaise, on ["transition", "transitions", , T], ["text", "text", <> T , X], ] as [InsertMode, string, ReactElement, ReactElement][]).map(([m, hint, buttonTxt, keyInfo]) => - + + {paramName && + <> } - } - catch (e) { - alert("invalid json"); - return; - } - onRaise(event, paramParsed); - }}> - - {event} - - {paramName && <>} -  
    )} +   + + )} } - + */} ; } diff --git a/src/VisualEditor/RectHelpers.tsx b/src/VisualEditor/RectHelpers.tsx index 9bec926..06b25c3 100644 --- a/src/VisualEditor/RectHelpers.tsx +++ b/src/VisualEditor/RectHelpers.tsx @@ -14,14 +14,14 @@ function lineGeometryProps(size: Vec2D): [RountanglePart, object][] { export function RectHelper(props: { uid: string, size: Vec2D, selected: string[], highlight: RountanglePart[] }) { const geomProps = lineGeometryProps(props.size); return <> - {geomProps.map(([side, ps]) => <> + {geomProps.map(([side, ps]) => {(props.selected.includes(side) || props.highlight.includes(side)) && } - )} + )} {/* The corner-helpers have the DOM class 'corner' added to them, because we ignore them when the user is making a selection. Only if the user clicks directly on them, do we select their respective parts. */} - - ; } diff --git a/src/VisualEditor/VisualEditor.css b/src/VisualEditor/VisualEditor.css index 619125a..e40a4b8 100644 --- a/src/VisualEditor/VisualEditor.css +++ b/src/VisualEditor/VisualEditor.css @@ -12,9 +12,6 @@ visibility: hidden !important; } -.svgCanvas.active { - /* background-color: rgb(255, 140, 0, 0.2); */ -} .svgCanvas text { user-select: none; @@ -129,7 +126,7 @@ line.selected, circle.selected { text.helper { fill: rgba(0,0,0,0); stroke: rgba(0,0,0,0); - stroke-width: 16px; + stroke-width: 6px; } text.helper:hover { stroke: blue; @@ -162,8 +159,10 @@ text.helper:hover { stroke: var(--error-color); } .arrow.fired { - stroke: rgb(192, 125, 0); + stroke: rgb(231, 111, 0); stroke-width: 3px; + + filter: drop-shadow( 0px 0px 5px rgb(186, 5, 195)); } text.error, tspan.error { diff --git a/src/VisualEditor/VisualEditor.tsx b/src/VisualEditor/VisualEditor.tsx index f0cb80f..3fac712 100644 --- a/src/VisualEditor/VisualEditor.tsx +++ b/src/VisualEditor/VisualEditor.tsx @@ -44,11 +44,6 @@ type HistorySelectable = { type Selectable = RountangleSelectable | ArrowSelectable | TextSelectable | HistorySelectable; type Selection = Selectable[]; -type HistoryState = { - current: VisualEditorState, - history: VisualEditorState[], - future: VisualEditorState[], -} export const sides: [RountanglePart, (r:Rect2D)=>Line2D][] = [ ["left", getLeftSide], @@ -60,6 +55,8 @@ export const sides: [RountanglePart, (r:Rect2D)=>Line2D][] = [ export type InsertMode = "and"|"or"|"pseudo"|"shallow"|"deep"|"transition"|"text"; type VisualEditorProps = { + state: VisualEditorState, + setState: Dispatch<(v:VisualEditorState) => VisualEditorState>, ast: Statechart, setAST: Dispatch>, rt: BigStep|undefined, @@ -69,59 +66,10 @@ type VisualEditorProps = { highlightActive: Set, highlightTransitions: string[], setModal: Dispatch>, + makeCheckPoint: () => void; }; -export function VisualEditor({ast, setAST, rt, errors, setErrors, mode, highlightActive, highlightTransitions, setModal}: VisualEditorProps) { - const [historyState, setHistoryState] = useState({current: emptyState, history: [], future: []}); - - const state = historyState.current; - const setState = (s: SetStateAction) => { - setHistoryState(historyState => { - let newState; - if (typeof s === 'function') { - newState = s(historyState.current); - } - else { - newState = s; - } - return { - ...historyState, - current: newState, - }; - }); - } - - function checkPoint() { - setHistoryState(historyState => ({ - ...historyState, - history: [...historyState.history, historyState.current], - future: [], - })); - } - function undo() { - setHistoryState(historyState => { - if (historyState.history.length === 0) { - return historyState; // no change - } - return { - current: historyState.history.at(-1)!, - history: historyState.history.slice(0,-1), - future: [...historyState.future, historyState.current], - } - }) - } - function redo() { - setHistoryState(historyState => { - if (historyState.future.length === 0) { - return historyState; // no change - } - return { - current: historyState.future.at(-1)!, - history: [...historyState.history, historyState.current], - future: historyState.future.slice(0,-1), - } - }); - } +export function VisualEditor({state, setState, ast, setAST, rt, errors, setErrors, mode, highlightActive, highlightTransitions, setModal, makeCheckPoint}: VisualEditorProps) { const [dragging, setDragging] = useState(null); @@ -136,7 +84,6 @@ export function VisualEditor({ast, setAST, rt, errors, setErrors, mode, highligh useEffect(() => { try { const compressedState = window.location.hash.slice(1); - console.log('get old state'); const ds = new DecompressionStream("deflate"); const writer = ds.writable.getWriter(); writer.write(Uint8Array.fromBase64(compressedState)).catch(e => { @@ -148,9 +95,8 @@ export function VisualEditor({ast, setAST, rt, errors, setErrors, mode, highligh new Response(ds.readable).arrayBuffer().then(decompressedBuffer => { try { - console.log('recovering state'); const recoveredState = JSON.parse(new TextDecoder().decode(decompressedBuffer)); - setState(recoveredState); + setState(() => recoveredState); } catch (e) { console.error("could not recover state:", e); @@ -177,7 +123,6 @@ export function VisualEditor({ast, setAST, rt, errors, setErrors, mode, highligh // todo: cancel this promise handler when concurrently starting another compression job new Response(cs.readable).arrayBuffer().then(compressedStateBuffer => { const compressedStateString = new Uint8Array(compressedStateBuffer).toBase64(); - console.log(compressedStateString.length, serializedState.length); window.location.hash = "#"+compressedStateString; }); }, 100); @@ -204,7 +149,7 @@ export function VisualEditor({ast, setAST, rt, errors, setErrors, mode, highligh const currentPointer = getCurrentPointer(e); if (e.button === 2) { - checkPoint(); + makeCheckPoint(); // ignore selection, middle mouse button always inserts setState(state => { const newID = state.nextID.toString(); @@ -283,7 +228,7 @@ export function VisualEditor({ast, setAST, rt, errors, setErrors, mode, highligh const uid = e.target?.dataset.uid; const parts: string[] = e.target?.dataset.parts?.split(' ').filter((p:string) => p!=="") || []; if (uid && parts.length > 0) { - checkPoint(); + makeCheckPoint(); // if the mouse button is pressed outside of the current selection, we reset the selection to whatever shape the mouse is on let allPartsInSelection = true; @@ -473,7 +418,7 @@ export function VisualEditor({ast, setAST, rt, errors, setErrors, mode, highligh if (e.key === "Delete") { // delete selection if (selection.length > 0) { - checkPoint(); + makeCheckPoint(); deleteShapes(selection); } } @@ -508,14 +453,6 @@ export function VisualEditor({ast, setAST, rt, errors, setErrors, mode, highligh // }); // } if (e.ctrlKey) { - if (e.key === "z") { - e.preventDefault(); - undo(); - } - if (e.key === "Z") { - e.preventDefault(); - redo(); - } if (e.key === "a") { e.preventDefault(); setDragging(null); @@ -778,9 +715,11 @@ export function VisualEditor({ast, setAST, rt, errors, setErrors, mode, highligh )} {state.history.map(history => <> - h.uid === history.uid))} highlight={Boolean(historyToHighlight[history.uid])} + {...history} /> )} @@ -808,6 +747,7 @@ export function VisualEditor({ast, setAST, rt, errors, setErrors, mode, highligh {state.texts.map(txt => { return txt.uid === shapeUid)} text={txt} selected={Boolean(selection.find(s => s.uid === txt.uid)?.parts?.length)} diff --git a/src/util/persistent_state.ts b/src/util/persistent_state.ts new file mode 100644 index 0000000..8046c56 --- /dev/null +++ b/src/util/persistent_state.ts @@ -0,0 +1,38 @@ +import { Dispatch, SetStateAction, useState } from "react"; + +// like useState, but it is persisted in localStorage +// important: values must be JSON-(de-)serializable +export function usePersistentState(key: string, initial: T): [T, Dispatch>] { + const [state, setState] = useState(() => { + const recovered = localStorage.getItem(key); + let parsed; + if (recovered !== null) { + try { + parsed = JSON.parse(recovered); + return parsed; + } catch (e) { + // console.warn(`failed to recover state for option '${key}'`, e, + // '(this is normal when running the app for the first time)'); + } + } + return initial; + }); + + function setStateWrapped(val: SetStateAction) { + setState((oldState: T) => { + let newVal; + if (typeof val === 'function') { + // @ts-ignore: i don't understand why 'val' might not be callable + newVal = val(oldState); + } + else { + newVal = val; + } + const serialized = JSON.stringify(newVal); + localStorage.setItem(key, serialized); + return newVal; + }); + } + + return [state, setStateWrapped]; +} diff --git a/tsconfig.json b/tsconfig.json index 5aafbe9..ff2bff4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,7 +8,6 @@ "strict": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "baseUrl": ".", "paths": { "@/*": ["./src/*"] }