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 {
|
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;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
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 { 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,56 +207,83 @@ export function App() {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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 sx={{height:'100%'}}>
|
||||||
<Stack direction="row" sx={{
|
<Stack direction="row" sx={{flexGrow:1, overflow: "auto"}}>
|
||||||
overflow: 'auto',
|
|
||||||
}}>
|
|
||||||
|
|
||||||
{/* main */}
|
{/* Left: top bar and main editor */}
|
||||||
<Box sx={{
|
<Box sx={{flexGrow:1, overflow: "auto"}}>
|
||||||
flexGrow:1,
|
<Stack sx={{height:'100%'}}>
|
||||||
overflow:'auto',
|
{/* Top bar */}
|
||||||
}}>
|
<Box sx={{
|
||||||
<VisualEditor {...{ast, setAST, rt: rt.at(rtIdx!), setRT, errors, setErrors, mode, highlightActive, highlightTransitions, setModal}}/>
|
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>
|
</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',
|
overflowY: "auto",
|
||||||
overflowY: "auto",
|
overflowX: "visible",
|
||||||
overflowX: "visible",
|
maxWidth: 'min(300px, 30vw)',
|
||||||
maxWidth: 'min(300px, 30vw)',
|
}}>
|
||||||
}}>
|
<Stack sx={{height:'100%'}}>
|
||||||
<ShowAST {...{...ast, rt: rt.at(rtIdx!), highlightActive}}/>
|
<Box className="onTop" sx={{flex: '0 0 content', backgroundColor: ''}}>
|
||||||
<ShowOutputEvents outputEvents={ast.outputEvents}/>
|
<details open={showStateTree}
|
||||||
<br/>
|
onToggle={e => setShowStateTree(e.newState === "open")}>
|
||||||
<div ref={refRightSideBar}>
|
<summary>state tree</summary>
|
||||||
<RTHistory {...{ast, rt, rtIdx, setTime, setRTIdx, refRightSideBar}}/>
|
<ul>
|
||||||
</div>
|
<ShowAST {...{...ast, rt: rt.at(rtIdx!), highlightActive}}/>
|
||||||
</Box>
|
</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>
|
</Stack>
|
||||||
<Box sx={{
|
|
||||||
flex: '0 0 content',
|
{/* Bottom panel */}
|
||||||
}}>
|
<Box sx={{flex: '0 0 content'}}>
|
||||||
<BottomPanel {...{errors}}/>
|
<BottomPanel {...{errors}}/>
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
|
||||||
|
|
@ -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}/> "Welcome to StateBuddy, buddy!"</b></>);
|
const [greeting, setGreeting] = useState(<><b><img src={head} style={{transform: "scaleX(-1)"}}/> "Welcome to StateBuddy, buddy!"</b></>);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setGreeting("");
|
setGreeting(<></>);
|
||||||
}, 2000);
|
}, 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) => <>
|
{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)}
|
||||||
|
 
|
||||||
|
<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
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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> entry / <ShowAction action={action}/></div>
|
<div> 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> exit / <ShowAction action={action}/></div>
|
<div> 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 => <> <ShowTransition transition={transition}/><br/></>)
|
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>}) {
|
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>;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,8 +64,13 @@ export function TopPanel({rt, rtIdx, time, setTime, onInit, onClear, onRaise, on
|
||||||
onClear();
|
onClear();
|
||||||
}
|
}
|
||||||
if (e.key === "Tab") {
|
if (e.key === "Tab") {
|
||||||
|
if (rtIdx === undefined) {
|
||||||
|
onInit();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
onSkip();
|
||||||
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onSkip();
|
|
||||||
}
|
}
|
||||||
if (e.key === "s") {
|
if (e.key === "s") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -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>
|
||||||
 
|
 
|
||||||
</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", <> T </>, <kbd>X</kbd>],
|
["text", "text", <> T </>, <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,33 +273,42 @@ 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">
|
||||||
// @ts-ignore
|
<button
|
||||||
const param = document.getElementById(`input-${event}-param`)?.value;
|
className="inputEvent"
|
||||||
let paramParsed;
|
title={`raise this input event`}
|
||||||
try {
|
disabled={!rt}
|
||||||
if (param) {
|
onClick={() => {
|
||||||
paramParsed = JSON.parse(param); // may throw
|
// @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) {
|
</div>
|
||||||
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[] }) {
|
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
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
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,
|
"strict": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue