better UI
This commit is contained in:
parent
44fb8726ca
commit
1f9379df7f
16 changed files with 440 additions and 248 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
179
src/App/App.tsx
179
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<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>
|
||||
|
|
|
|||
|
|
@ -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}/> "Welcome to StateBuddy, buddy!"</b></>);
|
||||
const [greeting, setGreeting] = useState(<><b><img src={head} style={{transform: "scaleX(-1)"}}/> "Welcome to StateBuddy, buddy!"</b></>);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
setGreeting("");
|
||||
setGreeting(<></>);
|
||||
}, 2000);
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
29
src/App/Icons.tsx
Normal file
29
src/App/Icons.tsx
Normal 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>;
|
||||
}
|
||||
|
|
@ -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)}
|
||||
 
|
||||
<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
|
||||
));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> entry / <ShowAction action={action}/></div>
|
||||
)
|
||||
|
|
@ -48,23 +58,56 @@ export function ShowAST(props: {root: ConcreteState | PseudoState, transitions:
|
|||
props.root.exitActions.map(action =>
|
||||
<div> 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 => <> <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}/></>
|
||||
}
|
||||
|
||||
</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> </>;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
 
|
||||
</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", <> T </>, <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}/></>}
|
||||
</div>)}
|
||||
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
</div>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -36,7 +36,5 @@ export function RountangleSVG(props: { rountangle: Rountangle; selected: string[
|
|||
<RectHelper uid={uid} size={minSize}
|
||||
selected={props.selected}
|
||||
highlight={props.highlight} />
|
||||
|
||||
|
||||
</g>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
38
src/util/persistent_state.ts
Normal file
38
src/util/persistent_state.ts
Normal 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];
|
||||
}
|
||||
|
|
@ -8,7 +8,6 @@
|
|||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue