better UI

This commit is contained in:
Joeri Exelmans 2025-10-20 16:29:48 +02:00
parent 44fb8726ca
commit 1f9379df7f
16 changed files with 440 additions and 248 deletions

View file

@ -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 <details> 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 <details> has no children (besides the obvious <summary> 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;
}

View file

@ -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<SetStateAction<ReactElement|null>>}) {
return <div style={{maxWidth: '500px', padding: 4}}>
<p><img src={logo} style={{maxWidth:'100%'}}/></p>

View file

@ -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;
}

View file

@ -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<InsertMode>("and");
const [historyState, setHistoryState] = useState<EditHistory>({current: emptyState, history: [], future: []});
const [ast, setAST] = useState<Statechart>(emptyStatechart);
const [errors, setErrors] = useState<TraceableError[]>([]);
const [rt, setRT] = useState<BigStep[]>([]);
const [rtIdx, setRTIdx] = useState<number|undefined>();
const [time, setTime] = useState<TimeMode>({kind: "paused", simtime: 0});
const [modal, setModal] = useState<ReactElement|null>(null);
const editorState = historyState.current;
const setEditorState = (cb: (value: VisualEditorState) => VisualEditorState) => {
setHistoryState(historyState => ({...historyState, current: cb(historyState.current)}));
}
const refRightSideBar = useRef<HTMLDivElement>(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<string> = (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 && <div
className="modalOuter"
@ -157,56 +207,83 @@ export function App() {
</span>
</div>
</div>}
<Stack sx={{height:'100vh'}}>
{/* Top bar */}
<Box
sx={{
display: "flex",
borderBottom: 1,
borderColor: "divider",
alignItems: 'center',
flex: '0 0 content',
}}>
<TopPanel
rt={rtIdx === undefined ? undefined : rt[rtIdx]}
{...{rtIdx, ast, time, setTime, onInit, onClear, onRaise, onBack, mode, setMode, setModal}}
/>
</Box>
{/* Everything below the top bar */}
<Stack direction="row" sx={{
overflow: 'auto',
}}>
<Stack sx={{height:'100%'}}>
<Stack direction="row" sx={{flexGrow:1, overflow: "auto"}}>
{/* main */}
<Box sx={{
flexGrow:1,
overflow:'auto',
}}>
<VisualEditor {...{ast, setAST, rt: rt.at(rtIdx!), setRT, errors, setErrors, mode, highlightActive, highlightTransitions, setModal}}/>
{/* Left: top bar and main editor */}
<Box sx={{flexGrow:1, overflow: "auto"}}>
<Stack sx={{height:'100%'}}>
{/* Top bar */}
<Box sx={{
display: "flex",
borderBottom: 1,
borderColor: "divider",
alignItems: 'center',
flex: '0 0 content',
}}>
<TopPanel
rt={rtIdx === undefined ? undefined : rt[rtIdx]}
{...{rtIdx, ast, time, setTime, onUndo, onRedo, onInit, onClear, onRaise, onBack, mode, setMode, setModal}}
/>
</Box>
{/* Below the top bar: Editor */}
<Box sx={{flexGrow:1, overflow: "auto"}}>
<VisualEditor {...{state: editorState, setState: setEditorState, ast, setAST, rt: rt.at(rtIdx!), setRT, errors, setErrors, mode, highlightActive, highlightTransitions, setModal, makeCheckPoint}}/>
</Box>
</Stack>
</Box>
{/* right sidebar */}
<Box
sx={{
borderLeft: 1,
borderColor: "divider",
flex: '0 0 content',
overflowY: "auto",
overflowX: "visible",
maxWidth: 'min(300px, 30vw)',
}}>
<ShowAST {...{...ast, rt: rt.at(rtIdx!), highlightActive}}/>
<ShowOutputEvents outputEvents={ast.outputEvents}/>
<br/>
<div ref={refRightSideBar}>
<RTHistory {...{ast, rt, rtIdx, setTime, setRTIdx, refRightSideBar}}/>
</div>
</Box>
{/* Right: sidebar */}
<Box sx={{
borderLeft: 1,
borderColor: "divider",
flex: '0 0 content',
overflowY: "auto",
overflowX: "visible",
maxWidth: 'min(300px, 30vw)',
}}>
<Stack sx={{height:'100%'}}>
<Box className="onTop" sx={{flex: '0 0 content', backgroundColor: ''}}>
<details open={showStateTree}
onToggle={e => setShowStateTree(e.newState === "open")}>
<summary>state tree</summary>
<ul>
<ShowAST {...{...ast, rt: rt.at(rtIdx!), highlightActive}}/>
</ul>
</details>
<hr/>
<details open={showInputEvents}
onToggle={e => setShowInputEvents(e.newState === "open")}>
<summary>input events</summary>
<ShowInputEvents inputEvents={ast.inputEvents} onRaise={onRaise} disabled={rtIdx===undefined}/>
</details>
<hr/>
<details open={showOutputEvents}
onToggle={e => setShowOutputEvents(e.newState === "open")}>
<summary>output events</summary>
<ShowOutputEvents outputEvents={ast.outputEvents}/>
</details>
</Box>
<Box sx={{
flexGrow:1,
overflow:'auto',
minHeight: '75%', // <-- allows us to always scroll down the sidebar far enough such that the execution history is enough in view
}}>
<Box sx={{ height: '100%'}}>
<div ref={refRightSideBar}>
<RTHistory {...{ast, rt, rtIdx, setTime, setRTIdx, refRightSideBar}}/>
</div>
</Box>
</Box>
</Stack>
</Box>
</Stack>
<Box sx={{
flex: '0 0 content',
}}>
{/* Bottom panel */}
<Box sx={{flex: '0 0 content'}}>
<BottomPanel {...{errors}}/>
</Box>
</Stack>

View file

@ -6,11 +6,11 @@ import "./BottomPanel.css";
import head from "../head.svg" ;
export function BottomPanel(props: {errors: TraceableError[]}) {
const [greeting, setGreeting] = useState(<><b><img src={head}/>&emsp;"Welcome to StateBuddy, buddy!"</b></>);
const [greeting, setGreeting] = useState(<><b><img src={head} style={{transform: "scaleX(-1)"}}/>&emsp;"Welcome to StateBuddy, buddy!"</b></>);
useEffect(() => {
setTimeout(() => {
setGreeting("");
setGreeting(<></>);
}, 2000);
}, []);

29
src/App/Icons.tsx Normal file
View file

@ -0,0 +1,29 @@
export function RountangleIcon(props: { kind: string; }) {
return <svg width={20} height={20}>
<rect rx={7} ry={7}
x={1} y={1}
width={18} height={18}
className={`rountangle ${props.kind}`}
style={{ ...(props.kind === "or" ? { strokeDasharray: '3 2' } : {}), strokeWidth: 1.2 }} />
</svg>;
}
export function PseudoStateIcon(props: {}) {
const w = 20, h = 20;
return <svg width={w} height={h}>
<polygon
points={`
${w / 2} ${1},
${w - 1} ${h / 2},
${w / 2} ${h - 1},
${1} ${h / 2},
`} fill="white" stroke="black" strokeWidth={1.2} />
</svg>;
}
export function HistoryIcon(props: { kind: "shallow" | "deep"; }) {
const w = 20, h = 20;
const text = props.kind === "shallow" ? "H" : "H*";
return <svg width={w} height={h}><circle cx={w / 2} cy={h / 2} r={Math.min(w, h) / 2 - 1} fill="white" stroke="black" /><text x={w / 2} y={h / 2 + 4} textAnchor="middle" fontSize={11} fontWeight={400}>{text}</text></svg>;
}

View file

@ -23,8 +23,12 @@ export function RTHistory({rt, rtIdx, ast, setRTIdx, setTime, refRightSideBar}:
{rt.map((r, idx) => <>
<hr/>
<div className={"runtimeState"+(idx===rtIdx?" active":"")} onClick={() => gotoRt(idx, r.simtime)}>
<div>{formatTime(r.simtime)}, {r.inputEvent || "<init>"}</div>
<ShowMode mode={r.mode} statechart={ast}/>
<div>
{formatTime(r.simtime)}
&emsp;
<div className="inputEvent">{r.inputEvent || "<init>"}</div>
</div>
<ShowMode mode={r.mode.difference(rt[idx-1]?.mode || new Set())} statechart={ast}/>
<ShowEnvironment environment={r.environment}/>
{r.outputEvents.length>0 && <>^
{r.outputEvents.map((e:RaisedEvent) => <span className="outputEvent">{e.name}</span>)}
@ -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
));
}

View file

@ -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<string, Transition[]>, rt: RT_Statechart | undefined, highlightActive: Set<string>}) {
const description = stateDescription(props.root);
const outgoing = props.transitions.get(props.root.uid) || [];
// const outgoing = props.transitions.get(props.root.uid) || [];
return <details open={true} className={props.highlightActive.has(props.root.uid) ? "active" : ""}>
return <li>{props.root.kind}: {description}
{props.root.kind !== "pseudo" && props.root.children.length>0 &&
<ul>
{props.root.children.map(child =>
<ShowAST key={child.uid} root={child} transitions={props.transitions} rt={props.rt} highlightActive={props.highlightActive} />
)}
</ul>
}
</li>;
return <details open={true} className={"stateTree" + (props.highlightActive.has(props.root.uid) ? " active" : "")}>
<summary>{props.root.kind}: {description}</summary>
{props.root.kind !== "pseudo" && props.root.entryActions.length>0 &&
{/* {props.root.kind !== "pseudo" && props.root.entryActions.length>0 &&
props.root.entryActions.map(action =>
<div>&emsp;entry / <ShowAction action={action}/></div>
)
@ -48,23 +58,56 @@ export function ShowAST(props: {root: ConcreteState | PseudoState, transitions:
props.root.exitActions.map(action =>
<div>&emsp;exit / <ShowAction action={action}/></div>
)
}
} */}
{props.root.kind !== "pseudo" && props.root.children.length>0 &&
props.root.children.map(child =>
<ShowAST root={child} transitions={props.transitions} rt={props.rt} highlightActive={props.highlightActive} />
<ShowAST key={child.uid} root={child} transitions={props.transitions} rt={props.rt} highlightActive={props.highlightActive} />
)
}
{outgoing.length>0 &&
{/* {outgoing.length>0 &&
outgoing.map(transition => <>&emsp;<ShowTransition transition={transition}/><br/></>)
}
</details>
} */}
</details>;
}
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}) =>
<div key={event+'/'+paramName} className="toolbarGroup">
<button
className="inputEvent"
title={`raise this input event`}
disabled={disabled}
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}/></>
}
&nbsp;
</div>
)
}
export function ShowOutputEvents(props: {outputEvents: Set<string>}) {
return <div style={{whiteSpace: 'wrap'}}>
out:
{[...props.outputEvents].map(eventName => {
return <><span className="outputEvent">{eventName}</span> </>;
})}
</div>;
return [...props.outputEvents].map(eventName => {
return <><div className="outputEvent">{eventName}</div> </>;
});
}

View file

@ -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<SetStateAction<ReactElemen
try {
const parsed = parseLabel(text);
} catch (e) {
// @ts-ignore
error = e.message;
}

View file

@ -20,13 +20,16 @@ import { formatTime } from "./util";
import { InsertMode } from "../VisualEditor/VisualEditor";
import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo";
import { About } from "./About";
import { Stack } from "@mui/material";
import { usePersistentState } from "@/util/persistent_state";
import { RountangleIcon, PseudoStateIcon, HistoryIcon } from "./Icons";
export type TopPanelProps = {
rt?: BigStep,
rtIdx?: number,
time: TimeMode,
setTime: Dispatch<SetStateAction<TimeMode>>,
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<SetStateAction<InsertMode>>,
setModal: Dispatch<SetStateAction<ReactElement>>,
setModal: Dispatch<SetStateAction<ReactElement|null>>,
}
function RountangleIcon(props: {kind: string}) {
return <svg width={20} height={20}>
<rect rx={7} ry={7}
x={1} y={1}
width={18} height={18}
className={`rountangle ${props.kind}`}
style={{...(props.kind === "or" ? {strokeDasharray: '3 2'}: {}), strokeWidth: 1.2}}
/>
</svg>;
}
function PseudoStateIcon(props: {}) {
const w=20, h=20;
return <svg width={w} height={h}>
<polygon
points={`
${w/2} ${1},
${w-1} ${h/2},
${w/2} ${h-1},
${1} ${h/2},
`} fill="white" stroke="black" strokeWidth={1.2}/>
</svg>;
}
function HistoryIcon(props: {kind: "shallow"|"deep"}) {
const w=20, h=20;
const text = props.kind === "shallow" ? "H" : "H*";
return <svg width={w} height={h}><circle cx={w/2} cy={h/2} r={Math.min(w,h)/2-1} fill="white" stroke="black"/><text x={w/2} y={h/2+4} textAnchor="middle" fontSize={11} fontWeight={400}>{text}</text></svg>;
}
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 */}
<div className="toolbarGroup">
<KeyInfo keyInfo={<><kbd>Ctrl</kbd>+<kbd>Z</kbd></>}>
<button title="undo"><UndoIcon fontSize="small"/></button>
<button title="undo" onClick={onUndo}><UndoIcon fontSize="small"/></button>
</KeyInfo>
<KeyInfo keyInfo={<><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd></>}>
<button title="redo"><RedoIcon fontSize="small"/></button>
<button title="redo" onClick={onRedo}><RedoIcon fontSize="small"/></button>
</KeyInfo>
&emsp;
</div>
@ -233,7 +212,7 @@ export function TopPanel({rt, rtIdx, time, setTime, onInit, onClear, onRaise, on
["transition", "transitions", <TrendingFlatIcon fontSize="small"/>, <kbd>T</kbd>],
["text", "text", <>&nbsp;T&nbsp;</>, <kbd>X</kbd>],
] as [InsertMode, string, ReactElement, ReactElement][]).map(([m, hint, buttonTxt, keyInfo]) =>
<KeyInfo keyInfo={keyInfo}>
<KeyInfo key={m} keyInfo={keyInfo}>
<button
title={"insert "+hint}
disabled={mode===m}
@ -294,33 +273,42 @@ export function TopPanel({rt, rtIdx, time, setTime, onInit, onClear, onRaise, on
</div>
{/* input events */}
<div className="toolbarGroup">
{/* <div className="toolbarGroup">
{ast.inputEvents &&
<>
{ast.inputEvents.map(({event, paramName}) =>
<div className="toolbarGroup"><button title={`raise input event '${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
<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}/></>
}
}
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}/></>}
&nbsp;</div>)}
&nbsp;
</div>
)}
</>
}
</div>
</div> */}
</div>;
}

View file

@ -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]) => <g key={side}>
{(props.selected.includes(side) || props.highlight.includes(side)) && <line className={""
+ (props.selected.includes(side) ? " selected" : "")
+ (props.highlight.includes(side) ? " highlight" : "")}
{...ps} data-uid={props.uid} data-parts={side}/>
}
<line className="helper" {...ps} data-uid={props.uid} data-parts={side}/>
</>)}
</g>)}
{/* 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. */}
<circle

View file

@ -36,7 +36,5 @@ export function RountangleSVG(props: { rountangle: Rountangle; selected: string[
<RectHelper uid={uid} size={minSize}
selected={props.selected}
highlight={props.highlight} />
</g>;
}

View file

@ -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 {

View file

@ -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<SetStateAction<Statechart>>,
rt: BigStep|undefined,
@ -69,59 +66,10 @@ type VisualEditorProps = {
highlightActive: Set<string>,
highlightTransitions: string[],
setModal: Dispatch<SetStateAction<ReactElement|null>>,
makeCheckPoint: () => void;
};
export function VisualEditor({ast, setAST, rt, errors, setErrors, mode, highlightActive, highlightTransitions, setModal}: VisualEditorProps) {
const [historyState, setHistoryState] = useState<HistoryState>({current: emptyState, history: [], future: []});
const state = historyState.current;
const setState = (s: SetStateAction<VisualEditorState>) => {
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<DraggingState>(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 => <>
<HistorySVG {...history}
<HistorySVG
key={history.uid}
selected={Boolean(selection.find(h => 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 <TextSVG
key={txt.uid}
error={errors.find(({shapeUid}) => txt.uid === shapeUid)}
text={txt}
selected={Boolean(selection.find(s => s.uid === txt.uid)?.parts?.length)}

View file

@ -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<T>(key: string, initial: T): [T, Dispatch<SetStateAction<T>>] {
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<T>) {
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];
}

View file

@ -8,7 +8,6 @@
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}