editable connections sc <-> plant
This commit is contained in:
parent
e27d3c4c88
commit
8ac5a730cc
28 changed files with 1191 additions and 1016 deletions
417
src/App/App.tsx
417
src/App/App.tsx
|
|
@ -1,11 +1,10 @@
|
|||
import "../index.css";
|
||||
import "./App.css";
|
||||
|
||||
import { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Dispatch, ReactElement, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { emptyState } from "@/statecharts/concrete_syntax";
|
||||
import { detectConnections } from "@/statecharts/detect_connections";
|
||||
import { Conns, coupledExecution, EventDestination, exposeStatechartInputs, statechartExecution } from "@/statecharts/timed_reactive";
|
||||
import { Conns, coupledExecution, EventDestination, statechartExecution, TimedReactive } from "@/statecharts/timed_reactive";
|
||||
import { RuntimeError } from "../statecharts/interpreter";
|
||||
import { parseStatechart } from "../statecharts/parser";
|
||||
import { BigStep, RaisedEvent } from "../statecharts/runtime_types";
|
||||
|
|
@ -13,17 +12,17 @@ import { getSimTime, getWallClkDelay, TimeMode } from "../statecharts/time";
|
|||
import { BottomPanel } from "./BottomPanel";
|
||||
import { usePersistentState } from "./persistent_state";
|
||||
import { PersistentDetails } from "./PersistentDetails";
|
||||
import { DummyPlant } from "./Plant/Dummy/Dummy";
|
||||
import { MicrowavePlant } from "./Plant/Microwave/Microwave";
|
||||
import { autoConnect, exposePlantInputs, Plant } from "./Plant/Plant";
|
||||
import { dummyPlant } from "./Plant/Dummy/Dummy";
|
||||
import { microwavePlant } from "./Plant/Microwave/Microwave";
|
||||
import { Plant } from "./Plant/Plant";
|
||||
import { RTHistory } from "./RTHistory";
|
||||
import { ShowAST, ShowInputEvents, ShowInternalEvents, ShowOutputEvents } from "./ShowAST";
|
||||
import { TopPanel } from "./TopPanel/TopPanel";
|
||||
import { getKeyHandler } from "./VisualEditor/shortcut_handler";
|
||||
import { InsertMode, VisualEditor, VisualEditorState } from "./VisualEditor/VisualEditor";
|
||||
import { addV2D, rotateLine90CCW, rotateLine90CW, rotatePoint90CCW, rotatePoint90CW, rotateRect90CCW, rotateRect90CW, scaleV2D, subtractV2D, Vec2D } from "@/util/geometry";
|
||||
import { HISTORY_RADIUS } from "./parameters";
|
||||
import { DigitalWatchPlant } from "./Plant/DigitalWatch/DigitalWatch";
|
||||
import { VisualEditor, VisualEditorState } from "./VisualEditor/VisualEditor";
|
||||
import { digitalWatchPlant } from "./Plant/DigitalWatch/DigitalWatch";
|
||||
import { useEditor as useEditor } from "./useEditor";
|
||||
import { InsertMode } from "./TopPanel/InsertModes";
|
||||
import { Statechart } from "@/statecharts/abstract_syntax";
|
||||
|
||||
export type EditHistory = {
|
||||
current: VisualEditorState,
|
||||
|
|
@ -32,9 +31,9 @@ export type EditHistory = {
|
|||
}
|
||||
|
||||
const plants: [string, Plant<any>][] = [
|
||||
["dummy", DummyPlant],
|
||||
["microwave", MicrowavePlant],
|
||||
["digital watch", DigitalWatchPlant],
|
||||
["dummy", dummyPlant],
|
||||
["microwave", microwavePlant],
|
||||
["digital watch", digitalWatchPlant],
|
||||
]
|
||||
|
||||
export type TraceItemError = {
|
||||
|
|
@ -58,25 +57,8 @@ export type TraceState = {
|
|||
idx: number,
|
||||
}; // <-- null if there is no trace
|
||||
|
||||
// function getPlantState<T>(plant: Plant<T>, trace: TraceItem[], idx: number): T | null {
|
||||
// if (idx === -1) {
|
||||
// return plant.initial;
|
||||
// }
|
||||
// let plantState = getPlantState(plant, trace, idx-1);
|
||||
// if (plantState !== null) {
|
||||
// const currentConfig = trace[idx];
|
||||
// if (currentConfig.kind === "bigstep") {
|
||||
// for (const o of currentConfig.outputEvents) {
|
||||
// plantState = plant.reduce(o, plantState);
|
||||
// }
|
||||
// }
|
||||
// return plantState;
|
||||
// }
|
||||
// return null;
|
||||
// }
|
||||
|
||||
export function App() {
|
||||
const [insertMode, setInsertMode] = useState<InsertMode>("and");
|
||||
const [insertMode, setInsertMode] = usePersistentState<InsertMode>("insertMode", "and");
|
||||
const [editHistory, setEditHistory] = useState<EditHistory|null>(null);
|
||||
const [trace, setTrace] = useState<TraceState|null>(null);
|
||||
const [time, setTime] = useState<TimeMode>({kind: "paused", simtime: 0});
|
||||
|
|
@ -86,6 +68,10 @@ export function App() {
|
|||
const [zoom, setZoom] = usePersistentState("zoom", 1);
|
||||
const [showKeys, setShowKeys] = usePersistentState("shortcuts", true);
|
||||
|
||||
const [autoScroll, setAutoScroll] = usePersistentState("autoScroll", true);
|
||||
const [autoConnect, setAutoConnect] = usePersistentState("autoConnect", true);
|
||||
const [plantConns, setPlantConns] = usePersistentState<Conns>("plantConns", {});
|
||||
|
||||
const plant = plants.find(([pn, p]) => pn === plantName)![1];
|
||||
|
||||
const editorState = editHistory && editHistory.current;
|
||||
|
|
@ -93,63 +79,6 @@ export function App() {
|
|||
setEditHistory(historyState => historyState && ({...historyState, current: cb(historyState.current)}));
|
||||
}, [setEditHistory]);
|
||||
|
||||
// recover editor state from URL - we need an effect here because decompression is asynchronous
|
||||
useEffect(() => {
|
||||
console.log('recovering state...');
|
||||
const compressedState = window.location.hash.slice(1);
|
||||
if (compressedState.length === 0) {
|
||||
// empty URL hash
|
||||
console.log("no state to recover");
|
||||
setEditHistory(() => ({current: emptyState, history: [], future: []}));
|
||||
return;
|
||||
}
|
||||
let compressedBuffer;
|
||||
try {
|
||||
compressedBuffer = Uint8Array.fromBase64(compressedState); // may throw
|
||||
} catch (e) {
|
||||
// probably invalid base64
|
||||
console.error("failed to recover state:", e);
|
||||
setEditHistory(() => ({current: emptyState, history: [], future: []}));
|
||||
return;
|
||||
}
|
||||
const ds = new DecompressionStream("deflate");
|
||||
const writer = ds.writable.getWriter();
|
||||
writer.write(compressedBuffer).catch(() => {}); // any promise rejections will be detected when we try to read
|
||||
writer.close().catch(() => {});
|
||||
new Response(ds.readable).arrayBuffer()
|
||||
.then(decompressedBuffer => {
|
||||
const recoveredState = JSON.parse(new TextDecoder().decode(decompressedBuffer));
|
||||
setEditHistory(() => ({current: recoveredState, history: [], future: []}));
|
||||
})
|
||||
.catch(e => {
|
||||
// any other error: invalid JSON, or decompression failed.
|
||||
console.error("failed to recover state:", e);
|
||||
setEditHistory({current: emptyState, history: [], future: []});
|
||||
});
|
||||
}, []);
|
||||
|
||||
// save editor state in URL
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (editorState === null) {
|
||||
window.location.hash = "#";
|
||||
return;
|
||||
}
|
||||
const serializedState = JSON.stringify(editorState);
|
||||
const stateBuffer = new TextEncoder().encode(serializedState);
|
||||
const cs = new CompressionStream("deflate");
|
||||
const writer = cs.writable.getWriter();
|
||||
writer.write(stateBuffer);
|
||||
writer.close();
|
||||
// todo: cancel this promise handler when concurrently starting another compression job
|
||||
new Response(cs.readable).arrayBuffer().then(compressedStateBuffer => {
|
||||
const compressedStateString = new Uint8Array(compressedStateBuffer).toBase64();
|
||||
window.location.hash = "#"+compressedStateString;
|
||||
});
|
||||
}, 100);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [editorState]);
|
||||
|
||||
const refRightSideBar = useRef<HTMLDivElement>(null);
|
||||
|
||||
// parse concrete syntax always:
|
||||
|
|
@ -166,177 +95,42 @@ export function App() {
|
|||
}] : [],
|
||||
]
|
||||
|
||||
// append editor state to undo history
|
||||
const makeCheckPoint = useCallback(() => {
|
||||
setEditHistory(historyState => historyState && ({
|
||||
...historyState,
|
||||
history: [...historyState.history, historyState.current],
|
||||
future: [],
|
||||
}));
|
||||
}, [setEditHistory]);
|
||||
const onUndo = useCallback(() => {
|
||||
setEditHistory(historyState => {
|
||||
if (historyState === null) return null;
|
||||
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],
|
||||
}
|
||||
})
|
||||
}, [setEditHistory]);
|
||||
const onRedo = useCallback(() => {
|
||||
setEditHistory(historyState => {
|
||||
if (historyState === null) return null;
|
||||
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),
|
||||
}
|
||||
});
|
||||
}, [setEditHistory]);
|
||||
const onRotate = useCallback((direction: "ccw" | "cw") => {
|
||||
makeCheckPoint();
|
||||
setEditHistory(historyState => {
|
||||
if (historyState === null) return null;
|
||||
|
||||
const selection = historyState.current.selection;
|
||||
|
||||
if (selection.length === 0) {
|
||||
return historyState;
|
||||
}
|
||||
|
||||
// determine bounding box... in a convoluted manner
|
||||
let minX = -Infinity, minY = -Infinity, maxX = Infinity, maxY = Infinity;
|
||||
|
||||
function addPointToBBox({x,y}: Vec2D) {
|
||||
minX = Math.max(minX, x);
|
||||
minY = Math.max(minY, y);
|
||||
maxX = Math.min(maxX, x);
|
||||
maxY = Math.min(maxY, y);
|
||||
}
|
||||
|
||||
for (const rt of historyState.current.rountangles) {
|
||||
if (selection.some(s => s.uid === rt.uid)) {
|
||||
addPointToBBox(rt.topLeft);
|
||||
addPointToBBox(addV2D(rt.topLeft, rt.size));
|
||||
}
|
||||
}
|
||||
for (const d of historyState.current.diamonds) {
|
||||
if (selection.some(s => s.uid === d.uid)) {
|
||||
addPointToBBox(d.topLeft);
|
||||
addPointToBBox(addV2D(d.topLeft, d.size));
|
||||
}
|
||||
}
|
||||
for (const arr of historyState.current.arrows) {
|
||||
if (selection.some(s => s.uid === arr.uid)) {
|
||||
addPointToBBox(arr.start);
|
||||
addPointToBBox(arr.end);
|
||||
}
|
||||
}
|
||||
for (const txt of historyState.current.texts) {
|
||||
if (selection.some(s => s.uid === txt.uid)) {
|
||||
addPointToBBox(txt.topLeft);
|
||||
}
|
||||
}
|
||||
const historySize = {x: HISTORY_RADIUS, y: HISTORY_RADIUS};
|
||||
for (const h of historyState.current.history) {
|
||||
if (selection.some(s => s.uid === h.uid)) {
|
||||
addPointToBBox(h.topLeft);
|
||||
addPointToBBox(addV2D(h.topLeft, scaleV2D(historySize, 2)));
|
||||
}
|
||||
}
|
||||
|
||||
const center: Vec2D = {
|
||||
x: (minX + maxX) / 2,
|
||||
y: (minY + maxY) / 2,
|
||||
};
|
||||
|
||||
const mapIfSelected = (shape: {uid: string}, cb: (shape:any)=>any) => {
|
||||
if (selection.some(s => s.uid === shape.uid)) {
|
||||
return cb(shape);
|
||||
}
|
||||
else {
|
||||
return shape;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...historyState,
|
||||
current: {
|
||||
...historyState.current,
|
||||
rountangles: historyState.current.rountangles.map(rt => mapIfSelected(rt, rt => {
|
||||
return {
|
||||
...rt,
|
||||
...(direction === "ccw"
|
||||
? rotateRect90CCW(rt, center)
|
||||
: rotateRect90CW(rt, center)),
|
||||
}
|
||||
})),
|
||||
arrows: historyState.current.arrows.map(arr => mapIfSelected(arr, arr => {
|
||||
return {
|
||||
...arr,
|
||||
...(direction === "ccw"
|
||||
? rotateLine90CCW(arr, center)
|
||||
: rotateLine90CW(arr, center)),
|
||||
};
|
||||
})),
|
||||
diamonds: historyState.current.diamonds.map(d => mapIfSelected(d, d => {
|
||||
return {
|
||||
...d,
|
||||
...(direction === "ccw"
|
||||
? rotateRect90CCW(d, center)
|
||||
: rotateRect90CW(d, center)),
|
||||
};
|
||||
})),
|
||||
texts: historyState.current.texts.map(txt => mapIfSelected(txt, txt => {
|
||||
return {
|
||||
...txt,
|
||||
topLeft: (direction === "ccw"
|
||||
? rotatePoint90CCW(txt.topLeft, center)
|
||||
: rotatePoint90CW(txt.topLeft, center)),
|
||||
};
|
||||
})),
|
||||
history: historyState.current.history.map(h => mapIfSelected(h, h => {
|
||||
return {
|
||||
...h,
|
||||
topLeft: (direction === "ccw"
|
||||
? subtractV2D(rotatePoint90CCW(addV2D(h.topLeft, historySize), center), historySize)
|
||||
: subtractV2D(rotatePoint90CW(addV2D(h.topLeft, historySize), center), historySize)
|
||||
),
|
||||
};
|
||||
})),
|
||||
},
|
||||
}
|
||||
})
|
||||
}, [setEditHistory]);
|
||||
const {makeCheckPoint, onRedo, onUndo, onRotate} = useEditor(editorState, setEditHistory);
|
||||
|
||||
const scrollDownSidebar = useCallback(() => {
|
||||
if (refRightSideBar.current) {
|
||||
if (autoScroll && refRightSideBar.current) {
|
||||
const el = refRightSideBar.current;
|
||||
// hack: we want to scroll to the new element, but we have to wait until it is rendered...
|
||||
setTimeout(() => {
|
||||
el.scrollIntoView({block: "end", behavior: "smooth"});
|
||||
}, 50);
|
||||
}
|
||||
}, [refRightSideBar.current]);
|
||||
}, [refRightSideBar.current, autoScroll]);
|
||||
|
||||
const plantConns = ast && ({
|
||||
inputEvents: {
|
||||
...exposeStatechartInputs(ast, "sc", (eventName: string) => "DEBUG_"+eventName),
|
||||
...exposePlantInputs(plant, "plant", (eventName: string) => "PLANT_UI_"+eventName),
|
||||
},
|
||||
outputEvents: autoConnect(ast, "sc", plant, "plant"),
|
||||
}) as Conns;
|
||||
// const plantConns = ast && ({
|
||||
// inputEvents: {
|
||||
// // all SC inputs are directly triggerable from outside
|
||||
// ...exposeStatechartInputs(ast, "sc", (eventName: string) => "debug."+eventName),
|
||||
|
||||
// ...Object.fromEntries(plant.uiEvents.map(e => {
|
||||
// const globalName = "PLANT_UI_"+e.event;
|
||||
// if (plant.inputEvents.some(f => f.event === e.event)) {
|
||||
// return [globalName, {kind: "model", model: 'plant', eventName: e.event}];
|
||||
// }
|
||||
// if (ast.inputEvents.some(f => f.event === e.event)) {
|
||||
// return [globalName, {kind: "model", model: 'sc', eventName: e.event}];
|
||||
// }
|
||||
// }).filter(entry => entry !== undefined)),
|
||||
// },
|
||||
// outputEvents: {}, //autoConnect(ast, "sc", plant, "plant"),
|
||||
// }) as Conns;
|
||||
const cE = useMemo(() => ast && coupledExecution({
|
||||
sc: statechartExecution(ast),
|
||||
plant: plant.execution,
|
||||
}, plantConns!), [ast]);
|
||||
}, {
|
||||
...plantConns,
|
||||
...Object.fromEntries(ast.inputEvents.map(({event}) => ["debug."+event, ['sc',event] as [string,string]])),
|
||||
}), [ast]);
|
||||
|
||||
const onInit = useCallback(() => {
|
||||
if (cE === null) return;
|
||||
|
|
@ -448,7 +242,7 @@ export function App() {
|
|||
scrollDownSidebar();
|
||||
}
|
||||
|
||||
function onBack() {
|
||||
const onBack = useCallback(() => {
|
||||
if (trace !== null) {
|
||||
setTime(() => {
|
||||
if (trace !== null) {
|
||||
|
|
@ -464,22 +258,7 @@ export function App() {
|
|||
idx: trace.idx-1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
console.log("Welcome to StateBuddy!");
|
||||
() => {
|
||||
console.log("Goodbye!");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = getKeyHandler(setInsertMode);
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, []);
|
||||
}, [trace]);
|
||||
|
||||
const currentBigStep = currentTraceItem && currentTraceItem.kind === "bigstep" && currentTraceItem;
|
||||
const highlightActive = (currentBigStep && currentBigStep.state.sc.mode) || new Set();
|
||||
|
|
@ -490,6 +269,12 @@ export function App() {
|
|||
|
||||
const speed = time.kind === "paused" ? 0 : time.scale;
|
||||
|
||||
const plantState = currentBigStep && currentBigStep.state.plant || plant.execution.initial()[1];
|
||||
|
||||
useEffect(() => {
|
||||
ast && autoConnect && autoDetectConns(ast, plant, setPlantConns);
|
||||
}, [ast, plant, autoConnect]);
|
||||
|
||||
return <>
|
||||
|
||||
{/* Modal dialog */}
|
||||
|
|
@ -523,7 +308,7 @@ export function App() {
|
|||
{/* Editor */}
|
||||
<div style={{flexGrow: 1, overflow: "auto"}}>
|
||||
{editorState && conns && syntaxErrors &&
|
||||
<VisualEditor {...{state: editorState, setState: setEditorState, conns, trace, setTrace, syntaxErrors: allErrors, insertMode, highlightActive, highlightTransitions, setModal, makeCheckPoint, zoom}}/>}
|
||||
<VisualEditor {...{state: editorState, setState: setEditorState, conns, trace, syntaxErrors: allErrors, insertMode, highlightActive, highlightTransitions, setModal, makeCheckPoint, zoom}}/>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -550,7 +335,7 @@ export function App() {
|
|||
<summary>input events</summary>
|
||||
{ast && <ShowInputEvents
|
||||
inputEvents={ast.inputEvents}
|
||||
onRaise={(e,p) => onRaise("DEBUG_"+e,p)}
|
||||
onRaise={(e,p) => onRaise("debug."+e,p)}
|
||||
disabled={trace===null || trace.trace[trace.idx].kind === "error"}
|
||||
showKeys={showKeys}/>}
|
||||
</PersistentDetails>
|
||||
|
|
@ -572,13 +357,25 @@ export function App() {
|
|||
<option>{plantName}</option>
|
||||
)}
|
||||
</select>
|
||||
{plantConns && <ShowConns {...plantConns} />}
|
||||
{currentBigStep && <plant.render state={currentBigStep.state.plant} speed={speed}
|
||||
raiseInput={e => onRaise("PLANT_UI_"+e.name, e.param)}
|
||||
{/* Render plant */}
|
||||
{<plant.render state={plantState} speed={speed}
|
||||
raiseUIEvent={e => onRaise("plant.ui."+e.name, e.param)}
|
||||
/>}
|
||||
</PersistentDetails>
|
||||
<PersistentDetails localStorageKey="showConnEditor" initiallyOpen={false}>
|
||||
<summary>connections</summary>
|
||||
<button title="auto-connect (name-based)" className={autoConnect?"active":""}
|
||||
onClick={() => setAutoConnect(c => !c)}>
|
||||
<AutoAwesomeIcon fontSize="small"/>
|
||||
</button>
|
||||
{ast && ConnEditor(ast, plant, plantConns, setPlantConns)}
|
||||
</PersistentDetails>
|
||||
|
||||
<details open={showExecutionTrace} onToggle={e => setShowExecutionTrace(e.newState === "open")}><summary>execution trace</summary>
|
||||
<input id="checkbox-show-plant-items" type="checkbox" checked={showPlantTrace} onChange={e => setShowPlantTrace(e.target.checked)}/><label htmlFor="checkbox-show-plant-items">show plant steps</label>
|
||||
<input id="checkbox-show-plant-items" type="checkbox" checked={showPlantTrace} onChange={e => setShowPlantTrace(e.target.checked)}/>
|
||||
<label htmlFor="checkbox-show-plant-items">show plant steps</label>
|
||||
<input id="checkbox-autoscroll" type="checkbox" checked={autoScroll} onChange={e => setAutoScroll(e.target.checked)}/>
|
||||
<label htmlFor="checkbox-autoscroll">auto-scroll</label>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
|
|
@ -622,11 +419,85 @@ function ShowEventDestination(dst: EventDestination) {
|
|||
|
||||
function ShowConns({inputEvents, outputEvents}: Conns) {
|
||||
return <div>
|
||||
<div style={{color: "grey"}}>
|
||||
{/* <div style={{color: "grey"}}>
|
||||
{Object.entries(inputEvents).map(([eventName, destination]) => <div>{eventName} → <ShowEventDestination {...destination}/></div>)}
|
||||
</div>
|
||||
{Object.entries(outputEvents).map(([modelName, mapping]) => <>{Object.entries(mapping).map(([eventName, destination]) => <div>{modelName}.{eventName} → <ShowEventDestination {...destination}/></div>)}</>)}
|
||||
{Object.entries(outputEvents).map(([modelName, mapping]) => <>{Object.entries(mapping).map(([eventName, destination]) => <div>{modelName}.{eventName} → <ShowEventDestination {...destination}/></div>)}</>)} */}
|
||||
</div>;
|
||||
}
|
||||
|
||||
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
|
||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
||||
|
||||
function autoDetectConns(ast: Statechart, plant: Plant<any>, setPlantConns: Dispatch<SetStateAction<Conns>>) {
|
||||
for (const {event: a} of plant.uiEvents) {
|
||||
for (const {event: b} of plant.inputEvents) {
|
||||
if (a === b) {
|
||||
setPlantConns(conns => ({...conns, ['plant.ui.'+a]: ['plant', b]}));
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (const {event: b} of ast.inputEvents) {
|
||||
if (a === b) {
|
||||
setPlantConns(conns => ({...conns, ['plant.ui.'+a]: ['sc', b]}));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const a of ast.outputEvents) {
|
||||
for (const {event: b} of plant.inputEvents) {
|
||||
if (a === b) {
|
||||
setPlantConns(conns => ({...conns, ['sc.'+a]: ['plant', b]}));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const {event: a} of plant.outputEvents) {
|
||||
for (const {event: b} of ast.inputEvents) {
|
||||
if (a === b) {
|
||||
setPlantConns(conns => ({...conns, ['plant.'+a]: ['sc', b]}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ConnEditor(ast: Statechart, plant: Plant<any>, plantConns: Conns, setPlantConns: Dispatch<SetStateAction<Conns>>) {
|
||||
const plantInputs = <>{plant.inputEvents.map(e => <option key={'plant.'+e.event} value={'plant.'+e.event}>plant.{e.event}</option>)}</>
|
||||
const scInputs = <>{ast.inputEvents.map(e => <option key={'sc.'+e.event} value={'sc.'+e.event}>sc.{e.event}</option>)}</>;
|
||||
return <>
|
||||
{/* Plant UI events can go to SC or to Plant */}
|
||||
{plant.uiEvents.map(e => <div>
|
||||
<label htmlFor={`select-dst-plant-ui-${e.event}`}>ui.{e.event} → </label>
|
||||
<select id={`select-dst-plant-ui-${e.event}`}
|
||||
value={plantConns['plant.ui.'+e.event]?.join('.')}
|
||||
onChange={domEvent => setPlantConns(conns => ({...conns, [`plant.ui.${e.event}`]: domEvent.target.value.split('.') as [string,string]}))}>
|
||||
<option key="none" value=""></option>
|
||||
{scInputs}
|
||||
{plantInputs}
|
||||
</select>
|
||||
</div>)}
|
||||
|
||||
{/* SC output events can go to Plant */}
|
||||
{[...ast.outputEvents].map(e => <div>
|
||||
<label htmlFor={`select-dst-sc-${e}`}>sc.{e} → </label>
|
||||
<select id={`select-dst-sc-${e}`}
|
||||
value={plantConns['sc.'+e]?.join('.')}
|
||||
onChange={domEvent => setPlantConns(conns => ({...conns, [`sc.${e}`]: domEvent.target.value.split('.') as [string,string]}))}>
|
||||
<option key="none" value=""></option>
|
||||
{plantInputs}
|
||||
</select>
|
||||
</div>)}
|
||||
|
||||
{/* Plant output events can go to Statechart */}
|
||||
{[...plant.outputEvents.map(e => <div>
|
||||
<label htmlFor={`select-dst-plant-${e.event}`}>plant.{e.event} → </label>
|
||||
<select id={`select-dst-plant-${e.event}`}
|
||||
value={plantConns['plant.'+e.event]?.join('.')}
|
||||
onChange={(domEvent => setPlantConns(conns => ({...conns, [`plant.${e.event}`]: domEvent.target.value.split('.') as [string,string]})))}>
|
||||
<option key="none" value=""></option>
|
||||
{scInputs}
|
||||
</select>
|
||||
</div>)]}
|
||||
</>;
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,5 @@
|
|||
|
||||
.bottom {
|
||||
border-top: 1px lightgrey solid;
|
||||
background-color: lightyellow;
|
||||
/* background-color: rgb(255, 251, 244); */
|
||||
background-color: rgb(255, 249, 235);
|
||||
}
|
||||
|
|
@ -2,10 +2,9 @@ import { useAudioContext } from "@/App/useAudioContext";
|
|||
import { ConcreteSyntax } from "@/App/VisualEditor/VisualEditor";
|
||||
import { detectConnections } from "@/statecharts/detect_connections";
|
||||
import { parseStatechart } from "@/statecharts/parser";
|
||||
import { BigStep, RT_Statechart } from "@/statecharts/runtime_types";
|
||||
import { statechartExecution } from "@/statecharts/timed_reactive";
|
||||
import { RT_Statechart } from "@/statecharts/runtime_types";
|
||||
import { useEffect } from "react";
|
||||
import { Plant, PlantRenderProps } from "../Plant";
|
||||
import { makeStatechartPlant, PlantRenderProps } from "../Plant";
|
||||
|
||||
import dwatchConcreteSyntax from "./model.json";
|
||||
import sndBeep from "./beep.wav";
|
||||
|
|
@ -24,12 +23,12 @@ if (dwatchErrors.length > 0) {
|
|||
|
||||
const twoDigits = (n: number) => ("0"+n.toString()).slice(-2);
|
||||
|
||||
export function DigitalWatch({state, speed, raiseInput}: PlantRenderProps<RT_Statechart>) {
|
||||
const displayingTime = state.mode.has("265");
|
||||
const displayingAlarm = state.mode.has("266");
|
||||
const displayingChrono = state.mode.has("264");
|
||||
export function DigitalWatch({state, speed, raiseUIEvent}: PlantRenderProps<RT_Statechart>) {
|
||||
const displayingTime = state.mode.has("625");
|
||||
const displayingAlarm = state.mode.has("626");
|
||||
const displayingChrono = state.mode.has("624");
|
||||
|
||||
const lightOn = state.mode.has("389");
|
||||
const lightOn = state.mode.has("630");
|
||||
|
||||
const alarm = state.environment.get("alarm");
|
||||
|
||||
|
|
@ -43,9 +42,9 @@ export function DigitalWatch({state, speed, raiseInput}: PlantRenderProps<RT_Sta
|
|||
const cs = state.environment.get("cs");
|
||||
const chs = state.environment.get("chs");
|
||||
|
||||
const hideH = state.mode.has("268");
|
||||
const hideM = state.mode.has("271");
|
||||
const hideS = state.mode.has("267");
|
||||
const hideH = state.mode.has("628");
|
||||
const hideM = state.mode.has("633");
|
||||
const hideS = state.mode.has("627");
|
||||
|
||||
// console.log({cm,cs,chs});
|
||||
|
||||
|
|
@ -64,7 +63,7 @@ export function DigitalWatch({state, speed, raiseInput}: PlantRenderProps<RT_Sta
|
|||
|
||||
preloadAudio(sndBeep);
|
||||
|
||||
const beep = state.mode.has("270");
|
||||
const beep = state.mode.has("632");
|
||||
|
||||
useEffect(() => {
|
||||
if (beep) {
|
||||
|
|
@ -88,20 +87,20 @@ export function DigitalWatch({state, speed, raiseInput}: PlantRenderProps<RT_Sta
|
|||
<text x="111" y="126" dominantBaseline="middle" textAnchor="middle" fontFamily="digital-font" fontSize={28} style={{whiteSpace:'preserve'}}>{hhmmss}</text>
|
||||
|
||||
<rect className="watchButtonHelper" x={0} y={54} width={24} height={24}
|
||||
onMouseDown={() => raiseInput({name: "topLeftPressed"})}
|
||||
onMouseUp={() => raiseInput({name: "topLeftReleased"})}
|
||||
onMouseDown={() => raiseUIEvent({name: "topLeftPressed"})}
|
||||
onMouseUp={() => raiseUIEvent({name: "topLeftReleased"})}
|
||||
/>
|
||||
<rect className="watchButtonHelper" x={198} y={54} width={24} height={24}
|
||||
onMouseDown={() => raiseInput({name: "topRightPressed"})}
|
||||
onMouseUp={() => raiseInput({name: "topRightReleased"})}
|
||||
onMouseDown={() => raiseUIEvent({name: "topRightPressed"})}
|
||||
onMouseUp={() => raiseUIEvent({name: "topRightReleased"})}
|
||||
/>
|
||||
<rect className="watchButtonHelper" x={0} y={154} width={24} height={24}
|
||||
onMouseDown={() => raiseInput({name: "bottomLeftPressed"})}
|
||||
onMouseUp={() => raiseInput({name: "bottomLeftReleased"})}
|
||||
onMouseDown={() => raiseUIEvent({name: "bottomLeftPressed"})}
|
||||
onMouseUp={() => raiseUIEvent({name: "bottomLeftReleased"})}
|
||||
/>
|
||||
<rect className="watchButtonHelper" x={198} y={154} width={24} height={24}
|
||||
onMouseDown={() => raiseInput({name: "bottomRightPressed"})}
|
||||
onMouseUp={() => raiseInput({name: "bottomRightReleased"})}
|
||||
onMouseDown={() => raiseUIEvent({name: "bottomRightPressed"})}
|
||||
onMouseUp={() => raiseUIEvent({name: "bottomRightReleased"})}
|
||||
/>
|
||||
|
||||
{alarm &&
|
||||
|
|
@ -111,46 +110,17 @@ export function DigitalWatch({state, speed, raiseInput}: PlantRenderProps<RT_Sta
|
|||
</>;
|
||||
}
|
||||
|
||||
export const DigitalWatchPlant: Plant<BigStep> = {
|
||||
inputEvents: [
|
||||
{ kind: "event", event: "displayTime" },
|
||||
{ kind: "event", event: "displayChrono" },
|
||||
{ kind: "event", event: "displayAlarm" },
|
||||
{ kind: "event", event: "beginEdit" },
|
||||
{ kind: "event", event: "endEdit" },
|
||||
{ kind: "event", event: "selectNext" },
|
||||
{ kind: "event", event: "incSelection" },
|
||||
{ kind: "event", event: "incTime" },
|
||||
{ kind: "event", event: "incAlarm" },
|
||||
{ kind: "event", event: "incChrono" },
|
||||
{ kind: "event", event: "resetChrono" },
|
||||
{ kind: "event", event: "lightOn"},
|
||||
{ kind: "event", event: "lightOff"},
|
||||
{ kind: "event", event: "setAlarm", paramName: 'alarmOn'},
|
||||
{ kind: "event", event: "beep", paramName: 'beep'},
|
||||
|
||||
// UI events
|
||||
{ kind: "event", event: "topLeftPressed" },
|
||||
{ kind: "event", event: "topRightPressed" },
|
||||
{ kind: "event", event: "bottomRightPressed" },
|
||||
{ kind: "event", event: "bottomLeftPressed" },
|
||||
{ kind: "event", event: "topLeftReleased" },
|
||||
{ kind: "event", event: "topRightReleased" },
|
||||
{ kind: "event", event: "bottomRightReleased" },
|
||||
{ kind: "event", event: "bottomLeftReleased" },
|
||||
],
|
||||
outputEvents: [
|
||||
{ kind: "event", event: "alarm" },
|
||||
|
||||
{ kind: "event", event: "topLeftPressed" },
|
||||
{ kind: "event", event: "topRightPressed" },
|
||||
{ kind: "event", event: "bottomRightPressed" },
|
||||
{ kind: "event", event: "bottomLeftPressed" },
|
||||
{ kind: "event", event: "topLeftReleased" },
|
||||
{ kind: "event", event: "topRightReleased" },
|
||||
{ kind: "event", event: "bottomRightReleased" },
|
||||
{ kind: "event", event: "bottomLeftReleased" },
|
||||
],
|
||||
execution: statechartExecution(dwatchAbstractSyntax),
|
||||
export const digitalWatchPlant = makeStatechartPlant({
|
||||
ast: dwatchAbstractSyntax,
|
||||
render: DigitalWatch,
|
||||
}
|
||||
uiEvents: [
|
||||
{ kind: "event", event: "topLeftPressed" },
|
||||
{ kind: "event", event: "topRightPressed" },
|
||||
{ kind: "event", event: "bottomRightPressed" },
|
||||
{ kind: "event", event: "bottomLeftPressed" },
|
||||
{ kind: "event", event: "topLeftReleased" },
|
||||
{ kind: "event", event: "topRightReleased" },
|
||||
{ kind: "event", event: "bottomRightReleased" },
|
||||
{ kind: "event", event: "bottomLeftReleased" },
|
||||
],
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -8,7 +8,8 @@ export const dummyExecution: TimedReactive<null> = {
|
|||
extTransition: () => [[], null],
|
||||
};
|
||||
|
||||
export const DummyPlant: Plant<null> = {
|
||||
export const dummyPlant: Plant<null> = {
|
||||
uiEvents: [],
|
||||
inputEvents: [],
|
||||
outputEvents: [],
|
||||
execution: dummyExecution,
|
||||
|
|
|
|||
|
|
@ -8,14 +8,24 @@ import fontDigital from "../DigitalWatch/digital-font.ttf";
|
|||
|
||||
import sndBell from "./bell.wav";
|
||||
import sndRunning from "./running.wav";
|
||||
import { BigStep, RaisedEvent, RT_Statechart } from "@/statecharts/runtime_types";
|
||||
import { RT_Statechart } from "@/statecharts/runtime_types";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import "./Microwave.css";
|
||||
import { useAudioContext } from "../../useAudioContext";
|
||||
import { Plant, PlantRenderProps } from "../Plant";
|
||||
import { statechartExecution } from "@/statecharts/timed_reactive";
|
||||
import { microwaveAbstractSyntax } from "./model";
|
||||
import { makeStatechartPlant, PlantRenderProps, StatechartPlantSpec } from "../Plant";
|
||||
import { detectConnections } from "@/statecharts/detect_connections";
|
||||
import { parseStatechart } from "@/statecharts/parser";
|
||||
|
||||
import microwaveConcreteSyntax from "./model.json";
|
||||
import { ConcreteSyntax } from "@/App/VisualEditor/VisualEditor";
|
||||
|
||||
export const [microwaveAbstractSyntax, microwaveErrors] = parseStatechart(microwaveConcreteSyntax as ConcreteSyntax, detectConnections(microwaveConcreteSyntax as ConcreteSyntax));
|
||||
|
||||
if (microwaveErrors.length > 0) {
|
||||
console.log({microwaveErrors});
|
||||
throw new Error("there were errors parsing microwave plant model. see console.")
|
||||
}
|
||||
|
||||
|
||||
const imgs = {
|
||||
|
|
@ -37,7 +47,7 @@ const DOOR_Y0 = 68;
|
|||
const DOOR_WIDTH = 353;
|
||||
const DOOR_HEIGHT = 217;
|
||||
|
||||
export function Microwave({state, speed, raiseInput}: PlantRenderProps<RT_Statechart>) {
|
||||
export function Microwave({state, speed, raiseUIEvent}: PlantRenderProps<RT_Statechart>) {
|
||||
const [playSound, preloadAudio] = useAudioContext(speed);
|
||||
|
||||
// preload(imgSmallClosedOff, {as: "image"});
|
||||
|
|
@ -48,9 +58,9 @@ export function Microwave({state, speed, raiseInput}: PlantRenderProps<RT_Statec
|
|||
preloadAudio(sndRunning);
|
||||
preloadAudio(sndBell);
|
||||
|
||||
const bellRinging = state.mode.has("45");
|
||||
const magnetronRunning = state.mode.has("28");
|
||||
const doorOpen = state.mode.has("13");
|
||||
const bellRinging = state.mode.has("12");
|
||||
const magnetronRunning = state.mode.has("8");
|
||||
const doorOpen = state.mode.has("7");
|
||||
const timeDisplay = state.environment.get("timeDisplay");
|
||||
|
||||
// a bit hacky: when the bell-state changes to true, we play the bell sound...
|
||||
|
|
@ -80,53 +90,40 @@ export function Microwave({state, speed, raiseInput}: PlantRenderProps<RT_Statec
|
|||
<image xlinkHref={imgs[doorOpen][magnetronRunning]} width={520} height={348}/>
|
||||
|
||||
<rect className="microwaveButtonHelper" x={START_X0} y={START_Y0} width={BUTTON_WIDTH} height={BUTTON_HEIGHT}
|
||||
onMouseDown={() => raiseInput({name: "startPressed"})}
|
||||
onMouseUp={() => raiseInput({name: "startReleased"})}
|
||||
onMouseDown={() => raiseUIEvent({name: "startPressed"})}
|
||||
onMouseUp={() => raiseUIEvent({name: "startReleased"})}
|
||||
/>
|
||||
<rect className="microwaveButtonHelper" x={STOP_X0} y={STOP_Y0} width={BUTTON_WIDTH} height={BUTTON_HEIGHT}
|
||||
onMouseDown={() => raiseInput({name: "stopPressed"})}
|
||||
onMouseUp={() => raiseInput({name: "stopReleased"})}
|
||||
onMouseDown={() => raiseUIEvent({name: "stopPressed"})}
|
||||
onMouseUp={() => raiseUIEvent({name: "stopReleased"})}
|
||||
/>
|
||||
<rect className="microwaveButtonHelper" x={INCTIME_X0} y={INCTIME_Y0} width={BUTTON_WIDTH} height={BUTTON_HEIGHT}
|
||||
onMouseDown={() => raiseInput({name: "incTimePressed"})}
|
||||
onMouseUp={() => raiseInput({name: "incTimeReleased"})}
|
||||
onMouseDown={() => raiseUIEvent({name: "incTimePressed"})}
|
||||
onMouseUp={() => raiseUIEvent({name: "incTimeReleased"})}
|
||||
/>
|
||||
<rect className="microwaveDoorHelper"
|
||||
x={DOOR_X0} y={DOOR_Y0} width={DOOR_WIDTH} height={DOOR_HEIGHT}
|
||||
onMouseDown={() => raiseInput({name: "doorMouseDown"})}
|
||||
onMouseUp={() => raiseInput({name: "doorMouseUp"})}
|
||||
onMouseDown={() => raiseUIEvent({name: "doorMouseDown"})}
|
||||
onMouseUp={() => raiseUIEvent({name: "doorMouseUp"})}
|
||||
/>
|
||||
<text x={472} y={106} textAnchor="end" fontFamily="digital-font" fontSize={24} fill="lightgreen">{timeDisplay}</text>
|
||||
</svg>
|
||||
</>;
|
||||
}
|
||||
|
||||
export const MicrowavePlant: Plant<BigStep> = {
|
||||
inputEvents: [
|
||||
// events coming from statechart
|
||||
{kind: "event", event: "setTimeDisplay", paramName: "t"},
|
||||
{kind: "event", event: "setMagnetron", paramName: "state"},
|
||||
{kind: "event", event: "ringBell"},
|
||||
|
||||
// events coming from UI:
|
||||
const microwavePlantSpec: StatechartPlantSpec = {
|
||||
ast: microwaveAbstractSyntax,
|
||||
render: Microwave,
|
||||
uiEvents: [
|
||||
{kind: "event", event: "doorMouseDown"},
|
||||
{kind: "event", event: "doorMouseUp"},
|
||||
{kind: "event", event: "startPressed"},
|
||||
{kind: "event", event: "stopPressed"},
|
||||
{kind: "event", event: "incTimePressed"},
|
||||
{kind: "event", event: "startReleased"},
|
||||
{kind: "event", event: "stopPressed"},
|
||||
{kind: "event", event: "stopReleased"},
|
||||
{kind: "event", event: "incTimePressed"},
|
||||
{kind: "event", event: "incTimeReleased"},
|
||||
],
|
||||
outputEvents: [
|
||||
{kind: "event", event: "door", paramName: "state"},
|
||||
{kind: "event", event: "startPressed"},
|
||||
{kind: "event", event: "stopPressed"},
|
||||
{kind: "event", event: "incTimePressed"},
|
||||
{kind: "event", event: "startReleased"},
|
||||
{kind: "event", event: "stopReleased"},
|
||||
{kind: "event", event: "incTimeReleased"},
|
||||
],
|
||||
execution: statechartExecution(microwaveAbstractSyntax),
|
||||
render: Microwave,
|
||||
}
|
||||
|
||||
export const microwavePlant = makeStatechartPlant(microwavePlantSpec);
|
||||
|
|
|
|||
1
src/App/Plant/Microwave/model.json
Normal file
1
src/App/Plant/Microwave/model.json
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1,18 +1,21 @@
|
|||
import { ReactElement } from "react";
|
||||
import { Statechart } from "@/statecharts/abstract_syntax";
|
||||
import { EventTrigger } from "@/statecharts/label_ast";
|
||||
import { RaisedEvent } from "@/statecharts/runtime_types";
|
||||
import { Conns, TimedReactive } from "@/statecharts/timed_reactive";
|
||||
import { BigStep, RaisedEvent, RT_Statechart } from "@/statecharts/runtime_types";
|
||||
import { statechartExecution, TimedReactive } from "@/statecharts/timed_reactive";
|
||||
|
||||
export type PlantRenderProps<StateType> = {
|
||||
state: StateType,
|
||||
speed: number,
|
||||
raiseInput: (e: RaisedEvent) => void,
|
||||
raiseUIEvent: (e: RaisedEvent) => void,
|
||||
};
|
||||
|
||||
export type Plant<StateType> = {
|
||||
uiEvents: EventTrigger[];
|
||||
|
||||
inputEvents: EventTrigger[];
|
||||
outputEvents: EventTrigger[];
|
||||
|
||||
execution: TimedReactive<StateType>;
|
||||
render: (props: PlantRenderProps<StateType>) => ReactElement;
|
||||
}
|
||||
|
|
@ -48,3 +51,19 @@ export function exposePlantInputs(plant: Plant<any>, plantName: string, tfm = (s
|
|||
}
|
||||
return inputs
|
||||
}
|
||||
|
||||
export type StatechartPlantSpec = {
|
||||
uiEvents: EventTrigger[],
|
||||
ast: Statechart,
|
||||
render: (props: PlantRenderProps<RT_Statechart>) => ReactElement,
|
||||
}
|
||||
|
||||
export function makeStatechartPlant({uiEvents, ast, render}: StatechartPlantSpec): Plant<BigStep> {
|
||||
return {
|
||||
uiEvents,
|
||||
inputEvents: ast.inputEvents,
|
||||
outputEvents: [...ast.outputEvents].map(e => ({kind: "event" as const, event: e})),
|
||||
execution: statechartExecution(ast),
|
||||
render,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { formatTime } from "../util/util";
|
|||
import { TimeMode, timeTravel } from "../statecharts/time";
|
||||
import { TraceItem, TraceState } from "./App";
|
||||
import { Environment } from "@/statecharts/environment";
|
||||
import { Conns } from "@/statecharts/timed_reactive";
|
||||
|
||||
type RTHistoryProps = {
|
||||
trace: TraceState|null,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { Dispatch, memo, ReactElement, SetStateAction } from "react";
|
||||
import { Dispatch, memo, ReactElement, SetStateAction, useCallback, useEffect } from "react";
|
||||
import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo";
|
||||
import { InsertMode } from "@/App/VisualEditor/VisualEditor";
|
||||
import { HistoryIcon, PseudoStateIcon, RountangleIcon } from "./Icons";
|
||||
|
||||
import TrendingFlatIcon from '@mui/icons-material/TrendingFlat';
|
||||
|
||||
export type InsertMode = "and" | "or" | "pseudo" | "shallow" | "deep" | "transition" | "text";
|
||||
|
||||
const insertModes: [InsertMode, string, ReactElement, ReactElement][] = [
|
||||
["and", "AND-states", <RountangleIcon kind="and"/>, <kbd>A</kbd>],
|
||||
["or", "OR-states", <RountangleIcon kind="or"/>, <kbd>O</kbd>],
|
||||
|
|
@ -16,6 +17,47 @@ const insertModes: [InsertMode, string, ReactElement, ReactElement][] = [
|
|||
];
|
||||
|
||||
export const InsertModes = memo(function InsertModes({showKeys, insertMode, setInsertMode}: {showKeys: boolean, insertMode: InsertMode, setInsertMode: Dispatch<SetStateAction<InsertMode>>}) {
|
||||
|
||||
const onKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
// @ts-ignore
|
||||
if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target?.tagName)) return;
|
||||
|
||||
if (!e.ctrlKey) {
|
||||
if (e.key === "a") {
|
||||
e.preventDefault();
|
||||
setInsertMode("and");
|
||||
}
|
||||
if (e.key === "o") {
|
||||
e.preventDefault();
|
||||
setInsertMode("or");
|
||||
}
|
||||
if (e.key === "p") {
|
||||
e.preventDefault();
|
||||
setInsertMode("pseudo");
|
||||
}
|
||||
if (e.key === "t") {
|
||||
e.preventDefault();
|
||||
setInsertMode("transition");
|
||||
}
|
||||
if (e.key === "x") {
|
||||
e.preventDefault();
|
||||
setInsertMode("text");
|
||||
}
|
||||
if (e.key === "h") {
|
||||
e.preventDefault();
|
||||
setInsertMode(oldMode => {
|
||||
if (oldMode === "shallow") return "deep";
|
||||
return "shallow";
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [setInsertMode]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
() => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [onKeyDown]);
|
||||
|
||||
const KeyInfo = showKeys ? KeyInfoVisible : KeyInfoHidden;
|
||||
return <>{insertModes.map(([m, hint, buttonTxt, keyInfo]) => <KeyInfo key={m} keyInfo={keyInfo}>
|
||||
<button
|
||||
|
|
|
|||
61
src/App/TopPanel/SpeedControl.tsx
Normal file
61
src/App/TopPanel/SpeedControl.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { Dispatch, memo, SetStateAction, useCallback, useEffect } from "react";
|
||||
import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo";
|
||||
import { setRealtime, TimeMode } from "@/statecharts/time";
|
||||
|
||||
export const SpeedControl = memo(function SpeedControl({showKeys, timescale, setTimescale, setTime}: {showKeys: boolean, timescale: number, setTimescale: Dispatch<SetStateAction<number>>, setTime: Dispatch<SetStateAction<TimeMode>>}) {
|
||||
|
||||
const onTimeScaleChange = useCallback((newValue: string, wallclktime: number) => {
|
||||
const asFloat = parseFloat(newValue);
|
||||
if (Number.isNaN(asFloat)) {
|
||||
return;
|
||||
}
|
||||
const maxed = Math.min(asFloat, 64);
|
||||
const mined = Math.max(maxed, 1/64);
|
||||
setTimescale(mined);
|
||||
setTime(time => {
|
||||
if (time.kind === "paused") {
|
||||
return time;
|
||||
}
|
||||
else {
|
||||
return setRealtime(time, mined, wallclktime);
|
||||
}
|
||||
});
|
||||
}, [setTime, setTimescale]);
|
||||
|
||||
const onSlower = useCallback(() => {
|
||||
onTimeScaleChange((timescale/2).toString(), Math.round(performance.now()));
|
||||
}, [onTimeScaleChange, timescale]);
|
||||
const onFaster = useCallback(() => {
|
||||
onTimeScaleChange((timescale*2).toString(), Math.round(performance.now()));
|
||||
}, [onTimeScaleChange, timescale]);
|
||||
|
||||
const onKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (!e.ctrlKey) {
|
||||
if (e.key === "s") {
|
||||
e.preventDefault();
|
||||
onSlower();
|
||||
}
|
||||
if (e.key === "f") {
|
||||
e.preventDefault();
|
||||
onFaster();
|
||||
}
|
||||
}
|
||||
}, [onSlower, onFaster])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [onKeyDown])
|
||||
|
||||
const KeyInfo = showKeys ? KeyInfoVisible : KeyInfoHidden;
|
||||
return <>
|
||||
<label htmlFor="number-timescale">speed</label>
|
||||
<KeyInfo keyInfo={<kbd>S</kbd>}>
|
||||
<button title="slower" onClick={onSlower}>÷2</button>
|
||||
</KeyInfo>
|
||||
<input title="controls how fast the simulation should run in real time mode - larger than 1 means: faster than wall-clock time" id="number-timescale" value={timescale.toFixed(3)} style={{width:40}} readOnly onChange={e => onTimeScaleChange(e.target.value, Math.round(performance.now()))}/>
|
||||
<KeyInfo keyInfo={<kbd>F</kbd>}>
|
||||
<button title="faster" onClick={onFaster}>×2</button>
|
||||
</KeyInfo>
|
||||
</>
|
||||
});
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { Dispatch, memo, ReactElement, SetStateAction, useCallback, useEffect, useState } from "react";
|
||||
import { TimerElapseEvent, Timers } from "../../statecharts/runtime_types";
|
||||
import { getSimTime, setPaused, setRealtime, TimeMode } from "../../statecharts/time";
|
||||
import { InsertMode } from "../VisualEditor/VisualEditor";
|
||||
import { InsertMode } from "./InsertModes";
|
||||
import { About } from "../Modals/About";
|
||||
import { EditHistory, TraceState } from "../App";
|
||||
import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo";
|
||||
|
|
@ -20,6 +20,7 @@ import StopIcon from '@mui/icons-material/Stop';
|
|||
import { InsertModes } from "./InsertModes";
|
||||
import { usePersistentState } from "@/App/persistent_state";
|
||||
import { RotateButtons } from "./RotateButtons";
|
||||
import { SpeedControl } from "./SpeedControl";
|
||||
|
||||
export type TopPanelProps = {
|
||||
trace: TraceState | null,
|
||||
|
|
@ -79,24 +80,6 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on
|
|||
updateDisplayedTime();
|
||||
}, [setTime, timescale, updateDisplayedTime]);
|
||||
|
||||
const onTimeScaleChange = useCallback((newValue: string, wallclktime: number) => {
|
||||
const asFloat = parseFloat(newValue);
|
||||
if (Number.isNaN(asFloat)) {
|
||||
return;
|
||||
}
|
||||
const maxed = Math.min(asFloat, 64);
|
||||
const mined = Math.max(maxed, 1/64);
|
||||
setTimescale(mined);
|
||||
setTime(time => {
|
||||
if (time.kind === "paused") {
|
||||
return time;
|
||||
}
|
||||
else {
|
||||
return setRealtime(time, mined, wallclktime);
|
||||
}
|
||||
});
|
||||
}, [setTime, setTimescale]);
|
||||
|
||||
// timestamp of next timed transition, in simulated time
|
||||
const timers: Timers = config?.kind === "bigstep" && config.state.sc.environment.get("_timers") || [];
|
||||
const nextTimedTransition: [number, TimerElapseEvent] | undefined = timers[0];
|
||||
|
|
@ -115,16 +98,10 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on
|
|||
}
|
||||
}, [nextTimedTransition, setTime]);
|
||||
|
||||
const onSlower = useCallback(() => {
|
||||
onTimeScaleChange((timescale/2).toString(), Math.round(performance.now()));
|
||||
}, [onTimeScaleChange, timescale]);
|
||||
const onFaster = useCallback(() => {
|
||||
onTimeScaleChange((timescale*2).toString(), Math.round(performance.now()));
|
||||
}, [onTimeScaleChange, timescale]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
// don't capture keyboard events when focused on an input element:
|
||||
// @ts-ignore
|
||||
if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target?.tagName)) return;
|
||||
|
||||
if (!e.ctrlKey) {
|
||||
|
|
@ -143,7 +120,7 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on
|
|||
onClear();
|
||||
}
|
||||
if (e.key === "Tab") {
|
||||
if (trace === null) {
|
||||
if (config === null) {
|
||||
onInit();
|
||||
}
|
||||
else {
|
||||
|
|
@ -151,14 +128,6 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on
|
|||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
if (e.key === "s") {
|
||||
e.preventDefault();
|
||||
onSlower();
|
||||
}
|
||||
if (e.key === "f") {
|
||||
e.preventDefault();
|
||||
onFaster();
|
||||
}
|
||||
if (e.key === "`") {
|
||||
e.preventDefault();
|
||||
setShowKeys(show => !show);
|
||||
|
|
@ -168,23 +137,12 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, 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 () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [trace, config, time, onInit, timescale, onChangePaused, setShowKeys, onUndo, onRedo, onSlower, onFaster, onSkip, onBack, onClear]);
|
||||
}, [config, time, onInit, onChangePaused, setShowKeys, onSkip, onBack, onClear]);
|
||||
|
||||
return <div className="toolbar">
|
||||
{/* shortcuts / about */}
|
||||
|
|
@ -241,14 +199,7 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on
|
|||
|
||||
{/* speed */}
|
||||
<div className="toolbarGroup">
|
||||
<label htmlFor="number-timescale">speed</label>
|
||||
<KeyInfo keyInfo={<kbd>S</kbd>}>
|
||||
<button title="slower" onClick={onSlower}>÷2</button>
|
||||
</KeyInfo>
|
||||
<input title="controls how fast the simulation should run in real time mode - larger than 1 means: faster than wall-clock time" id="number-timescale" value={timescale.toFixed(3)} style={{width:40}} readOnly onChange={e => onTimeScaleChange(e.target.value, Math.round(performance.now()))}/>
|
||||
<KeyInfo keyInfo={<kbd>F</kbd>}>
|
||||
<button title="faster" onClick={onFaster}>×2</button>
|
||||
</KeyInfo>
|
||||
<SpeedControl setTime={setTime} timescale={timescale} setTimescale={setTimescale} showKeys={showKeys} />
|
||||
 
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,30 @@
|
|||
import { memo } from "react";
|
||||
import { memo, useCallback, useEffect } from "react";
|
||||
import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo";
|
||||
|
||||
import UndoIcon from '@mui/icons-material/Undo';
|
||||
import RedoIcon from '@mui/icons-material/Redo';
|
||||
|
||||
export const UndoRedoButtons = memo(function UndoRedoButtons({showKeys, onUndo, onRedo, historyLength, futureLength}: {showKeys: boolean, onUndo: () => void, onRedo: () => void, historyLength: number, futureLength: number}) {
|
||||
|
||||
const onKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.ctrlKey) {
|
||||
// ctrl is down
|
||||
if (e.key === "z") {
|
||||
e.preventDefault();
|
||||
onUndo();
|
||||
}
|
||||
if (e.key === "Z") {
|
||||
e.preventDefault();
|
||||
onRedo();
|
||||
}
|
||||
}
|
||||
}, [onUndo, onRedo]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [onKeyDown]);
|
||||
|
||||
const KeyInfo = showKeys ? KeyInfoVisible : KeyInfoHidden;
|
||||
return <>
|
||||
<KeyInfo keyInfo={<><kbd>Ctrl</kbd>+<kbd>Z</kbd></>}>
|
||||
|
|
|
|||
|
|
@ -24,10 +24,12 @@ export const ZoomButtons = memo(function ZoomButtons({showKeys, zoom, setZoom}:
|
|||
if (e.ctrlKey) {
|
||||
if (e.key === "+") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onZoomIn();
|
||||
}
|
||||
if (e.key === "-") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onZoomOut();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Diamond, RectSide } from "@/statecharts/concrete_syntax";
|
||||
import { rountangleMinSize } from "./VisualEditor";
|
||||
import { rountangleMinSize } from "@/statecharts/concrete_syntax";
|
||||
import { Vec2D } from "../../util/geometry";
|
||||
import { RectHelper } from "./RectHelpers";
|
||||
import { memo } from "react";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { memo } from "react";
|
||||
import { RectSide } from "../../statecharts/concrete_syntax";
|
||||
import { Vec2D } from "../../util/geometry";
|
||||
import { CORNER_HELPER_OFFSET, CORNER_HELPER_RADIUS } from "../parameters";
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { memo } from "react";
|
|||
import { Rountangle, RectSide } from "../../statecharts/concrete_syntax";
|
||||
import { ROUNTANGLE_RADIUS } from "../parameters";
|
||||
import { RectHelper } from "./RectHelpers";
|
||||
import { rountangleMinSize } from "./VisualEditor";
|
||||
import { rountangleMinSize } from "@/statecharts/concrete_syntax";
|
||||
import { arraysEqual } from "@/util/util";
|
||||
|
||||
|
||||
|
|
|
|||
14
src/App/VisualEditor/Selection.tsx
Normal file
14
src/App/VisualEditor/Selection.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { normalizeRect, Rect2D } from "@/util/geometry";
|
||||
|
||||
export type SelectingState = Rect2D | null;
|
||||
|
||||
export function Selecting(props: SelectingState) {
|
||||
const normalizedRect = normalizeRect(props!);
|
||||
return <rect
|
||||
className="selecting"
|
||||
x={normalizedRect.topLeft.x}
|
||||
y={normalizedRect.topLeft.y}
|
||||
width={normalizedRect.size.x}
|
||||
height={normalizedRect.size.y}
|
||||
/>;
|
||||
}
|
||||
|
|
@ -1,22 +1,23 @@
|
|||
import { ClipboardEvent, Dispatch, memo, ReactElement, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Dispatch, memo, ReactElement, SetStateAction, useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Statechart } from "../../statecharts/abstract_syntax";
|
||||
import { Arrow, ArrowPart, Diamond, History, Rountangle, RectSide, Text } from "../../statecharts/concrete_syntax";
|
||||
import { parseStatechart, TraceableError } from "../../statecharts/parser";
|
||||
import { ArcDirection, Rect2D, Vec2D, addV2D, arcDirection, area, isEntirelyWithin, normalizeRect, scaleV2D, subtractV2D, transformLine, transformRect } from "../../util/geometry";
|
||||
import { MIN_ROUNTANGLE_SIZE } from "../parameters";
|
||||
import { getBBoxInSvgCoords } from "../../util/svg_helper";
|
||||
import { ArrowSVG } from "./ArrowSVG";
|
||||
import { RountangleSVG } from "./RountangleSVG";
|
||||
import { TextSVG } from "./TextSVG";
|
||||
import { DiamondSVG } from "./DiamondSVG";
|
||||
import { HistorySVG } from "./HistorySVG";
|
||||
import { Connections, detectConnections } from "../../statecharts/detect_connections";
|
||||
|
||||
import "./VisualEditor.css";
|
||||
import { TraceState } from "@/App/App";
|
||||
import { InsertMode } from "../TopPanel/InsertModes";
|
||||
import { Mode } from "@/statecharts/runtime_types";
|
||||
import { arraysEqual, objectsEqual, setsEqual } from "@/util/util";
|
||||
import { Arrow, ArrowPart, Diamond, History, RectSide, Rountangle, Text } from "../../statecharts/concrete_syntax";
|
||||
import { Connections } from "../../statecharts/detect_connections";
|
||||
import { TraceableError } from "../../statecharts/parser";
|
||||
import { ArcDirection, arcDirection } from "../../util/geometry";
|
||||
import { ArrowSVG } from "./ArrowSVG";
|
||||
import { DiamondSVG } from "./DiamondSVG";
|
||||
import { HistorySVG } from "./HistorySVG";
|
||||
import { RountangleSVG } from "./RountangleSVG";
|
||||
import { TextSVG } from "./TextSVG";
|
||||
import { useCopyPaste } from "./useCopyPaste";
|
||||
|
||||
import "./VisualEditor.css";
|
||||
import { useMouse } from "./useMouse";
|
||||
import { Selecting } from "./Selection";
|
||||
|
||||
export type ConcreteSyntax = {
|
||||
rountangles: Rountangle[];
|
||||
|
|
@ -31,8 +32,6 @@ export type VisualEditorState = ConcreteSyntax & {
|
|||
selection: Selection;
|
||||
};
|
||||
|
||||
type SelectingState = Rect2D | null;
|
||||
|
||||
export type RountangleSelectable = {
|
||||
// kind: "rountangle";
|
||||
parts: RectSide[];
|
||||
|
|
@ -55,9 +54,6 @@ type Selectable = RountangleSelectable | ArrowSelectable | TextSelectable | Hist
|
|||
|
||||
export type Selection = Selectable[];
|
||||
|
||||
export type InsertMode = "and"|"or"|"pseudo"|"shallow"|"deep"|"transition"|"text";
|
||||
|
||||
|
||||
type VisualEditorProps = {
|
||||
state: VisualEditorState,
|
||||
setState: Dispatch<(v:VisualEditorState) => VisualEditorState>,
|
||||
|
|
@ -74,17 +70,8 @@ type VisualEditorProps = {
|
|||
|
||||
export const VisualEditor = memo(function VisualEditor({state, setState, trace, conns, syntaxErrors: errors, insertMode, highlightActive, highlightTransitions, setModal, makeCheckPoint, zoom}: VisualEditorProps) {
|
||||
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
window.setState = setState;
|
||||
|
||||
// uid's of selected rountangles
|
||||
const selection = state.selection || [];
|
||||
const setSelection = useCallback((cb: (oldSelection: Selection) => Selection) =>
|
||||
setState(oldState => ({...oldState, selection: cb(oldState.selection)})),[setState]);
|
||||
|
||||
// not null while the user is making a selection
|
||||
const [selectingState, setSelectingState] = useState<SelectingState>(null);
|
||||
|
||||
const refSVG = useRef<SVGSVGElement>(null);
|
||||
|
||||
|
|
@ -102,344 +89,11 @@ export const VisualEditor = memo(function VisualEditor({state, setState, trace,
|
|||
})
|
||||
}, [trace && trace.idx]);
|
||||
|
||||
const getCurrentPointer = useCallback((e: {pageX: number, pageY: number}) => {
|
||||
const bbox = refSVG.current!.getBoundingClientRect();
|
||||
return {
|
||||
x: (e.pageX - bbox.left)/zoom,
|
||||
y: (e.pageY - bbox.top)/zoom,
|
||||
}
|
||||
}, [refSVG.current, zoom]);
|
||||
|
||||
const onMouseDown = useCallback((e: {button: number, target: any, pageX: number, pageY: number}) => {
|
||||
const currentPointer = getCurrentPointer(e);
|
||||
if (e.button === 2) {
|
||||
makeCheckPoint();
|
||||
// ignore selection, middle mouse button always inserts
|
||||
setState(state => {
|
||||
const newID = state.nextID.toString();
|
||||
if (insertMode === "and" || insertMode === "or") {
|
||||
// insert rountangle
|
||||
return {
|
||||
...state,
|
||||
rountangles: [...state.rountangles, {
|
||||
uid: newID,
|
||||
topLeft: currentPointer,
|
||||
size: MIN_ROUNTANGLE_SIZE,
|
||||
kind: insertMode,
|
||||
}],
|
||||
nextID: state.nextID+1,
|
||||
selection: [{uid: newID, parts: ["bottom", "right"]}],
|
||||
};
|
||||
}
|
||||
else if (insertMode === "pseudo") {
|
||||
return {
|
||||
...state,
|
||||
diamonds: [...state.diamonds, {
|
||||
uid: newID,
|
||||
topLeft: currentPointer,
|
||||
size: MIN_ROUNTANGLE_SIZE,
|
||||
}],
|
||||
nextID: state.nextID+1,
|
||||
selection: [{uid: newID, parts: ["bottom", "right"]}],
|
||||
};
|
||||
}
|
||||
else if (insertMode === "shallow" || insertMode === "deep") {
|
||||
return {
|
||||
...state,
|
||||
history: [...state.history, {
|
||||
uid: newID,
|
||||
kind: insertMode,
|
||||
topLeft: currentPointer,
|
||||
}],
|
||||
nextID: state.nextID+1,
|
||||
selection: [{uid: newID, parts: ["history"]}],
|
||||
}
|
||||
}
|
||||
else if (insertMode === "transition") {
|
||||
return {
|
||||
...state,
|
||||
arrows: [...state.arrows, {
|
||||
uid: newID,
|
||||
start: currentPointer,
|
||||
end: currentPointer,
|
||||
}],
|
||||
nextID: state.nextID+1,
|
||||
selection: [{uid: newID, parts: ["end"]}],
|
||||
}
|
||||
}
|
||||
else if (insertMode === "text") {
|
||||
return {
|
||||
...state,
|
||||
texts: [...state.texts, {
|
||||
uid: newID,
|
||||
text: "// Double-click to edit",
|
||||
topLeft: currentPointer,
|
||||
}],
|
||||
nextID: state.nextID+1,
|
||||
selection: [{uid: newID, parts: ["text"]}],
|
||||
}
|
||||
}
|
||||
throw new Error("unreachable, mode=" + insertMode); // shut up typescript
|
||||
});
|
||||
setDragging(true);
|
||||
return;
|
||||
}
|
||||
const {onCopy, onPaste, onCut, deleteSelection} = useCopyPaste(makeCheckPoint, state, setState, selection);
|
||||
|
||||
if (e.button === 0) {
|
||||
// left mouse button on a shape will drag that shape (and everything else that's selected). if the shape under the pointer was not in the selection then the selection is reset to contain only that shape.
|
||||
const uid = e.target?.dataset.uid;
|
||||
const parts: string[] = e.target?.dataset.parts?.split(' ').filter((p:string) => p!=="") || [];
|
||||
if (uid && parts.length > 0) {
|
||||
makeCheckPoint();
|
||||
const {onMouseDown, selectionRect} = useMouse(makeCheckPoint, insertMode, zoom, refSVG, state, setState, deleteSelection);
|
||||
|
||||
// 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;
|
||||
for (const part of parts) {
|
||||
if (!(selection.find(s => s.uid === uid)?.parts || [] as string[]).includes(part)) {
|
||||
allPartsInSelection = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!allPartsInSelection) {
|
||||
if (e.target.classList.contains("helper")) {
|
||||
setSelection(() => [{uid, parts}] as Selection);
|
||||
}
|
||||
else {
|
||||
setDragging(false);
|
||||
setSelectingState({
|
||||
topLeft: currentPointer,
|
||||
size: {x: 0, y: 0},
|
||||
});
|
||||
setSelection(() => []);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// start dragging
|
||||
setDragging(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// otherwise, just start making a selection
|
||||
setDragging(false);
|
||||
setSelectingState({
|
||||
topLeft: currentPointer,
|
||||
size: {x: 0, y: 0},
|
||||
});
|
||||
setSelection(() => []);
|
||||
}, [getCurrentPointer, makeCheckPoint, insertMode, selection]);
|
||||
|
||||
const onMouseMove = useCallback((e: {pageX: number, pageY: number, movementX: number, movementY: number}) => {
|
||||
const currentPointer = getCurrentPointer(e);
|
||||
if (dragging) {
|
||||
// const pointerDelta = subtractV2D(currentPointer, dragging.lastMousePos);
|
||||
const pointerDelta = {x: e.movementX/zoom, y: e.movementY/zoom};
|
||||
setState(state => ({
|
||||
...state,
|
||||
rountangles: state.rountangles.map(r => {
|
||||
const parts = state.selection.find(selected => selected.uid === r.uid)?.parts || [];
|
||||
if (parts.length === 0) {
|
||||
return r;
|
||||
}
|
||||
return {
|
||||
...r,
|
||||
...transformRect(r, parts, pointerDelta),
|
||||
};
|
||||
})
|
||||
.toSorted((a,b) => area(b) - area(a)), // sort: smaller rountangles are drawn on top
|
||||
diamonds: state.diamonds.map(d => {
|
||||
const parts = state.selection.find(selected => selected.uid === d.uid)?.parts || [];
|
||||
if (parts.length === 0) {
|
||||
return d;
|
||||
}
|
||||
return {
|
||||
...d,
|
||||
...transformRect(d, parts, pointerDelta),
|
||||
}
|
||||
}),
|
||||
history: state.history.map(h => {
|
||||
const parts = state.selection.find(selected => selected.uid === h.uid)?.parts || [];
|
||||
if (parts.length === 0) {
|
||||
return h;
|
||||
}
|
||||
return {
|
||||
...h,
|
||||
topLeft: addV2D(h.topLeft, pointerDelta),
|
||||
}
|
||||
}),
|
||||
arrows: state.arrows.map(a => {
|
||||
const parts = state.selection.find(selected => selected.uid === a.uid)?.parts || [];
|
||||
if (parts.length === 0) {
|
||||
return a;
|
||||
}
|
||||
return {
|
||||
...a,
|
||||
...transformLine(a, parts, pointerDelta),
|
||||
}
|
||||
}),
|
||||
texts: state.texts.map(t => {
|
||||
const parts = state.selection.find(selected => selected.uid === t.uid)?.parts || [];
|
||||
if (parts.length === 0) {
|
||||
return t;
|
||||
}
|
||||
return {
|
||||
...t,
|
||||
topLeft: addV2D(t.topLeft, pointerDelta),
|
||||
}
|
||||
}),
|
||||
}));
|
||||
setDragging(true);
|
||||
}
|
||||
else if (selectingState) {
|
||||
setSelectingState(ss => {
|
||||
const selectionSize = subtractV2D(currentPointer, ss!.topLeft);
|
||||
return {
|
||||
...ss!,
|
||||
size: selectionSize,
|
||||
};
|
||||
});
|
||||
}
|
||||
}, [getCurrentPointer, selectingState, dragging]);
|
||||
|
||||
const onMouseUp = useCallback((e: {target: any, pageX: number, pageY: number}) => {
|
||||
if (dragging) {
|
||||
setDragging(false);
|
||||
// do not persist sizes smaller than 40x40
|
||||
setState(state => {
|
||||
return {
|
||||
...state,
|
||||
rountangles: state.rountangles.map(r => ({
|
||||
...r,
|
||||
size: rountangleMinSize(r.size),
|
||||
})),
|
||||
diamonds: state.diamonds.map(d => ({
|
||||
...d,
|
||||
size: rountangleMinSize(d.size),
|
||||
}))
|
||||
};
|
||||
});
|
||||
}
|
||||
if (selectingState) {
|
||||
if (selectingState.size.x === 0 && selectingState.size.y === 0) {
|
||||
const uid = e.target?.dataset.uid;
|
||||
if (uid) {
|
||||
const parts = e.target?.dataset.parts.split(' ').filter((p: string) => p!=="");
|
||||
if (uid) {
|
||||
setSelection(() => [{
|
||||
uid,
|
||||
parts,
|
||||
}]);
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
// we were making a selection
|
||||
const normalizedSS = normalizeRect(selectingState);
|
||||
const shapes = Array.from(refSVG.current?.querySelectorAll("rect, line, circle, text") || []) as SVGGraphicsElement[];
|
||||
const shapesInSelection = shapes.filter(el => {
|
||||
const bbox = getBBoxInSvgCoords(el, refSVG.current!);
|
||||
const scaledBBox = {
|
||||
topLeft: scaleV2D(bbox.topLeft, 1/zoom),
|
||||
size: scaleV2D(bbox.size, 1/zoom),
|
||||
}
|
||||
return isEntirelyWithin(scaledBBox, normalizedSS);
|
||||
}).filter(el => !el.classList.contains("corner"));
|
||||
|
||||
const uidToParts = new Map();
|
||||
for (const shape of shapesInSelection) {
|
||||
const uid = shape.dataset.uid;
|
||||
if (uid) {
|
||||
const parts: Set<string> = uidToParts.get(uid) || new Set();
|
||||
for (const part of shape.dataset.parts?.split(' ') || []) {
|
||||
parts.add(part);
|
||||
}
|
||||
uidToParts.set(uid, parts);
|
||||
}
|
||||
}
|
||||
setSelection(() => [...uidToParts.entries()].map(([uid,parts]) => ({
|
||||
uid,
|
||||
parts: [...parts],
|
||||
})));
|
||||
}
|
||||
}
|
||||
setSelectingState(null); // no longer making a selection
|
||||
}, [dragging, selectingState, refSVG.current]);
|
||||
|
||||
const deleteSelection = useCallback(() => {
|
||||
setState(state => ({
|
||||
...state,
|
||||
rountangles: state.rountangles.filter(r => !state.selection.some(rs => rs.uid === r.uid)),
|
||||
diamonds: state.diamonds.filter(d => !state.selection.some(ds => ds.uid === d.uid)),
|
||||
history: state.history.filter(h => !state.selection.some(hs => hs.uid === h.uid)),
|
||||
arrows: state.arrows.filter(a => !state.selection.some(as => as.uid === a.uid)),
|
||||
texts: state.texts.filter(t => !state.selection.some(ts => ts.uid === t.uid)),
|
||||
selection: [],
|
||||
}));
|
||||
}, [setState]);
|
||||
|
||||
const onKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
// don't capture keyboard events when focused on an input element:
|
||||
if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target?.tagName)) return;
|
||||
|
||||
if (e.key === "Delete") {
|
||||
// delete selection
|
||||
makeCheckPoint();
|
||||
deleteSelection();
|
||||
}
|
||||
if (e.key === "o") {
|
||||
// selected states become OR-states
|
||||
setState(state => ({
|
||||
...state,
|
||||
rountangles: state.rountangles.map(r => state.selection.some(rs => rs.uid === r.uid) ? ({...r, kind: "or"}) : r),
|
||||
}));
|
||||
}
|
||||
if (e.key === "a") {
|
||||
// selected states become AND-states
|
||||
setState(state => ({
|
||||
...state,
|
||||
rountangles: state.rountangles.map(r => state.selection.some(rs => rs.uid === r.uid) ? ({...r, kind: "and"}) : r),
|
||||
}));
|
||||
}
|
||||
// if (e.key === "p") {
|
||||
// // selected states become pseudo-states
|
||||
// setSelection(selection => {
|
||||
// setState(state => ({
|
||||
// ...state,
|
||||
// rountangles: state.rountangles.map(r => selection.some(rs => rs.uid === r.uid) ? ({...r, kind: "pseudo"}) : r),
|
||||
// }));
|
||||
// return selection;
|
||||
// });
|
||||
// }
|
||||
if (e.ctrlKey) {
|
||||
if (e.key === "a") {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
setState(state => ({
|
||||
...state,
|
||||
// @ts-ignore
|
||||
selection: [
|
||||
...state.rountangles.map(r => ({uid: r.uid, parts: ["left", "top", "right", "bottom"]})),
|
||||
...state.diamonds.map(d => ({uid: d.uid, parts: ["left", "top", "right", "bottom"]})),
|
||||
...state.arrows.map(a => ({uid: a.uid, parts: ["start", "end"]})),
|
||||
...state.texts.map(t => ({uid: t.uid, parts: ["text"]})),
|
||||
]
|
||||
}))
|
||||
}
|
||||
}
|
||||
}, [makeCheckPoint, deleteSelection, setState, setDragging]);
|
||||
|
||||
useEffect(() => {
|
||||
// mousemove and mouseup are global event handlers so they keep working when pointer is outside of browser window
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
}, [selectingState, dragging]);
|
||||
|
||||
// for visual feedback, when selecting/moving one thing, we also highlight (in green) all the things that belong to the thing we selected.
|
||||
const sidesToHighlight: {[key: string]: RectSide[]} = {};
|
||||
|
|
@ -487,101 +141,6 @@ export const VisualEditor = memo(function VisualEditor({state, setState, trace,
|
|||
}
|
||||
}
|
||||
|
||||
const onPaste = useCallback((e: ClipboardEvent) => {
|
||||
const data = e.clipboardData?.getData("text/plain");
|
||||
if (data) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(data);
|
||||
}
|
||||
catch (e) {
|
||||
return;
|
||||
}
|
||||
// const offset = {x: 40, y: 40};
|
||||
const offset = {x: 0, y: 0};
|
||||
setState(state => {
|
||||
let nextID = state.nextID;
|
||||
const copiedRountangles: Rountangle[] = parsed.rountangles.map((r: Rountangle) => ({
|
||||
...r,
|
||||
uid: (nextID++).toString(),
|
||||
topLeft: addV2D(r.topLeft, offset),
|
||||
} as Rountangle));
|
||||
const copiedDiamonds: Diamond[] = parsed.diamonds.map((r: Diamond) => ({
|
||||
...r,
|
||||
uid: (nextID++).toString(),
|
||||
topLeft: addV2D(r.topLeft, offset),
|
||||
} as Diamond));
|
||||
const copiedArrows: Arrow[] = parsed.arrows.map((a: Arrow) => ({
|
||||
...a,
|
||||
uid: (nextID++).toString(),
|
||||
start: addV2D(a.start, offset),
|
||||
end: addV2D(a.end, offset),
|
||||
} as Arrow));
|
||||
const copiedTexts: Text[] = parsed.texts.map((t: Text) => ({
|
||||
...t,
|
||||
uid: (nextID++).toString(),
|
||||
topLeft: addV2D(t.topLeft, offset),
|
||||
} as Text));
|
||||
const copiedHistories: History[] = parsed.history.map((h: History) => ({
|
||||
...h,
|
||||
uid: (nextID++).toString(),
|
||||
topLeft: addV2D(h.topLeft, offset),
|
||||
}))
|
||||
// @ts-ignore
|
||||
const newSelection: Selection = [
|
||||
...copiedRountangles.map(r => ({uid: r.uid, parts: ["left", "top", "right", "bottom"]})),
|
||||
...copiedDiamonds.map(d => ({uid: d.uid, parts: ["left", "top", "right", "bottom"]})),
|
||||
...copiedArrows.map(a => ({uid: a.uid, parts: ["start", "end"]})),
|
||||
...copiedTexts.map(t => ({uid: t.uid, parts: ["text"]})),
|
||||
...copiedHistories.map(h => ({uid: h.uid, parts: ["history"]})),
|
||||
];
|
||||
return {
|
||||
...state,
|
||||
rountangles: [...state.rountangles, ...copiedRountangles],
|
||||
diamonds: [...state.diamonds, ...copiedDiamonds],
|
||||
arrows: [...state.arrows, ...copiedArrows],
|
||||
texts: [...state.texts, ...copiedTexts],
|
||||
history: [...state.history, ...copiedHistories],
|
||||
nextID: nextID,
|
||||
selection: newSelection,
|
||||
};
|
||||
});
|
||||
// copyInternal(newSelection, e); // doesn't work
|
||||
e.preventDefault();
|
||||
}
|
||||
}, [setState]);
|
||||
|
||||
const copyInternal = useCallback((state: VisualEditorState, selection: Selection, e: ClipboardEvent) => {
|
||||
const uidsToCopy = new Set(selection.map(shape => shape.uid));
|
||||
const rountanglesToCopy = state.rountangles.filter(r => uidsToCopy.has(r.uid));
|
||||
const diamondsToCopy = state.diamonds.filter(d => uidsToCopy.has(d.uid));
|
||||
const historiesToCopy = state.history.filter(h => uidsToCopy.has(h.uid));
|
||||
const arrowsToCopy = state.arrows.filter(a => uidsToCopy.has(a.uid));
|
||||
const textsToCopy = state.texts.filter(t => uidsToCopy.has(t.uid));
|
||||
e.clipboardData?.setData("text/plain", JSON.stringify({
|
||||
rountangles: rountanglesToCopy,
|
||||
diamonds: diamondsToCopy,
|
||||
history: historiesToCopy,
|
||||
arrows: arrowsToCopy,
|
||||
texts: textsToCopy,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const onCopy = useCallback((e: ClipboardEvent) => {
|
||||
if (selection.length > 0) {
|
||||
e.preventDefault();
|
||||
copyInternal(state, selection, e);
|
||||
}
|
||||
}, [state, selection]);
|
||||
|
||||
const onCut = useCallback((e: ClipboardEvent) => {
|
||||
if (selection.length > 0) {
|
||||
copyInternal(state, selection, e);
|
||||
deleteSelection();
|
||||
e.preventDefault();
|
||||
}
|
||||
}, [state, selection]);
|
||||
|
||||
const onEditText = useCallback((text: Text, newText: string) => {
|
||||
if (newText === "") {
|
||||
// delete text node
|
||||
|
|
@ -616,7 +175,7 @@ export const VisualEditor = memo(function VisualEditor({state, setState, trace,
|
|||
const size = 4000*zoom;
|
||||
|
||||
return <svg width={size} height={size}
|
||||
className={"svgCanvas"+(active.has("root")?" active":"")+(dragging ? " dragging" : "")}
|
||||
className={"svgCanvas"+(active.has("root")?" active":"")/*+(dragging ? " dragging" : "")*/}
|
||||
onMouseDown={onMouseDown}
|
||||
onContextMenu={e => e.preventDefault()}
|
||||
ref={refSVG}
|
||||
|
|
@ -689,20 +248,10 @@ export const VisualEditor = memo(function VisualEditor({state, setState, trace,
|
|||
|
||||
<Texts texts={state.texts} {...{selection, textsToHighlight, errors, onEditText, setModal}}/>
|
||||
|
||||
{selectingState && <Selecting {...selectingState} />}
|
||||
{selectionRect}
|
||||
</svg>;
|
||||
});
|
||||
|
||||
export function rountangleMinSize(size: Vec2D): Vec2D {
|
||||
if (size.x >= 40 && size.y >= 40) {
|
||||
return size;
|
||||
}
|
||||
return {
|
||||
x: Math.max(40, size.x),
|
||||
y: Math.max(40, size.y),
|
||||
};
|
||||
}
|
||||
|
||||
const Rountangles = memo(function Rountangles({rountangles, selection, sidesToHighlight, rountanglesToHighlight, errors, highlightActive}: {rountangles: Rountangle[], selection: Selection, sidesToHighlight: {[key: string]: RectSide[]}, rountanglesToHighlight: {[key: string]: boolean}, errors: TraceableError[], highlightActive: Mode}) {
|
||||
return <>{rountangles.map(rountangle => {
|
||||
return <RountangleSVG
|
||||
|
|
@ -765,13 +314,3 @@ const Texts = memo(function Texts({texts, selection, textsToHighlight, errors, o
|
|||
&& p.setModal === n.setModal;
|
||||
});
|
||||
|
||||
export function Selecting(props: SelectingState) {
|
||||
const normalizedRect = normalizeRect(props!);
|
||||
return <rect
|
||||
className="selecting"
|
||||
x={normalizedRect.topLeft.x}
|
||||
y={normalizedRect.topLeft.y}
|
||||
width={normalizedRect.size.x}
|
||||
height={normalizedRect.size.y}
|
||||
/>;
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import { Dispatch, SetStateAction } from "react";
|
||||
import { InsertMode } from "./VisualEditor";
|
||||
|
||||
export function getKeyHandler(setMode: Dispatch<SetStateAction<InsertMode>>) {
|
||||
return function onKeyDown(e: KeyboardEvent) {
|
||||
// don't capture keyboard events when focused on an input element:
|
||||
if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target?.tagName)) return;
|
||||
|
||||
if (!e.ctrlKey) {
|
||||
if (e.key === "a") {
|
||||
setMode("and");
|
||||
}
|
||||
if (e.key === "o") {
|
||||
setMode("or");
|
||||
}
|
||||
if (e.key === "p") {
|
||||
setMode("pseudo");
|
||||
}
|
||||
if (e.key === "t") {
|
||||
setMode("transition");
|
||||
}
|
||||
if (e.key === "x") {
|
||||
setMode("text");
|
||||
}
|
||||
if (e.key === "h") {
|
||||
setMode(oldMode => {
|
||||
if (oldMode === "shallow") return "deep";
|
||||
return "shallow";
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
135
src/App/VisualEditor/useCopyPaste.ts
Normal file
135
src/App/VisualEditor/useCopyPaste.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import { Arrow, Diamond, Rountangle, Text, History } from "@/statecharts/concrete_syntax";
|
||||
import { ClipboardEvent, Dispatch, SetStateAction, useCallback, useEffect } from "react";
|
||||
import { Selection, VisualEditorState } from "./VisualEditor";
|
||||
import { addV2D } from "@/util/geometry";
|
||||
|
||||
// const offset = {x: 40, y: 40};
|
||||
const offset = {x: 0, y: 0};
|
||||
|
||||
export function useCopyPaste(makeCheckPoint: () => void, state: VisualEditorState, setState: Dispatch<(v:VisualEditorState) => VisualEditorState>, selection: Selection) {
|
||||
const onPaste = useCallback((e: ClipboardEvent) => {
|
||||
const data = e.clipboardData?.getData("text/plain");
|
||||
if (data) {
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
setState(state => {
|
||||
try {
|
||||
let nextID = state.nextID;
|
||||
const copiedRountangles: Rountangle[] = parsed.rountangles.map((r: Rountangle) => ({
|
||||
...r,
|
||||
uid: (nextID++).toString(),
|
||||
topLeft: addV2D(r.topLeft, offset),
|
||||
} as Rountangle));
|
||||
const copiedDiamonds: Diamond[] = parsed.diamonds.map((r: Diamond) => ({
|
||||
...r,
|
||||
uid: (nextID++).toString(),
|
||||
topLeft: addV2D(r.topLeft, offset),
|
||||
} as Diamond));
|
||||
const copiedArrows: Arrow[] = parsed.arrows.map((a: Arrow) => ({
|
||||
...a,
|
||||
uid: (nextID++).toString(),
|
||||
start: addV2D(a.start, offset),
|
||||
end: addV2D(a.end, offset),
|
||||
} as Arrow));
|
||||
const copiedTexts: Text[] = parsed.texts.map((t: Text) => ({
|
||||
...t,
|
||||
uid: (nextID++).toString(),
|
||||
topLeft: addV2D(t.topLeft, offset),
|
||||
} as Text));
|
||||
const copiedHistories: History[] = parsed.history.map((h: History) => ({
|
||||
...h,
|
||||
uid: (nextID++).toString(),
|
||||
topLeft: addV2D(h.topLeft, offset),
|
||||
}))
|
||||
// @ts-ignore
|
||||
const newSelection: Selection = [
|
||||
...copiedRountangles.map(r => ({uid: r.uid, parts: ["left", "top", "right", "bottom"]})),
|
||||
...copiedDiamonds.map(d => ({uid: d.uid, parts: ["left", "top", "right", "bottom"]})),
|
||||
...copiedArrows.map(a => ({uid: a.uid, parts: ["start", "end"]})),
|
||||
...copiedTexts.map(t => ({uid: t.uid, parts: ["text"]})),
|
||||
...copiedHistories.map(h => ({uid: h.uid, parts: ["history"]})),
|
||||
];
|
||||
makeCheckPoint();
|
||||
return {
|
||||
...state,
|
||||
rountangles: [...state.rountangles, ...copiedRountangles],
|
||||
diamonds: [...state.diamonds, ...copiedDiamonds],
|
||||
arrows: [...state.arrows, ...copiedArrows],
|
||||
texts: [...state.texts, ...copiedTexts],
|
||||
history: [...state.history, ...copiedHistories],
|
||||
nextID: nextID,
|
||||
selection: newSelection,
|
||||
};
|
||||
}
|
||||
catch (e) {
|
||||
console.warn("error pasting data. most likely you're tying to paste nonsense. ", e);
|
||||
return state;
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (e) {
|
||||
console.warn("error pasting data. most likely you're tying to paste nonsense. ", e);
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
}, [setState]);
|
||||
|
||||
const copyInternal = useCallback((state: VisualEditorState, selection: Selection, e: ClipboardEvent) => {
|
||||
const uidsToCopy = new Set(selection.map(shape => shape.uid));
|
||||
const rountanglesToCopy = state.rountangles.filter(r => uidsToCopy.has(r.uid));
|
||||
const diamondsToCopy = state.diamonds.filter(d => uidsToCopy.has(d.uid));
|
||||
const historiesToCopy = state.history.filter(h => uidsToCopy.has(h.uid));
|
||||
const arrowsToCopy = state.arrows.filter(a => uidsToCopy.has(a.uid));
|
||||
const textsToCopy = state.texts.filter(t => uidsToCopy.has(t.uid));
|
||||
e.clipboardData?.setData("text/plain", JSON.stringify({
|
||||
rountangles: rountanglesToCopy,
|
||||
diamonds: diamondsToCopy,
|
||||
history: historiesToCopy,
|
||||
arrows: arrowsToCopy,
|
||||
texts: textsToCopy,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const onCopy = useCallback((e: ClipboardEvent) => {
|
||||
if (selection.length > 0) {
|
||||
e.preventDefault();
|
||||
copyInternal(state, selection, e);
|
||||
}
|
||||
}, [state, selection]);
|
||||
|
||||
const onCut = useCallback((e: ClipboardEvent) => {
|
||||
if (selection.length > 0) {
|
||||
copyInternal(state, selection, e);
|
||||
deleteSelection();
|
||||
e.preventDefault();
|
||||
}
|
||||
}, [state, selection]);
|
||||
|
||||
const deleteSelection = useCallback(() => {
|
||||
setState(state => ({
|
||||
...state,
|
||||
rountangles: state.rountangles.filter(r => !state.selection.some(rs => rs.uid === r.uid)),
|
||||
diamonds: state.diamonds.filter(d => !state.selection.some(ds => ds.uid === d.uid)),
|
||||
history: state.history.filter(h => !state.selection.some(hs => hs.uid === h.uid)),
|
||||
arrows: state.arrows.filter(a => !state.selection.some(as => as.uid === a.uid)),
|
||||
texts: state.texts.filter(t => !state.selection.some(ts => ts.uid === t.uid)),
|
||||
selection: [],
|
||||
}));
|
||||
}, [setState]);
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Delete") {
|
||||
// delete selection
|
||||
makeCheckPoint();
|
||||
deleteSelection();
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
})
|
||||
|
||||
return {onCopy, onPaste, onCut, deleteSelection};
|
||||
}
|
||||
344
src/App/VisualEditor/useMouse.tsx
Normal file
344
src/App/VisualEditor/useMouse.tsx
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
import { rountangleMinSize } from "@/statecharts/concrete_syntax";
|
||||
import { addV2D, area, isEntirelyWithin, normalizeRect, scaleV2D, subtractV2D, transformLine, transformRect } from "@/util/geometry";
|
||||
import { getBBoxInSvgCoords } from "@/util/svg_helper";
|
||||
import { Dispatch, useCallback, useEffect, useState } from "react";
|
||||
import { MIN_ROUNTANGLE_SIZE } from "../parameters";
|
||||
import { InsertMode } from "../TopPanel/InsertModes";
|
||||
import { Selecting, SelectingState } from "./Selection";
|
||||
import { Selection, VisualEditorState } from "./VisualEditor";
|
||||
|
||||
export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoom: number, refSVG: {current: SVGSVGElement|null}, state: VisualEditorState, setState: Dispatch<(v: VisualEditorState) => VisualEditorState>, deleteSelection: () => void) {
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
// not null while the user is making a selection
|
||||
const [selectingState, setSelectingState] = useState<SelectingState>(null);
|
||||
|
||||
const selection = state.selection;
|
||||
const setSelection = useCallback((cb: (oldSelection: Selection) => Selection) =>
|
||||
setState(oldState => ({...oldState, selection: cb(oldState.selection)})),[setState]);
|
||||
|
||||
const getCurrentPointer = useCallback((e: {pageX: number, pageY: number}) => {
|
||||
const bbox = refSVG.current!.getBoundingClientRect();
|
||||
return {
|
||||
x: (e.pageX - bbox.left)/zoom,
|
||||
y: (e.pageY - bbox.top)/zoom,
|
||||
}
|
||||
}, [refSVG.current, zoom]);
|
||||
|
||||
const onMouseDown = useCallback((e: {button: number, target: any, pageX: number, pageY: number}) => {
|
||||
const currentPointer = getCurrentPointer(e);
|
||||
if (e.button === 2) {
|
||||
makeCheckPoint();
|
||||
// ignore selection, middle mouse button always inserts
|
||||
setState(state => {
|
||||
const newID = state.nextID.toString();
|
||||
if (insertMode === "and" || insertMode === "or") {
|
||||
// insert rountangle
|
||||
return {
|
||||
...state,
|
||||
rountangles: [...state.rountangles, {
|
||||
uid: newID,
|
||||
topLeft: currentPointer,
|
||||
size: MIN_ROUNTANGLE_SIZE,
|
||||
kind: insertMode,
|
||||
}],
|
||||
nextID: state.nextID+1,
|
||||
selection: [{uid: newID, parts: ["bottom", "right"]}],
|
||||
};
|
||||
}
|
||||
else if (insertMode === "pseudo") {
|
||||
return {
|
||||
...state,
|
||||
diamonds: [...state.diamonds, {
|
||||
uid: newID,
|
||||
topLeft: currentPointer,
|
||||
size: MIN_ROUNTANGLE_SIZE,
|
||||
}],
|
||||
nextID: state.nextID+1,
|
||||
selection: [{uid: newID, parts: ["bottom", "right"]}],
|
||||
};
|
||||
}
|
||||
else if (insertMode === "shallow" || insertMode === "deep") {
|
||||
return {
|
||||
...state,
|
||||
history: [...state.history, {
|
||||
uid: newID,
|
||||
kind: insertMode,
|
||||
topLeft: currentPointer,
|
||||
}],
|
||||
nextID: state.nextID+1,
|
||||
selection: [{uid: newID, parts: ["history"]}],
|
||||
}
|
||||
}
|
||||
else if (insertMode === "transition") {
|
||||
return {
|
||||
...state,
|
||||
arrows: [...state.arrows, {
|
||||
uid: newID,
|
||||
start: currentPointer,
|
||||
end: currentPointer,
|
||||
}],
|
||||
nextID: state.nextID+1,
|
||||
selection: [{uid: newID, parts: ["end"]}],
|
||||
}
|
||||
}
|
||||
else if (insertMode === "text") {
|
||||
return {
|
||||
...state,
|
||||
texts: [...state.texts, {
|
||||
uid: newID,
|
||||
text: "// Double-click to edit",
|
||||
topLeft: currentPointer,
|
||||
}],
|
||||
nextID: state.nextID+1,
|
||||
selection: [{uid: newID, parts: ["text"]}],
|
||||
}
|
||||
}
|
||||
throw new Error("unreachable, mode=" + insertMode); // shut up typescript
|
||||
});
|
||||
setDragging(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.button === 0) {
|
||||
// left mouse button on a shape will drag that shape (and everything else that's selected). if the shape under the pointer was not in the selection then the selection is reset to contain only that shape.
|
||||
const uid = e.target?.dataset.uid;
|
||||
const parts: string[] = e.target?.dataset.parts?.split(' ').filter((p:string) => p!=="") || [];
|
||||
if (uid && parts.length > 0) {
|
||||
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;
|
||||
for (const part of parts) {
|
||||
if (!(selection.find(s => s.uid === uid)?.parts || [] as string[]).includes(part)) {
|
||||
allPartsInSelection = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!allPartsInSelection) {
|
||||
if (e.target.classList.contains("helper")) {
|
||||
setSelection(() => [{uid, parts}] as Selection);
|
||||
}
|
||||
else {
|
||||
setDragging(false);
|
||||
setSelectingState({
|
||||
topLeft: currentPointer,
|
||||
size: {x: 0, y: 0},
|
||||
});
|
||||
setSelection(() => []);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// start dragging
|
||||
setDragging(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// otherwise, just start making a selection
|
||||
setDragging(false);
|
||||
setSelectingState({
|
||||
topLeft: currentPointer,
|
||||
size: {x: 0, y: 0},
|
||||
});
|
||||
setSelection(() => []);
|
||||
}, [getCurrentPointer, makeCheckPoint, insertMode, selection]);
|
||||
|
||||
const onMouseMove = useCallback((e: {pageX: number, pageY: number, movementX: number, movementY: number}) => {
|
||||
const currentPointer = getCurrentPointer(e);
|
||||
if (dragging) {
|
||||
// const pointerDelta = subtractV2D(currentPointer, dragging.lastMousePos);
|
||||
const pointerDelta = {x: e.movementX/zoom, y: e.movementY/zoom};
|
||||
setState(state => ({
|
||||
...state,
|
||||
rountangles: state.rountangles.map(r => {
|
||||
const parts = state.selection.find(selected => selected.uid === r.uid)?.parts || [];
|
||||
if (parts.length === 0) {
|
||||
return r;
|
||||
}
|
||||
return {
|
||||
...r,
|
||||
...transformRect(r, parts, pointerDelta),
|
||||
};
|
||||
})
|
||||
.toSorted((a,b) => area(b) - area(a)), // sort: smaller rountangles are drawn on top
|
||||
diamonds: state.diamonds.map(d => {
|
||||
const parts = state.selection.find(selected => selected.uid === d.uid)?.parts || [];
|
||||
if (parts.length === 0) {
|
||||
return d;
|
||||
}
|
||||
return {
|
||||
...d,
|
||||
...transformRect(d, parts, pointerDelta),
|
||||
}
|
||||
}),
|
||||
history: state.history.map(h => {
|
||||
const parts = state.selection.find(selected => selected.uid === h.uid)?.parts || [];
|
||||
if (parts.length === 0) {
|
||||
return h;
|
||||
}
|
||||
return {
|
||||
...h,
|
||||
topLeft: addV2D(h.topLeft, pointerDelta),
|
||||
}
|
||||
}),
|
||||
arrows: state.arrows.map(a => {
|
||||
const parts = state.selection.find(selected => selected.uid === a.uid)?.parts || [];
|
||||
if (parts.length === 0) {
|
||||
return a;
|
||||
}
|
||||
return {
|
||||
...a,
|
||||
...transformLine(a, parts, pointerDelta),
|
||||
}
|
||||
}),
|
||||
texts: state.texts.map(t => {
|
||||
const parts = state.selection.find(selected => selected.uid === t.uid)?.parts || [];
|
||||
if (parts.length === 0) {
|
||||
return t;
|
||||
}
|
||||
return {
|
||||
...t,
|
||||
topLeft: addV2D(t.topLeft, pointerDelta),
|
||||
}
|
||||
}),
|
||||
}));
|
||||
setDragging(true);
|
||||
}
|
||||
else if (selectingState) {
|
||||
setSelectingState(ss => {
|
||||
const selectionSize = subtractV2D(currentPointer, ss!.topLeft);
|
||||
return {
|
||||
...ss!,
|
||||
size: selectionSize,
|
||||
};
|
||||
});
|
||||
}
|
||||
}, [getCurrentPointer, selectingState, dragging]);
|
||||
|
||||
const onMouseUp = useCallback((e: {target: any, pageX: number, pageY: number}) => {
|
||||
if (dragging) {
|
||||
setDragging(false);
|
||||
// do not persist sizes smaller than 40x40
|
||||
setState(state => {
|
||||
return {
|
||||
...state,
|
||||
rountangles: state.rountangles.map(r => ({
|
||||
...r,
|
||||
size: rountangleMinSize(r.size),
|
||||
})),
|
||||
diamonds: state.diamonds.map(d => ({
|
||||
...d,
|
||||
size: rountangleMinSize(d.size),
|
||||
}))
|
||||
};
|
||||
});
|
||||
}
|
||||
if (selectingState) {
|
||||
if (selectingState.size.x === 0 && selectingState.size.y === 0) {
|
||||
const uid = e.target?.dataset.uid;
|
||||
if (uid) {
|
||||
const parts = e.target?.dataset.parts.split(' ').filter((p: string) => p!=="");
|
||||
if (uid) {
|
||||
setSelection(() => [{
|
||||
uid,
|
||||
parts,
|
||||
}]);
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
// we were making a selection
|
||||
const normalizedSS = normalizeRect(selectingState);
|
||||
const shapes = Array.from(refSVG.current?.querySelectorAll("rect, line, circle, text") || []) as SVGGraphicsElement[];
|
||||
const shapesInSelection = shapes.filter(el => {
|
||||
const bbox = getBBoxInSvgCoords(el, refSVG.current!);
|
||||
const scaledBBox = {
|
||||
topLeft: scaleV2D(bbox.topLeft, 1/zoom),
|
||||
size: scaleV2D(bbox.size, 1/zoom),
|
||||
}
|
||||
return isEntirelyWithin(scaledBBox, normalizedSS);
|
||||
}).filter(el => !el.classList.contains("corner"));
|
||||
|
||||
const uidToParts = new Map();
|
||||
for (const shape of shapesInSelection) {
|
||||
const uid = shape.dataset.uid;
|
||||
if (uid) {
|
||||
const parts: Set<string> = uidToParts.get(uid) || new Set();
|
||||
for (const part of shape.dataset.parts?.split(' ') || []) {
|
||||
parts.add(part);
|
||||
}
|
||||
uidToParts.set(uid, parts);
|
||||
}
|
||||
}
|
||||
setSelection(() => [...uidToParts.entries()].map(([uid,parts]) => ({
|
||||
uid,
|
||||
parts: [...parts],
|
||||
})));
|
||||
}
|
||||
}
|
||||
setSelectingState(null); // no longer making a selection
|
||||
}, [dragging, selectingState, refSVG.current]);
|
||||
|
||||
const onKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
// don't capture keyboard events when focused on an input element:
|
||||
// @ts-ignore
|
||||
if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target?.tagName)) return;
|
||||
|
||||
if (e.key === "o") {
|
||||
// selected states become OR-states
|
||||
setState(state => ({
|
||||
...state,
|
||||
rountangles: state.rountangles.map(r => state.selection.some(rs => rs.uid === r.uid) ? ({...r, kind: "or"}) : r),
|
||||
}));
|
||||
}
|
||||
if (e.key === "a") {
|
||||
// selected states become AND-states
|
||||
setState(state => ({
|
||||
...state,
|
||||
rountangles: state.rountangles.map(r => state.selection.some(rs => rs.uid === r.uid) ? ({...r, kind: "and"}) : r),
|
||||
}));
|
||||
}
|
||||
// if (e.key === "p") {
|
||||
// // selected states become pseudo-states
|
||||
// setSelection(selection => {
|
||||
// setState(state => ({
|
||||
// ...state,
|
||||
// rountangles: state.rountangles.map(r => selection.some(rs => rs.uid === r.uid) ? ({...r, kind: "pseudo"}) : r),
|
||||
// }));
|
||||
// return selection;
|
||||
// });
|
||||
// }
|
||||
if (e.ctrlKey) {
|
||||
if (e.key === "a") {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
setState(state => ({
|
||||
...state,
|
||||
// @ts-ignore
|
||||
selection: [
|
||||
...state.rountangles.map(r => ({uid: r.uid, parts: ["left", "top", "right", "bottom"]})),
|
||||
...state.diamonds.map(d => ({uid: d.uid, parts: ["left", "top", "right", "bottom"]})),
|
||||
...state.arrows.map(a => ({uid: a.uid, parts: ["start", "end"]})),
|
||||
...state.texts.map(t => ({uid: t.uid, parts: ["text"]})),
|
||||
]
|
||||
}))
|
||||
}
|
||||
}
|
||||
}, [makeCheckPoint, deleteSelection, setState, setDragging]);
|
||||
|
||||
useEffect(() => {
|
||||
// mousemove and mouseup are global event handlers so they keep working when pointer is outside of browser window
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [selectingState, dragging]);
|
||||
|
||||
return {onMouseDown, selectionRect: selectingState && <Selecting {...selectingState} />};
|
||||
}
|
||||
225
src/App/useEditor.ts
Normal file
225
src/App/useEditor.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import { addV2D, rotateLine90CCW, rotateLine90CW, rotatePoint90CCW, rotatePoint90CW, rotateRect90CCW, rotateRect90CW, scaleV2D, subtractV2D, Vec2D } from "@/util/geometry";
|
||||
import { HISTORY_RADIUS } from "./parameters";
|
||||
import { Dispatch, SetStateAction, useCallback, useEffect } from "react";
|
||||
import { EditHistory } from "./App";
|
||||
import { VisualEditorState } from "./VisualEditor/VisualEditor";
|
||||
import { emptyState } from "@/statecharts/concrete_syntax";
|
||||
|
||||
export function useEditor(editorState: VisualEditorState | null, setEditHistory: Dispatch<SetStateAction<EditHistory|null>>) {
|
||||
useEffect(() => {
|
||||
console.log("Welcome to StateBuddy!");
|
||||
() => {
|
||||
console.log("Goodbye!");
|
||||
}
|
||||
}, []);
|
||||
|
||||
// recover editor state from URL - we need an effect here because decompression is asynchronous
|
||||
useEffect(() => {
|
||||
console.log('recovering state...');
|
||||
const compressedState = window.location.hash.slice(1);
|
||||
if (compressedState.length === 0) {
|
||||
// empty URL hash
|
||||
console.log("no state to recover");
|
||||
setEditHistory(() => ({current: emptyState, history: [], future: []}));
|
||||
return;
|
||||
}
|
||||
let compressedBuffer;
|
||||
try {
|
||||
compressedBuffer = Uint8Array.fromBase64(compressedState); // may throw
|
||||
} catch (e) {
|
||||
// probably invalid base64
|
||||
console.error("failed to recover state:", e);
|
||||
setEditHistory(() => ({current: emptyState, history: [], future: []}));
|
||||
return;
|
||||
}
|
||||
const ds = new DecompressionStream("deflate");
|
||||
const writer = ds.writable.getWriter();
|
||||
writer.write(compressedBuffer).catch(() => {}); // any promise rejections will be detected when we try to read
|
||||
writer.close().catch(() => {});
|
||||
new Response(ds.readable).arrayBuffer()
|
||||
.then(decompressedBuffer => {
|
||||
const recoveredState = JSON.parse(new TextDecoder().decode(decompressedBuffer));
|
||||
setEditHistory(() => ({current: recoveredState, history: [], future: []}));
|
||||
})
|
||||
.catch(e => {
|
||||
// any other error: invalid JSON, or decompression failed.
|
||||
console.error("failed to recover state:", e);
|
||||
setEditHistory({current: emptyState, history: [], future: []});
|
||||
});
|
||||
}, []);
|
||||
|
||||
// save editor state in URL
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (editorState === null) {
|
||||
window.location.hash = "#";
|
||||
return;
|
||||
}
|
||||
const serializedState = JSON.stringify(editorState);
|
||||
const stateBuffer = new TextEncoder().encode(serializedState);
|
||||
const cs = new CompressionStream("deflate");
|
||||
const writer = cs.writable.getWriter();
|
||||
writer.write(stateBuffer);
|
||||
writer.close();
|
||||
// todo: cancel this promise handler when concurrently starting another compression job
|
||||
new Response(cs.readable).arrayBuffer().then(compressedStateBuffer => {
|
||||
const compressedStateString = new Uint8Array(compressedStateBuffer).toBase64();
|
||||
window.location.hash = "#"+compressedStateString;
|
||||
});
|
||||
}, 100);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [editorState]);
|
||||
|
||||
|
||||
// append editor state to undo history
|
||||
const makeCheckPoint = useCallback(() => {
|
||||
setEditHistory(historyState => historyState && ({
|
||||
...historyState,
|
||||
history: [...historyState.history, historyState.current],
|
||||
future: [],
|
||||
}));
|
||||
}, [setEditHistory]);
|
||||
const onUndo = useCallback(() => {
|
||||
setEditHistory(historyState => {
|
||||
if (historyState === null) return null;
|
||||
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],
|
||||
}
|
||||
})
|
||||
}, [setEditHistory]);
|
||||
const onRedo = useCallback(() => {
|
||||
setEditHistory(historyState => {
|
||||
if (historyState === null) return null;
|
||||
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),
|
||||
}
|
||||
});
|
||||
}, [setEditHistory]);
|
||||
const onRotate = useCallback((direction: "ccw" | "cw") => {
|
||||
makeCheckPoint();
|
||||
setEditHistory(historyState => {
|
||||
if (historyState === null) return null;
|
||||
|
||||
const selection = historyState.current.selection;
|
||||
|
||||
if (selection.length === 0) {
|
||||
return historyState;
|
||||
}
|
||||
|
||||
// determine bounding box... in a convoluted manner
|
||||
let minX = -Infinity, minY = -Infinity, maxX = Infinity, maxY = Infinity;
|
||||
|
||||
function addPointToBBox({x,y}: Vec2D) {
|
||||
minX = Math.max(minX, x);
|
||||
minY = Math.max(minY, y);
|
||||
maxX = Math.min(maxX, x);
|
||||
maxY = Math.min(maxY, y);
|
||||
}
|
||||
|
||||
for (const rt of historyState.current.rountangles) {
|
||||
if (selection.some(s => s.uid === rt.uid)) {
|
||||
addPointToBBox(rt.topLeft);
|
||||
addPointToBBox(addV2D(rt.topLeft, rt.size));
|
||||
}
|
||||
}
|
||||
for (const d of historyState.current.diamonds) {
|
||||
if (selection.some(s => s.uid === d.uid)) {
|
||||
addPointToBBox(d.topLeft);
|
||||
addPointToBBox(addV2D(d.topLeft, d.size));
|
||||
}
|
||||
}
|
||||
for (const arr of historyState.current.arrows) {
|
||||
if (selection.some(s => s.uid === arr.uid)) {
|
||||
addPointToBBox(arr.start);
|
||||
addPointToBBox(arr.end);
|
||||
}
|
||||
}
|
||||
for (const txt of historyState.current.texts) {
|
||||
if (selection.some(s => s.uid === txt.uid)) {
|
||||
addPointToBBox(txt.topLeft);
|
||||
}
|
||||
}
|
||||
const historySize = {x: HISTORY_RADIUS, y: HISTORY_RADIUS};
|
||||
for (const h of historyState.current.history) {
|
||||
if (selection.some(s => s.uid === h.uid)) {
|
||||
addPointToBBox(h.topLeft);
|
||||
addPointToBBox(addV2D(h.topLeft, scaleV2D(historySize, 2)));
|
||||
}
|
||||
}
|
||||
|
||||
const center: Vec2D = {
|
||||
x: (minX + maxX) / 2,
|
||||
y: (minY + maxY) / 2,
|
||||
};
|
||||
|
||||
const mapIfSelected = (shape: {uid: string}, cb: (shape:any)=>any) => {
|
||||
if (selection.some(s => s.uid === shape.uid)) {
|
||||
return cb(shape);
|
||||
}
|
||||
else {
|
||||
return shape;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...historyState,
|
||||
current: {
|
||||
...historyState.current,
|
||||
rountangles: historyState.current.rountangles.map(rt => mapIfSelected(rt, rt => {
|
||||
return {
|
||||
...rt,
|
||||
...(direction === "ccw"
|
||||
? rotateRect90CCW(rt, center)
|
||||
: rotateRect90CW(rt, center)),
|
||||
}
|
||||
})),
|
||||
arrows: historyState.current.arrows.map(arr => mapIfSelected(arr, arr => {
|
||||
return {
|
||||
...arr,
|
||||
...(direction === "ccw"
|
||||
? rotateLine90CCW(arr, center)
|
||||
: rotateLine90CW(arr, center)),
|
||||
};
|
||||
})),
|
||||
diamonds: historyState.current.diamonds.map(d => mapIfSelected(d, d => {
|
||||
return {
|
||||
...d,
|
||||
...(direction === "ccw"
|
||||
? rotateRect90CCW(d, center)
|
||||
: rotateRect90CW(d, center)),
|
||||
};
|
||||
})),
|
||||
texts: historyState.current.texts.map(txt => mapIfSelected(txt, txt => {
|
||||
return {
|
||||
...txt,
|
||||
topLeft: (direction === "ccw"
|
||||
? rotatePoint90CCW(txt.topLeft, center)
|
||||
: rotatePoint90CW(txt.topLeft, center)),
|
||||
};
|
||||
})),
|
||||
history: historyState.current.history.map(h => mapIfSelected(h, h => {
|
||||
return {
|
||||
...h,
|
||||
topLeft: (direction === "ccw"
|
||||
? subtractV2D(rotatePoint90CCW(addV2D(h.topLeft, historySize), center), historySize)
|
||||
: subtractV2D(rotatePoint90CW(addV2D(h.topLeft, historySize), center), historySize)
|
||||
),
|
||||
};
|
||||
})),
|
||||
},
|
||||
}
|
||||
})
|
||||
}, [setEditHistory]);
|
||||
|
||||
return {makeCheckPoint, onUndo, onRedo, onRotate};
|
||||
}
|
||||
3
src/App/useSimulator.ts
Normal file
3
src/App/useSimulator.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function useSimulator() {
|
||||
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { Rect2D, Vec2D, Line2D, euclideanDistance, intersectLines, isWithin, lineBBox, subtractV2D } from "../util/geometry";
|
||||
import { ARROW_SNAP_THRESHOLD, HISTORY_RADIUS, TEXT_SNAP_THRESHOLD } from "../App/parameters";
|
||||
import { ARROW_SNAP_THRESHOLD, HISTORY_RADIUS, ROUNTANGLE_RADIUS, TEXT_SNAP_THRESHOLD } from "../App/parameters";
|
||||
import { VisualEditorState } from "../App/VisualEditor/VisualEditor";
|
||||
import { sides } from "@/util/geometry";
|
||||
|
||||
|
|
@ -123,3 +123,14 @@ export function findNearestHistory(point: Vec2D, candidates: History[]): History
|
|||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function rountangleMinSize(size: Vec2D): Vec2D {
|
||||
const minSize = ROUNTANGLE_RADIUS * 2;
|
||||
if (size.x >= minSize && size.y >= minSize) {
|
||||
return size;
|
||||
}
|
||||
return {
|
||||
x: Math.max(minSize, size.x),
|
||||
y: Math.max(minSize, size.y),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { AbstractState, computeArena, computePath, ConcreteState, getDescendants, HistoryState, isOverlapping, OrState, StableState, Statechart, stateDescription, Transition, transitionDescription, TransitionSrcTgt } from "./abstract_syntax";
|
||||
import { evalExpr } from "./actionlang_interpreter";
|
||||
import { Environment, FlatEnvironment, ScopedEnvironment } from "./environment";
|
||||
import { Environment, FlatEnvironment } from "./environment";
|
||||
import { Action, EventTrigger, TransitionLabel } from "./label_ast";
|
||||
import { BigStep, initialRaised, Mode, RaisedEvents, RT_Event, RT_History, RT_Statechart, TimerElapseEvent, Timers } from "./runtime_types";
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ export type TimedReactive<RT_Config> = {
|
|||
timeAdvance: (c: RT_Config) => number,
|
||||
intTransition: (c: RT_Config) => [RaisedEvent[], RT_Config],
|
||||
extTransition: (simtime: number, c: RT_Config, e: InputEvent) => [RaisedEvent[], RT_Config],
|
||||
|
||||
// inputEvents: string[],
|
||||
// outputEvents: string[],
|
||||
}
|
||||
|
||||
export function statechartExecution(ast: Statechart): TimedReactive<BigStep> {
|
||||
|
|
@ -32,7 +35,10 @@ export function statechartExecution(ast: Statechart): TimedReactive<BigStep> {
|
|||
extTransition: (simtime: number, c: RT_Statechart, e: InputEvent) => {
|
||||
const {outputEvents, ...rest} = handleInputEvent(simtime, e, ast, c);
|
||||
return [outputEvents, {outputEvents, ...rest}];
|
||||
}
|
||||
},
|
||||
|
||||
// inputEvents: ast.inputEvents.map(e => e.event),
|
||||
// outputEvents: [...ast.outputEvents],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -53,24 +59,24 @@ export type OutputDestination = {
|
|||
// kind: "nowhere",
|
||||
// };
|
||||
|
||||
export function exposeStatechartInputsOutputs(ast: Statechart, model: string): Conns {
|
||||
return {
|
||||
// all the coupled execution's input events become input events for the statechart
|
||||
inputEvents: exposeStatechartInputs(ast, model),
|
||||
outputEvents: exposeStatechartOutputs(ast, model),
|
||||
}
|
||||
}
|
||||
// export function exposeStatechartInputsOutputs(ast: Statechart, model: string): Conns {
|
||||
// return {
|
||||
// // all the coupled execution's input events become input events for the statechart
|
||||
// inputEvents: exposeStatechartInputs(ast, model),
|
||||
// outputEvents: exposeStatechartOutputs(ast, model),
|
||||
// }
|
||||
// }
|
||||
|
||||
export function exposeStatechartInputs(ast: Statechart, model: string, tfm = (s: string) => s): {[eventName: string]: ModelDestination} {
|
||||
return Object.fromEntries(ast.inputEvents.map(e => [tfm(e.event), {kind: "model", model, eventName: e.event}]));
|
||||
}
|
||||
// export function exposeStatechartInputs(ast: Statechart, model: string, tfm = (s: string) => s): {[eventName: string]: ModelDestination} {
|
||||
// return Object.fromEntries(ast.inputEvents.map(e => [tfm(e.event), {kind: "model", model, eventName: e.event}]));
|
||||
// }
|
||||
|
||||
export function exposeStatechartOutputs(ast: Statechart, model: string): {[modelName: string]: {[eventName: string]: EventDestination}} {
|
||||
return {
|
||||
// all the statechart's output events become output events of our coupled execution
|
||||
[model]: Object.fromEntries([...ast.outputEvents].map(e => [e, {kind: "output", model, eventName: e}])),
|
||||
};
|
||||
}
|
||||
// export function exposeStatechartOutputs(ast: Statechart, model: string): {[modelName: string]: {[eventName: string]: EventDestination}} {
|
||||
// return {
|
||||
// // all the statechart's output events become output events of our coupled execution
|
||||
// [model]: Object.fromEntries([...ast.outputEvents].map(e => [e, {kind: "output", model, eventName: e}])),
|
||||
// };
|
||||
// }
|
||||
|
||||
// export function hideStatechartOutputs(ast: Statechart, model: string) {
|
||||
// return {
|
||||
|
|
@ -78,15 +84,23 @@ export function exposeStatechartOutputs(ast: Statechart, model: string): {[model
|
|||
// }
|
||||
// }
|
||||
|
||||
export type Conns = {
|
||||
// inputs coming from outside are routed to the right models
|
||||
inputEvents: {[eventName: string]: ModelDestination},
|
||||
// export type Conns = {
|
||||
// // inputs coming from outside are routed to the right models
|
||||
// inputEvents: {[eventName: string]: ModelDestination},
|
||||
|
||||
// outputs coming from the models are routed to other models or to outside
|
||||
outputEvents: {[modelName: string]: {[eventName: string]: EventDestination}},
|
||||
}
|
||||
// // outputs coming from the models are routed to other models or to outside
|
||||
// outputEvents: {[modelName: string]: {[eventName: string]: EventDestination}},
|
||||
// }
|
||||
|
||||
export function coupledExecution<T extends {[name: string]: any}>(models: {[name in keyof T]: TimedReactive<T[name]>}, conns: Conns): TimedReactive<T> {
|
||||
|
||||
// maps source to target. e.g.:
|
||||
// {
|
||||
// "sc.incTime": ["plant", "incTime"],
|
||||
// "DEBUG_topRightClicked": ["sc", "topRightClicked"],
|
||||
// }
|
||||
export type Conns = {[eventName: string]: [string|null, string]};
|
||||
|
||||
export function coupledExecution<T extends {[name: string]: any}>(models: {[name in keyof T]: TimedReactive<T[name]>}, conns: Conns, /*inputEvents: string[], outputEvents: []*/): TimedReactive<T> {
|
||||
|
||||
function makeModelExtTransition(simtime: number, c: T, model: string, e: InputEvent) {
|
||||
const [outputEvents, newConfig] = models[model].extTransition(simtime, c[model], e);
|
||||
|
|
@ -100,33 +114,33 @@ export function coupledExecution<T extends {[name: string]: any}>(models: {[name
|
|||
function processOutputs(simtime: number, events: RaisedEvent[], model: string, c: T): [RaisedEvent[], T] {
|
||||
if (events.length > 0) {
|
||||
const [event, ...rest] = events;
|
||||
const destination = conns.outputEvents[model]?.[event.name];
|
||||
const destination = conns[model+'.'+event.name];
|
||||
if (destination === undefined) {
|
||||
// ignore
|
||||
console.log(`${model}.${event.name} goes nowhere`);
|
||||
return processOutputs(simtime, rest, model, c);
|
||||
}
|
||||
if (destination.kind === "model") {
|
||||
const [destinationModel, destinationEventName] = destination;
|
||||
if (destinationModel !== null) {
|
||||
// output event is input for another model
|
||||
console.log(`${model}.${event.name} goes to ${destination.model}.${destination.eventName}`);
|
||||
console.log(`${model}.${event.name} goes to ${destinationModel}.${destinationEventName}`);
|
||||
const inputEvent = {
|
||||
kind: "input" as const,
|
||||
name: destination.eventName,
|
||||
name: destinationEventName,
|
||||
param: event.param,
|
||||
};
|
||||
const [outputEvents, newConfig] = makeModelExtTransition(simtime, c, destination.model, inputEvent);
|
||||
const [outputEvents, newConfig] = makeModelExtTransition(simtime, c, destinationModel, inputEvent);
|
||||
|
||||
// proceed with 'rest':
|
||||
const [restOutputEvents, newConfig2] = processOutputs(simtime, rest, model, newConfig);
|
||||
return [[...outputEvents, ...restOutputEvents], newConfig2];
|
||||
}
|
||||
else if (destination.kind === "output") {
|
||||
// kind === "output"
|
||||
console.log(`${model}.${event.name} becomes ^${destination.eventName}`);
|
||||
else {
|
||||
// event is output event of our coupled execution
|
||||
console.log(`${model}.${event.name} becomes ^${destinationEventName}`);
|
||||
const [outputEvents, newConfig] = processOutputs(simtime, rest, model, c);
|
||||
return [[event, ...outputEvents], newConfig];
|
||||
}
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
else {
|
||||
return [[], c];
|
||||
|
|
@ -146,7 +160,6 @@ export function coupledExecution<T extends {[name: string]: any}>(models: {[name
|
|||
// @ts-ignore
|
||||
state[modelName] = modelState;
|
||||
}
|
||||
console.log({state});
|
||||
// 2. handle all output events (models' outputs may be inputs for each other)
|
||||
let finalOutputs = [];
|
||||
for (const [modelName, outputEvents] of allOutputs) {
|
||||
|
|
@ -175,12 +188,13 @@ export function coupledExecution<T extends {[name: string]: any}>(models: {[name
|
|||
throw new Error("cannot make intTransition - timeAdvance is infinity");
|
||||
},
|
||||
extTransition: (simtime, c, e) => {
|
||||
if (!Object.hasOwn(conns.inputEvents, e.name)) {
|
||||
if (!Object.hasOwn(conns, e.name)) {
|
||||
console.warn('input event', e.name, 'goes to nowhere');
|
||||
return [[], c];
|
||||
}
|
||||
else {
|
||||
const {model, eventName} = conns.inputEvents[e.name];
|
||||
const [model, eventName] = conns[e.name];
|
||||
if (model !== null) {
|
||||
console.log('input event', e.name, 'goes to', `${model}.${eventName}`);
|
||||
const inputEvent: InputEvent = {
|
||||
kind: "input",
|
||||
|
|
@ -189,7 +203,13 @@ export function coupledExecution<T extends {[name: string]: any}>(models: {[name
|
|||
};
|
||||
return makeModelExtTransition(simtime, c, model, inputEvent);
|
||||
}
|
||||
else {
|
||||
throw new Error("not implemented: input event becoming output event right away.")
|
||||
}
|
||||
}
|
||||
},
|
||||
// inputEvents,
|
||||
// outputEvents,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue