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 { details.active {
/* background-color: rgba(128, 72, 0, 0.855);
color: white; */
border: rgb(192, 125, 0); border: rgb(192, 125, 0);
background-color:rgb(255, 251, 244); background-color:rgb(255, 251, 244);
filter: drop-shadow( 0px 0px 3px rgba(192, 125, 0, 0.856)); filter: drop-shadow( 0px 0px 3px rgba(192, 125, 0, 0.856));
} }
details { details > summary {
border: 1px black solid; padding: 2px;
/* border-radius: 5px; */ }
background-color: white;
/* these two rules add a bit of padding to an opened <details> node */
details:open > summary {
margin-bottom: 4px; margin-bottom: 4px;
padding-right: 2px; }
padding-top: 2px; details:open {
padding-bottom: 2px; padding-bottom: 8px;
color: black; }
width: fit-content;
border-radius: 10px; 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 { .outputEvent {
@ -29,6 +49,25 @@ details {
display: inline-block; 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 { .activeState {
border: rgb(192, 125, 0); border: rgb(192, 125, 0);
background-color:rgb(255, 251, 244); background-color:rgb(255, 251, 244);
@ -46,6 +85,33 @@ hr {
border: 0; border: 0;
border-top: 1px solid #ccc; border-top: 1px solid #ccc;
margin: 0; margin: 0;
margin-bottom: -3px; margin-bottom: -1px;
padding: 0; 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"; 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}}> return <div style={{maxWidth: '500px', padding: 4}}>
<p><img src={logo} style={{maxWidth:'100%'}}/></p> <p><img src={logo} style={{maxWidth:'100%'}}/></p>

View file

@ -1,10 +1,9 @@
details { /* details {
padding-left: 20; padding-left: 20;
/* margin-left: 30; */
} }
summary { summary {
margin-left: -20; margin-left: -20;
} } */
.runtimeState { .runtimeState {
padding-left: 4px; padding-left: 4px;
@ -39,10 +38,6 @@ summary {
vertical-align: middle; vertical-align: middle;
} }
.toolbar *:not(label) {
/* vertical-align: bottom; */
}
.toolbar input { .toolbar input {
height: 20px; height: 20px;
} }
@ -67,15 +62,11 @@ button.active {
position: absolute; position: absolute;
width: 100%; width: 100%;
height: 100%; height: 100%;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
text-align: center; text-align: center;
background-color: rgba(200,200,200,0.7); background-color: rgba(200,200,200,0.7);
/* backdrop-filter: blur(2px) */
} }
.modalInner { .modalInner {
@ -86,3 +77,19 @@ button.active {
max-height: 100vh; max-height: 100vh;
overflow: auto; 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 { emptyStatechart, Statechart } from "../statecharts/abstract_syntax";
import { handleInputEvent, initialize } from "../statecharts/interpreter"; import { handleInputEvent, initialize } from "../statecharts/interpreter";
@ -9,26 +9,73 @@ import { getSimTime, getWallClkDelay, TimeMode } from "../statecharts/time";
import "../index.css"; import "../index.css";
import "./App.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 { TopPanel } from "./TopPanel";
import { RTHistory } from "./RTHistory"; import { RTHistory } from "./RTHistory";
import { ShowAST, ShowOutputEvents } from "./ShowAST"; import { ShowAST, ShowInputEvents, ShowOutputEvents } from "./ShowAST";
import { TraceableError } from "../statecharts/parser"; import { TraceableError } from "../statecharts/parser";
import { getKeyHandler } from "./shortcut_handler"; import { getKeyHandler } from "./shortcut_handler";
import { BottomPanel } from "./BottomPanel"; 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() { export function App() {
const [mode, setMode] = useState<InsertMode>("and"); const [mode, setMode] = useState<InsertMode>("and");
const [historyState, setHistoryState] = useState<EditHistory>({current: emptyState, history: [], future: []});
const [ast, setAST] = useState<Statechart>(emptyStatechart); const [ast, setAST] = useState<Statechart>(emptyStatechart);
const [errors, setErrors] = useState<TraceableError[]>([]); const [errors, setErrors] = useState<TraceableError[]>([]);
const [rt, setRT] = useState<BigStep[]>([]); const [rt, setRT] = useState<BigStep[]>([]);
const [rtIdx, setRTIdx] = useState<number|undefined>(); const [rtIdx, setRTIdx] = useState<number|undefined>();
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);
const editorState = historyState.current;
const setEditorState = (cb: (value: VisualEditorState) => VisualEditorState) => {
setHistoryState(historyState => ({...historyState, current: cb(historyState.current)}));
}
const refRightSideBar = useRef<HTMLDivElement>(null); 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() { function onInit() {
const config = initialize(ast); const config = initialize(ast);
setRT([{inputEvent: null, simtime: 0, ...config}]); setRT([{inputEvent: null, simtime: 0, ...config}]);
@ -140,13 +187,16 @@ export function App() {
// return state && state.parent?.kind !== "and"; // return state && state.parent?.kind !== "and";
// })) || new Set(); // })) || 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; 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 <> return <>
{/* Modal dialog */} {/* Modal dialog */}
{modal && <div {modal && <div
className="modalOuter" className="modalOuter"
@ -157,10 +207,15 @@ export function App() {
</span> </span>
</div> </div>
</div>} </div>}
<Stack sx={{height:'100vh'}}>
<Stack sx={{height:'100%'}}>
<Stack direction="row" sx={{flexGrow:1, overflow: "auto"}}>
{/* Left: top bar and main editor */}
<Box sx={{flexGrow:1, overflow: "auto"}}>
<Stack sx={{height:'100%'}}>
{/* Top bar */} {/* Top bar */}
<Box <Box sx={{
sx={{
display: "flex", display: "flex",
borderBottom: 1, borderBottom: 1,
borderColor: "divider", borderColor: "divider",
@ -169,26 +224,18 @@ export function App() {
}}> }}>
<TopPanel <TopPanel
rt={rtIdx === undefined ? undefined : rt[rtIdx]} rt={rtIdx === undefined ? undefined : rt[rtIdx]}
{...{rtIdx, ast, time, setTime, onInit, onClear, onRaise, onBack, mode, setMode, setModal}} {...{rtIdx, ast, time, setTime, onUndo, onRedo, onInit, onClear, onRaise, onBack, mode, setMode, setModal}}
/> />
</Box> </Box>
{/* Below the top bar: Editor */}
{/* Everything below the top bar */} <Box sx={{flexGrow:1, overflow: "auto"}}>
<Stack direction="row" sx={{ <VisualEditor {...{state: editorState, setState: setEditorState, ast, setAST, rt: rt.at(rtIdx!), setRT, errors, setErrors, mode, highlightActive, highlightTransitions, setModal, makeCheckPoint}}/>
overflow: 'auto', </Box>
}}> </Stack>
{/* main */}
<Box sx={{
flexGrow:1,
overflow:'auto',
}}>
<VisualEditor {...{ast, setAST, rt: rt.at(rtIdx!), setRT, errors, setErrors, mode, highlightActive, highlightTransitions, setModal}}/>
</Box> </Box>
{/* right sidebar */} {/* Right: sidebar */}
<Box <Box sx={{
sx={{
borderLeft: 1, borderLeft: 1,
borderColor: "divider", borderColor: "divider",
flex: '0 0 content', flex: '0 0 content',
@ -196,17 +243,47 @@ export function App() {
overflowX: "visible", overflowX: "visible",
maxWidth: 'min(300px, 30vw)', 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}}/> <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}/> <ShowOutputEvents outputEvents={ast.outputEvents}/>
<br/> </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}> <div ref={refRightSideBar}>
<RTHistory {...{ast, rt, rtIdx, setTime, setRTIdx, refRightSideBar}}/> <RTHistory {...{ast, rt, rtIdx, setTime, setRTIdx, refRightSideBar}}/>
</div> </div>
</Box> </Box>
</Box>
</Stack> </Stack>
<Box sx={{ </Box>
flex: '0 0 content',
}}>
</Stack>
{/* Bottom panel */}
<Box sx={{flex: '0 0 content'}}>
<BottomPanel {...{errors}}/> <BottomPanel {...{errors}}/>
</Box> </Box>
</Stack> </Stack>

View file

@ -6,11 +6,11 @@ import "./BottomPanel.css";
import head from "../head.svg" ; import head from "../head.svg" ;
export function BottomPanel(props: {errors: TraceableError[]}) { 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(() => { useEffect(() => {
setTimeout(() => { setTimeout(() => {
setGreeting(""); setGreeting(<></>);
}, 2000); }, 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) => <> {rt.map((r, idx) => <>
<hr/> <hr/>
<div className={"runtimeState"+(idx===rtIdx?" active":"")} onClick={() => gotoRt(idx, r.simtime)}> <div className={"runtimeState"+(idx===rtIdx?" active":"")} onClick={() => gotoRt(idx, r.simtime)}>
<div>{formatTime(r.simtime)}, {r.inputEvent || "<init>"}</div> <div>
<ShowMode mode={r.mode} statechart={ast}/> {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}/> <ShowEnvironment environment={r.environment}/>
{r.outputEvents.length>0 && <>^ {r.outputEvents.length>0 && <>^
{r.outputEvents.map((e:RaisedEvent) => <span className="outputEvent">{e.name}</span>)} {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) { 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 { 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 { RT_Statechart } from "../statecharts/runtime_types";
import "./AST.css"; 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>}) { export function ShowAST(props: {root: ConcreteState | PseudoState, transitions: Map<string, Transition[]>, rt: RT_Statechart | undefined, highlightActive: Set<string>}) {
const description = stateDescription(props.root); 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> <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 => props.root.entryActions.map(action =>
<div>&emsp;entry / <ShowAction action={action}/></div> <div>&emsp;entry / <ShowAction action={action}/></div>
) )
@ -48,23 +58,56 @@ export function ShowAST(props: {root: ConcreteState | PseudoState, transitions:
props.root.exitActions.map(action => props.root.exitActions.map(action =>
<div>&emsp;exit / <ShowAction action={action}/></div> <div>&emsp;exit / <ShowAction action={action}/></div>
) )
} } */}
{props.root.kind !== "pseudo" && props.root.children.length>0 && {props.root.kind !== "pseudo" && props.root.children.length>0 &&
props.root.children.map(child => 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/></>) 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>}) { export function ShowOutputEvents(props: {outputEvents: Set<string>}) {
return <div style={{whiteSpace: 'wrap'}}> return [...props.outputEvents].map(eventName => {
out: return <><div className="outputEvent">{eventName}</div> </>;
{[...props.outputEvents].map(eventName => { });
return <><span className="outputEvent">{eventName}</span> </>;
})}
</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"; import { parse as parseLabel } from "../statecharts/label_parser";
@ -23,6 +23,7 @@ export function TextDialog(props: {setModal: Dispatch<SetStateAction<ReactElemen
try { try {
const parsed = parseLabel(text); const parsed = parseLabel(text);
} catch (e) { } catch (e) {
// @ts-ignore
error = e.message; error = e.message;
} }

View file

@ -20,13 +20,16 @@ import { formatTime } from "./util";
import { InsertMode } from "../VisualEditor/VisualEditor"; import { InsertMode } from "../VisualEditor/VisualEditor";
import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo"; import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo";
import { About } from "./About"; import { About } from "./About";
import { Stack } from "@mui/material"; import { usePersistentState } from "@/util/persistent_state";
import { RountangleIcon, PseudoStateIcon, HistoryIcon } from "./Icons";
export type TopPanelProps = { export type TopPanelProps = {
rt?: BigStep, rt?: BigStep,
rtIdx?: number, rtIdx?: number,
time: TimeMode, time: TimeMode,
setTime: Dispatch<SetStateAction<TimeMode>>, setTime: Dispatch<SetStateAction<TimeMode>>,
onUndo: () => void,
onRedo: () => void,
onInit: () => void, onInit: () => void,
onClear: () => void, onClear: () => void,
onRaise: (e: string, p: any) => void, onRaise: (e: string, p: any) => void,
@ -34,44 +37,13 @@ export type TopPanelProps = {
ast: Statechart, ast: Statechart,
mode: InsertMode, mode: InsertMode,
setMode: Dispatch<SetStateAction<InsertMode>>, setMode: Dispatch<SetStateAction<InsertMode>>,
setModal: Dispatch<SetStateAction<ReactElement>>, setModal: Dispatch<SetStateAction<ReactElement|null>>,
} }
function RountangleIcon(props: {kind: string}) { export function TopPanel({rt, rtIdx, time, setTime, onUndo, onRedo, onInit, onClear, onRaise, onBack, ast, mode, setMode, setModal}: TopPanelProps) {
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) {
const [displayTime, setDisplayTime] = useState("0.000"); const [displayTime, setDisplayTime] = useState("0.000");
const [timescale, setTimescale] = useState(1); const [timescale, setTimescale] = useState(1);
const [showKeys, setShowKeys] = useState(true); const [showKeys, setShowKeys] = usePersistentState("shortcuts", true);
const KeyInfo = showKeys ? KeyInfoVisible : KeyInfoHidden; const KeyInfo = showKeys ? KeyInfoVisible : KeyInfoHidden;
@ -92,9 +64,14 @@ export function TopPanel({rt, rtIdx, time, setTime, onInit, onClear, onRaise, on
onClear(); onClear();
} }
if (e.key === "Tab") { if (e.key === "Tab") {
e.preventDefault(); if (rtIdx === undefined) {
onInit();
}
else {
onSkip(); onSkip();
} }
e.preventDefault();
}
if (e.key === "s") { if (e.key === "s") {
e.preventDefault(); e.preventDefault();
onSlower(); onSlower();
@ -112,6 +89,17 @@ export function TopPanel({rt, rtIdx, time, setTime, onInit, onClear, onRaise, on
onBack(); onBack();
} }
} }
else {
// ctrl is down
if (e.key === "z") {
e.preventDefault();
onUndo();
}
if (e.key === "Z") {
e.preventDefault();
onRedo();
}
}
}; };
window.addEventListener("keydown", onKeyDown); window.addEventListener("keydown", onKeyDown);
return () => { return () => {
@ -119,15 +107,6 @@ export function TopPanel({rt, rtIdx, time, setTime, onInit, onClear, onRaise, on
}; };
}, [time, onInit, timescale]); }, [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() { function updateDisplayedTime() {
const now = Math.round(performance.now()); const now = Math.round(performance.now());
const timeMs = getSimTime(time, now); const timeMs = getSimTime(time, now);
@ -214,10 +193,10 @@ export function TopPanel({rt, rtIdx, time, setTime, onInit, onClear, onRaise, on
{/* undo / redo */} {/* undo / redo */}
<div className="toolbarGroup"> <div className="toolbarGroup">
<KeyInfo keyInfo={<><kbd>Ctrl</kbd>+<kbd>Z</kbd></>}> <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 keyInfo={<><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd></>}> <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> </KeyInfo>
&emsp; &emsp;
</div> </div>
@ -233,7 +212,7 @@ export function TopPanel({rt, rtIdx, time, setTime, onInit, onClear, onRaise, on
["transition", "transitions", <TrendingFlatIcon fontSize="small"/>, <kbd>T</kbd>], ["transition", "transitions", <TrendingFlatIcon fontSize="small"/>, <kbd>T</kbd>],
["text", "text", <>&nbsp;T&nbsp;</>, <kbd>X</kbd>], ["text", "text", <>&nbsp;T&nbsp;</>, <kbd>X</kbd>],
] as [InsertMode, string, ReactElement, ReactElement][]).map(([m, hint, buttonTxt, keyInfo]) => ] as [InsertMode, string, ReactElement, ReactElement][]).map(([m, hint, buttonTxt, keyInfo]) =>
<KeyInfo keyInfo={keyInfo}> <KeyInfo key={m} keyInfo={keyInfo}>
<button <button
title={"insert "+hint} title={"insert "+hint}
disabled={mode===m} disabled={mode===m}
@ -294,11 +273,16 @@ export function TopPanel({rt, rtIdx, time, setTime, onInit, onClear, onRaise, on
</div> </div>
{/* input events */} {/* input events */}
<div className="toolbarGroup"> {/* <div className="toolbarGroup">
{ast.inputEvents && {ast.inputEvents &&
<> <>
{ast.inputEvents.map(({event, paramName}) => {ast.inputEvents.map(({event, paramName}) =>
<div className="toolbarGroup"><button title={`raise input event '${event}'`} disabled={!rt} onClick={() => { <div key={event+'/'+paramName} className="toolbarGroup">
<button
className="inputEvent"
title={`raise this input event`}
disabled={!rt}
onClick={() => {
// @ts-ignore // @ts-ignore
const param = document.getElementById(`input-${event}-param`)?.value; const param = document.getElementById(`input-${event}-param`)?.value;
let paramParsed; let paramParsed;
@ -316,11 +300,15 @@ export function TopPanel({rt, rtIdx, time, setTime, onInit, onClear, onRaise, on
<BoltIcon fontSize="small"/> <BoltIcon fontSize="small"/>
{event} {event}
</button> </button>
{paramName && <><input id={`input-${event}-param`} style={{width: 20}} placeholder={paramName}/></>} {paramName &&
&nbsp;</div>)} <><input id={`input-${event}-param`} style={{width: 20}} placeholder={paramName}/></>
}
&nbsp;
</div>
)}
</> </>
} }
</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[] }) { export function RectHelper(props: { uid: string, size: Vec2D, selected: string[], highlight: RountanglePart[] }) {
const geomProps = lineGeometryProps(props.size); const geomProps = lineGeometryProps(props.size);
return <> 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) || props.highlight.includes(side)) && <line className={""
+ (props.selected.includes(side) ? " selected" : "") + (props.selected.includes(side) ? " selected" : "")
+ (props.highlight.includes(side) ? " highlight" : "")} + (props.highlight.includes(side) ? " highlight" : "")}
{...ps} data-uid={props.uid} data-parts={side}/> {...ps} data-uid={props.uid} data-parts={side}/>
} }
<line className="helper" {...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. */} {/* 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 <circle

View file

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

View file

@ -12,9 +12,6 @@
visibility: hidden !important; visibility: hidden !important;
} }
.svgCanvas.active {
/* background-color: rgb(255, 140, 0, 0.2); */
}
.svgCanvas text { .svgCanvas text {
user-select: none; user-select: none;
@ -129,7 +126,7 @@ line.selected, circle.selected {
text.helper { text.helper {
fill: rgba(0,0,0,0); fill: rgba(0,0,0,0);
stroke: rgba(0,0,0,0); stroke: rgba(0,0,0,0);
stroke-width: 16px; stroke-width: 6px;
} }
text.helper:hover { text.helper:hover {
stroke: blue; stroke: blue;
@ -162,8 +159,10 @@ text.helper:hover {
stroke: var(--error-color); stroke: var(--error-color);
} }
.arrow.fired { .arrow.fired {
stroke: rgb(192, 125, 0); stroke: rgb(231, 111, 0);
stroke-width: 3px; stroke-width: 3px;
filter: drop-shadow( 0px 0px 5px rgb(186, 5, 195));
} }
text.error, tspan.error { text.error, tspan.error {

View file

@ -44,11 +44,6 @@ type HistorySelectable = {
type Selectable = RountangleSelectable | ArrowSelectable | TextSelectable | HistorySelectable; type Selectable = RountangleSelectable | ArrowSelectable | TextSelectable | HistorySelectable;
type Selection = Selectable[]; type Selection = Selectable[];
type HistoryState = {
current: VisualEditorState,
history: VisualEditorState[],
future: VisualEditorState[],
}
export const sides: [RountanglePart, (r:Rect2D)=>Line2D][] = [ export const sides: [RountanglePart, (r:Rect2D)=>Line2D][] = [
["left", getLeftSide], ["left", getLeftSide],
@ -60,6 +55,8 @@ export const sides: [RountanglePart, (r:Rect2D)=>Line2D][] = [
export type InsertMode = "and"|"or"|"pseudo"|"shallow"|"deep"|"transition"|"text"; export type InsertMode = "and"|"or"|"pseudo"|"shallow"|"deep"|"transition"|"text";
type VisualEditorProps = { type VisualEditorProps = {
state: VisualEditorState,
setState: Dispatch<(v:VisualEditorState) => VisualEditorState>,
ast: Statechart, ast: Statechart,
setAST: Dispatch<SetStateAction<Statechart>>, setAST: Dispatch<SetStateAction<Statechart>>,
rt: BigStep|undefined, rt: BigStep|undefined,
@ -69,59 +66,10 @@ type VisualEditorProps = {
highlightActive: Set<string>, highlightActive: Set<string>,
highlightTransitions: string[], highlightTransitions: string[],
setModal: Dispatch<SetStateAction<ReactElement|null>>, setModal: Dispatch<SetStateAction<ReactElement|null>>,
makeCheckPoint: () => void;
}; };
export function VisualEditor({ast, setAST, rt, errors, setErrors, mode, highlightActive, highlightTransitions, setModal}: VisualEditorProps) { export function VisualEditor({state, setState, ast, setAST, rt, errors, setErrors, mode, highlightActive, highlightTransitions, setModal, makeCheckPoint}: 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),
}
});
}
const [dragging, setDragging] = useState<DraggingState>(null); const [dragging, setDragging] = useState<DraggingState>(null);
@ -136,7 +84,6 @@ export function VisualEditor({ast, setAST, rt, errors, setErrors, mode, highligh
useEffect(() => { useEffect(() => {
try { try {
const compressedState = window.location.hash.slice(1); const compressedState = window.location.hash.slice(1);
console.log('get old state');
const ds = new DecompressionStream("deflate"); const ds = new DecompressionStream("deflate");
const writer = ds.writable.getWriter(); const writer = ds.writable.getWriter();
writer.write(Uint8Array.fromBase64(compressedState)).catch(e => { 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 => { new Response(ds.readable).arrayBuffer().then(decompressedBuffer => {
try { try {
console.log('recovering state');
const recoveredState = JSON.parse(new TextDecoder().decode(decompressedBuffer)); const recoveredState = JSON.parse(new TextDecoder().decode(decompressedBuffer));
setState(recoveredState); setState(() => recoveredState);
} }
catch (e) { catch (e) {
console.error("could not recover state:", 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 // todo: cancel this promise handler when concurrently starting another compression job
new Response(cs.readable).arrayBuffer().then(compressedStateBuffer => { new Response(cs.readable).arrayBuffer().then(compressedStateBuffer => {
const compressedStateString = new Uint8Array(compressedStateBuffer).toBase64(); const compressedStateString = new Uint8Array(compressedStateBuffer).toBase64();
console.log(compressedStateString.length, serializedState.length);
window.location.hash = "#"+compressedStateString; window.location.hash = "#"+compressedStateString;
}); });
}, 100); }, 100);
@ -204,7 +149,7 @@ export function VisualEditor({ast, setAST, rt, errors, setErrors, mode, highligh
const currentPointer = getCurrentPointer(e); const currentPointer = getCurrentPointer(e);
if (e.button === 2) { if (e.button === 2) {
checkPoint(); makeCheckPoint();
// ignore selection, middle mouse button always inserts // ignore selection, middle mouse button always inserts
setState(state => { setState(state => {
const newID = state.nextID.toString(); 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 uid = e.target?.dataset.uid;
const parts: string[] = e.target?.dataset.parts?.split(' ').filter((p:string) => p!=="") || []; const parts: string[] = e.target?.dataset.parts?.split(' ').filter((p:string) => p!=="") || [];
if (uid && parts.length > 0) { 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 // 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; let allPartsInSelection = true;
@ -473,7 +418,7 @@ export function VisualEditor({ast, setAST, rt, errors, setErrors, mode, highligh
if (e.key === "Delete") { if (e.key === "Delete") {
// delete selection // delete selection
if (selection.length > 0) { if (selection.length > 0) {
checkPoint(); makeCheckPoint();
deleteShapes(selection); deleteShapes(selection);
} }
} }
@ -508,14 +453,6 @@ export function VisualEditor({ast, setAST, rt, errors, setErrors, mode, highligh
// }); // });
// } // }
if (e.ctrlKey) { if (e.ctrlKey) {
if (e.key === "z") {
e.preventDefault();
undo();
}
if (e.key === "Z") {
e.preventDefault();
redo();
}
if (e.key === "a") { if (e.key === "a") {
e.preventDefault(); e.preventDefault();
setDragging(null); setDragging(null);
@ -778,9 +715,11 @@ export function VisualEditor({ast, setAST, rt, errors, setErrors, mode, highligh
</>)} </>)}
{state.history.map(history => <> {state.history.map(history => <>
<HistorySVG {...history} <HistorySVG
key={history.uid}
selected={Boolean(selection.find(h => h.uid === history.uid))} selected={Boolean(selection.find(h => h.uid === history.uid))}
highlight={Boolean(historyToHighlight[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 => { {state.texts.map(txt => {
return <TextSVG return <TextSVG
key={txt.uid}
error={errors.find(({shapeUid}) => txt.uid === shapeUid)} error={errors.find(({shapeUid}) => txt.uid === shapeUid)}
text={txt} text={txt}
selected={Boolean(selection.find(s => s.uid === txt.uid)?.parts?.length)} 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, "strict": true,
"skipLibCheck": true, "skipLibCheck": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }