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 "../index.css";
|
||||||
import "./App.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 { 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 { RuntimeError } from "../statecharts/interpreter";
|
||||||
import { parseStatechart } from "../statecharts/parser";
|
import { parseStatechart } from "../statecharts/parser";
|
||||||
import { BigStep, RaisedEvent } from "../statecharts/runtime_types";
|
import { BigStep, RaisedEvent } from "../statecharts/runtime_types";
|
||||||
|
|
@ -13,17 +12,17 @@ import { getSimTime, getWallClkDelay, TimeMode } from "../statecharts/time";
|
||||||
import { BottomPanel } from "./BottomPanel";
|
import { BottomPanel } from "./BottomPanel";
|
||||||
import { usePersistentState } from "./persistent_state";
|
import { usePersistentState } from "./persistent_state";
|
||||||
import { PersistentDetails } from "./PersistentDetails";
|
import { PersistentDetails } from "./PersistentDetails";
|
||||||
import { DummyPlant } from "./Plant/Dummy/Dummy";
|
import { dummyPlant } from "./Plant/Dummy/Dummy";
|
||||||
import { MicrowavePlant } from "./Plant/Microwave/Microwave";
|
import { microwavePlant } from "./Plant/Microwave/Microwave";
|
||||||
import { autoConnect, exposePlantInputs, Plant } from "./Plant/Plant";
|
import { Plant } from "./Plant/Plant";
|
||||||
import { RTHistory } from "./RTHistory";
|
import { RTHistory } from "./RTHistory";
|
||||||
import { ShowAST, ShowInputEvents, ShowInternalEvents, ShowOutputEvents } from "./ShowAST";
|
import { ShowAST, ShowInputEvents, ShowInternalEvents, ShowOutputEvents } from "./ShowAST";
|
||||||
import { TopPanel } from "./TopPanel/TopPanel";
|
import { TopPanel } from "./TopPanel/TopPanel";
|
||||||
import { getKeyHandler } from "./VisualEditor/shortcut_handler";
|
import { VisualEditor, VisualEditorState } from "./VisualEditor/VisualEditor";
|
||||||
import { InsertMode, VisualEditor, VisualEditorState } from "./VisualEditor/VisualEditor";
|
import { digitalWatchPlant } from "./Plant/DigitalWatch/DigitalWatch";
|
||||||
import { addV2D, rotateLine90CCW, rotateLine90CW, rotatePoint90CCW, rotatePoint90CW, rotateRect90CCW, rotateRect90CW, scaleV2D, subtractV2D, Vec2D } from "@/util/geometry";
|
import { useEditor as useEditor } from "./useEditor";
|
||||||
import { HISTORY_RADIUS } from "./parameters";
|
import { InsertMode } from "./TopPanel/InsertModes";
|
||||||
import { DigitalWatchPlant } from "./Plant/DigitalWatch/DigitalWatch";
|
import { Statechart } from "@/statecharts/abstract_syntax";
|
||||||
|
|
||||||
export type EditHistory = {
|
export type EditHistory = {
|
||||||
current: VisualEditorState,
|
current: VisualEditorState,
|
||||||
|
|
@ -32,9 +31,9 @@ export type EditHistory = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const plants: [string, Plant<any>][] = [
|
const plants: [string, Plant<any>][] = [
|
||||||
["dummy", DummyPlant],
|
["dummy", dummyPlant],
|
||||||
["microwave", MicrowavePlant],
|
["microwave", microwavePlant],
|
||||||
["digital watch", DigitalWatchPlant],
|
["digital watch", digitalWatchPlant],
|
||||||
]
|
]
|
||||||
|
|
||||||
export type TraceItemError = {
|
export type TraceItemError = {
|
||||||
|
|
@ -58,25 +57,8 @@ export type TraceState = {
|
||||||
idx: number,
|
idx: number,
|
||||||
}; // <-- null if there is no trace
|
}; // <-- 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() {
|
export function App() {
|
||||||
const [insertMode, setInsertMode] = useState<InsertMode>("and");
|
const [insertMode, setInsertMode] = usePersistentState<InsertMode>("insertMode", "and");
|
||||||
const [editHistory, setEditHistory] = useState<EditHistory|null>(null);
|
const [editHistory, setEditHistory] = useState<EditHistory|null>(null);
|
||||||
const [trace, setTrace] = useState<TraceState|null>(null);
|
const [trace, setTrace] = useState<TraceState|null>(null);
|
||||||
const [time, setTime] = useState<TimeMode>({kind: "paused", simtime: 0});
|
const [time, setTime] = useState<TimeMode>({kind: "paused", simtime: 0});
|
||||||
|
|
@ -86,6 +68,10 @@ export function App() {
|
||||||
const [zoom, setZoom] = usePersistentState("zoom", 1);
|
const [zoom, setZoom] = usePersistentState("zoom", 1);
|
||||||
const [showKeys, setShowKeys] = usePersistentState("shortcuts", true);
|
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 plant = plants.find(([pn, p]) => pn === plantName)![1];
|
||||||
|
|
||||||
const editorState = editHistory && editHistory.current;
|
const editorState = editHistory && editHistory.current;
|
||||||
|
|
@ -93,63 +79,6 @@ export function App() {
|
||||||
setEditHistory(historyState => historyState && ({...historyState, current: cb(historyState.current)}));
|
setEditHistory(historyState => historyState && ({...historyState, current: cb(historyState.current)}));
|
||||||
}, [setEditHistory]);
|
}, [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);
|
const refRightSideBar = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// parse concrete syntax always:
|
// parse concrete syntax always:
|
||||||
|
|
@ -166,177 +95,42 @@ export function App() {
|
||||||
}] : [],
|
}] : [],
|
||||||
]
|
]
|
||||||
|
|
||||||
// append editor state to undo history
|
const {makeCheckPoint, onRedo, onUndo, onRotate} = useEditor(editorState, setEditHistory);
|
||||||
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 scrollDownSidebar = useCallback(() => {
|
const scrollDownSidebar = useCallback(() => {
|
||||||
if (refRightSideBar.current) {
|
if (autoScroll && refRightSideBar.current) {
|
||||||
const el = refRightSideBar.current;
|
const el = refRightSideBar.current;
|
||||||
// hack: we want to scroll to the new element, but we have to wait until it is rendered...
|
// hack: we want to scroll to the new element, but we have to wait until it is rendered...
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
el.scrollIntoView({block: "end", behavior: "smooth"});
|
el.scrollIntoView({block: "end", behavior: "smooth"});
|
||||||
}, 50);
|
}, 50);
|
||||||
}
|
}
|
||||||
}, [refRightSideBar.current]);
|
}, [refRightSideBar.current, autoScroll]);
|
||||||
|
|
||||||
const plantConns = ast && ({
|
// const plantConns = ast && ({
|
||||||
inputEvents: {
|
// inputEvents: {
|
||||||
...exposeStatechartInputs(ast, "sc", (eventName: string) => "DEBUG_"+eventName),
|
// // all SC inputs are directly triggerable from outside
|
||||||
...exposePlantInputs(plant, "plant", (eventName: string) => "PLANT_UI_"+eventName),
|
// ...exposeStatechartInputs(ast, "sc", (eventName: string) => "debug."+eventName),
|
||||||
},
|
|
||||||
outputEvents: autoConnect(ast, "sc", plant, "plant"),
|
// ...Object.fromEntries(plant.uiEvents.map(e => {
|
||||||
}) as Conns;
|
// 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({
|
const cE = useMemo(() => ast && coupledExecution({
|
||||||
sc: statechartExecution(ast),
|
sc: statechartExecution(ast),
|
||||||
plant: plant.execution,
|
plant: plant.execution,
|
||||||
}, plantConns!), [ast]);
|
}, {
|
||||||
|
...plantConns,
|
||||||
|
...Object.fromEntries(ast.inputEvents.map(({event}) => ["debug."+event, ['sc',event] as [string,string]])),
|
||||||
|
}), [ast]);
|
||||||
|
|
||||||
const onInit = useCallback(() => {
|
const onInit = useCallback(() => {
|
||||||
if (cE === null) return;
|
if (cE === null) return;
|
||||||
|
|
@ -448,7 +242,7 @@ export function App() {
|
||||||
scrollDownSidebar();
|
scrollDownSidebar();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onBack() {
|
const onBack = useCallback(() => {
|
||||||
if (trace !== null) {
|
if (trace !== null) {
|
||||||
setTime(() => {
|
setTime(() => {
|
||||||
if (trace !== null) {
|
if (trace !== null) {
|
||||||
|
|
@ -464,22 +258,7 @@ export function App() {
|
||||||
idx: trace.idx-1,
|
idx: trace.idx-1,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}, [trace]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.log("Welcome to StateBuddy!");
|
|
||||||
() => {
|
|
||||||
console.log("Goodbye!");
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onKeyDown = getKeyHandler(setInsertMode);
|
|
||||||
window.addEventListener("keydown", onKeyDown);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("keydown", onKeyDown);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const currentBigStep = currentTraceItem && currentTraceItem.kind === "bigstep" && currentTraceItem;
|
const currentBigStep = currentTraceItem && currentTraceItem.kind === "bigstep" && currentTraceItem;
|
||||||
const highlightActive = (currentBigStep && currentBigStep.state.sc.mode) || new Set();
|
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 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 <>
|
return <>
|
||||||
|
|
||||||
{/* Modal dialog */}
|
{/* Modal dialog */}
|
||||||
|
|
@ -523,7 +308,7 @@ export function App() {
|
||||||
{/* Editor */}
|
{/* Editor */}
|
||||||
<div style={{flexGrow: 1, overflow: "auto"}}>
|
<div style={{flexGrow: 1, overflow: "auto"}}>
|
||||||
{editorState && conns && syntaxErrors &&
|
{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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -550,7 +335,7 @@ export function App() {
|
||||||
<summary>input events</summary>
|
<summary>input events</summary>
|
||||||
{ast && <ShowInputEvents
|
{ast && <ShowInputEvents
|
||||||
inputEvents={ast.inputEvents}
|
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"}
|
disabled={trace===null || trace.trace[trace.idx].kind === "error"}
|
||||||
showKeys={showKeys}/>}
|
showKeys={showKeys}/>}
|
||||||
</PersistentDetails>
|
</PersistentDetails>
|
||||||
|
|
@ -572,13 +357,25 @@ export function App() {
|
||||||
<option>{plantName}</option>
|
<option>{plantName}</option>
|
||||||
)}
|
)}
|
||||||
</select>
|
</select>
|
||||||
{plantConns && <ShowConns {...plantConns} />}
|
{/* Render plant */}
|
||||||
{currentBigStep && <plant.render state={currentBigStep.state.plant} speed={speed}
|
{<plant.render state={plantState} speed={speed}
|
||||||
raiseInput={e => onRaise("PLANT_UI_"+e.name, e.param)}
|
raiseUIEvent={e => onRaise("plant.ui."+e.name, e.param)}
|
||||||
/>}
|
/>}
|
||||||
</PersistentDetails>
|
</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>
|
<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>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -622,11 +419,85 @@ function ShowEventDestination(dst: EventDestination) {
|
||||||
|
|
||||||
function ShowConns({inputEvents, outputEvents}: Conns) {
|
function ShowConns({inputEvents, outputEvents}: Conns) {
|
||||||
return <div>
|
return <div>
|
||||||
<div style={{color: "grey"}}>
|
{/* <div style={{color: "grey"}}>
|
||||||
{Object.entries(inputEvents).map(([eventName, destination]) => <div>{eventName} → <ShowEventDestination {...destination}/></div>)}
|
{Object.entries(inputEvents).map(([eventName, destination]) => <div>{eventName} → <ShowEventDestination {...destination}/></div>)}
|
||||||
</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>;
|
</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;
|
export default App;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,5 @@
|
||||||
|
|
||||||
.bottom {
|
.bottom {
|
||||||
border-top: 1px lightgrey solid;
|
border-top: 1px lightgrey solid;
|
||||||
background-color: lightyellow;
|
background-color: rgb(255, 249, 235);
|
||||||
/* background-color: rgb(255, 251, 244); */
|
|
||||||
}
|
}
|
||||||
|
|
@ -2,10 +2,9 @@ import { useAudioContext } from "@/App/useAudioContext";
|
||||||
import { ConcreteSyntax } from "@/App/VisualEditor/VisualEditor";
|
import { ConcreteSyntax } from "@/App/VisualEditor/VisualEditor";
|
||||||
import { detectConnections } from "@/statecharts/detect_connections";
|
import { detectConnections } from "@/statecharts/detect_connections";
|
||||||
import { parseStatechart } from "@/statecharts/parser";
|
import { parseStatechart } from "@/statecharts/parser";
|
||||||
import { BigStep, RT_Statechart } from "@/statecharts/runtime_types";
|
import { RT_Statechart } from "@/statecharts/runtime_types";
|
||||||
import { statechartExecution } from "@/statecharts/timed_reactive";
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Plant, PlantRenderProps } from "../Plant";
|
import { makeStatechartPlant, PlantRenderProps } from "../Plant";
|
||||||
|
|
||||||
import dwatchConcreteSyntax from "./model.json";
|
import dwatchConcreteSyntax from "./model.json";
|
||||||
import sndBeep from "./beep.wav";
|
import sndBeep from "./beep.wav";
|
||||||
|
|
@ -24,12 +23,12 @@ if (dwatchErrors.length > 0) {
|
||||||
|
|
||||||
const twoDigits = (n: number) => ("0"+n.toString()).slice(-2);
|
const twoDigits = (n: number) => ("0"+n.toString()).slice(-2);
|
||||||
|
|
||||||
export function DigitalWatch({state, speed, raiseInput}: PlantRenderProps<RT_Statechart>) {
|
export function DigitalWatch({state, speed, raiseUIEvent}: PlantRenderProps<RT_Statechart>) {
|
||||||
const displayingTime = state.mode.has("265");
|
const displayingTime = state.mode.has("625");
|
||||||
const displayingAlarm = state.mode.has("266");
|
const displayingAlarm = state.mode.has("626");
|
||||||
const displayingChrono = state.mode.has("264");
|
const displayingChrono = state.mode.has("624");
|
||||||
|
|
||||||
const lightOn = state.mode.has("389");
|
const lightOn = state.mode.has("630");
|
||||||
|
|
||||||
const alarm = state.environment.get("alarm");
|
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 cs = state.environment.get("cs");
|
||||||
const chs = state.environment.get("chs");
|
const chs = state.environment.get("chs");
|
||||||
|
|
||||||
const hideH = state.mode.has("268");
|
const hideH = state.mode.has("628");
|
||||||
const hideM = state.mode.has("271");
|
const hideM = state.mode.has("633");
|
||||||
const hideS = state.mode.has("267");
|
const hideS = state.mode.has("627");
|
||||||
|
|
||||||
// console.log({cm,cs,chs});
|
// console.log({cm,cs,chs});
|
||||||
|
|
||||||
|
|
@ -64,7 +63,7 @@ export function DigitalWatch({state, speed, raiseInput}: PlantRenderProps<RT_Sta
|
||||||
|
|
||||||
preloadAudio(sndBeep);
|
preloadAudio(sndBeep);
|
||||||
|
|
||||||
const beep = state.mode.has("270");
|
const beep = state.mode.has("632");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (beep) {
|
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>
|
<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}
|
<rect className="watchButtonHelper" x={0} y={54} width={24} height={24}
|
||||||
onMouseDown={() => raiseInput({name: "topLeftPressed"})}
|
onMouseDown={() => raiseUIEvent({name: "topLeftPressed"})}
|
||||||
onMouseUp={() => raiseInput({name: "topLeftReleased"})}
|
onMouseUp={() => raiseUIEvent({name: "topLeftReleased"})}
|
||||||
/>
|
/>
|
||||||
<rect className="watchButtonHelper" x={198} y={54} width={24} height={24}
|
<rect className="watchButtonHelper" x={198} y={54} width={24} height={24}
|
||||||
onMouseDown={() => raiseInput({name: "topRightPressed"})}
|
onMouseDown={() => raiseUIEvent({name: "topRightPressed"})}
|
||||||
onMouseUp={() => raiseInput({name: "topRightReleased"})}
|
onMouseUp={() => raiseUIEvent({name: "topRightReleased"})}
|
||||||
/>
|
/>
|
||||||
<rect className="watchButtonHelper" x={0} y={154} width={24} height={24}
|
<rect className="watchButtonHelper" x={0} y={154} width={24} height={24}
|
||||||
onMouseDown={() => raiseInput({name: "bottomLeftPressed"})}
|
onMouseDown={() => raiseUIEvent({name: "bottomLeftPressed"})}
|
||||||
onMouseUp={() => raiseInput({name: "bottomLeftReleased"})}
|
onMouseUp={() => raiseUIEvent({name: "bottomLeftReleased"})}
|
||||||
/>
|
/>
|
||||||
<rect className="watchButtonHelper" x={198} y={154} width={24} height={24}
|
<rect className="watchButtonHelper" x={198} y={154} width={24} height={24}
|
||||||
onMouseDown={() => raiseInput({name: "bottomRightPressed"})}
|
onMouseDown={() => raiseUIEvent({name: "bottomRightPressed"})}
|
||||||
onMouseUp={() => raiseInput({name: "bottomRightReleased"})}
|
onMouseUp={() => raiseUIEvent({name: "bottomRightReleased"})}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{alarm &&
|
{alarm &&
|
||||||
|
|
@ -111,46 +110,17 @@ export function DigitalWatch({state, speed, raiseInput}: PlantRenderProps<RT_Sta
|
||||||
</>;
|
</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DigitalWatchPlant: Plant<BigStep> = {
|
export const digitalWatchPlant = makeStatechartPlant({
|
||||||
inputEvents: [
|
ast: dwatchAbstractSyntax,
|
||||||
{ 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),
|
|
||||||
render: DigitalWatch,
|
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],
|
extTransition: () => [[], null],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DummyPlant: Plant<null> = {
|
export const dummyPlant: Plant<null> = {
|
||||||
|
uiEvents: [],
|
||||||
inputEvents: [],
|
inputEvents: [],
|
||||||
outputEvents: [],
|
outputEvents: [],
|
||||||
execution: dummyExecution,
|
execution: dummyExecution,
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,24 @@ import fontDigital from "../DigitalWatch/digital-font.ttf";
|
||||||
|
|
||||||
import sndBell from "./bell.wav";
|
import sndBell from "./bell.wav";
|
||||||
import sndRunning from "./running.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 { useEffect } from "react";
|
||||||
|
|
||||||
import "./Microwave.css";
|
import "./Microwave.css";
|
||||||
import { useAudioContext } from "../../useAudioContext";
|
import { useAudioContext } from "../../useAudioContext";
|
||||||
import { Plant, PlantRenderProps } from "../Plant";
|
import { makeStatechartPlant, PlantRenderProps, StatechartPlantSpec } from "../Plant";
|
||||||
import { statechartExecution } from "@/statecharts/timed_reactive";
|
import { detectConnections } from "@/statecharts/detect_connections";
|
||||||
import { microwaveAbstractSyntax } from "./model";
|
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 = {
|
const imgs = {
|
||||||
|
|
@ -37,7 +47,7 @@ const DOOR_Y0 = 68;
|
||||||
const DOOR_WIDTH = 353;
|
const DOOR_WIDTH = 353;
|
||||||
const DOOR_HEIGHT = 217;
|
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);
|
const [playSound, preloadAudio] = useAudioContext(speed);
|
||||||
|
|
||||||
// preload(imgSmallClosedOff, {as: "image"});
|
// preload(imgSmallClosedOff, {as: "image"});
|
||||||
|
|
@ -48,9 +58,9 @@ export function Microwave({state, speed, raiseInput}: PlantRenderProps<RT_Statec
|
||||||
preloadAudio(sndRunning);
|
preloadAudio(sndRunning);
|
||||||
preloadAudio(sndBell);
|
preloadAudio(sndBell);
|
||||||
|
|
||||||
const bellRinging = state.mode.has("45");
|
const bellRinging = state.mode.has("12");
|
||||||
const magnetronRunning = state.mode.has("28");
|
const magnetronRunning = state.mode.has("8");
|
||||||
const doorOpen = state.mode.has("13");
|
const doorOpen = state.mode.has("7");
|
||||||
const timeDisplay = state.environment.get("timeDisplay");
|
const timeDisplay = state.environment.get("timeDisplay");
|
||||||
|
|
||||||
// a bit hacky: when the bell-state changes to true, we play the bell sound...
|
// 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}/>
|
<image xlinkHref={imgs[doorOpen][magnetronRunning]} width={520} height={348}/>
|
||||||
|
|
||||||
<rect className="microwaveButtonHelper" x={START_X0} y={START_Y0} width={BUTTON_WIDTH} height={BUTTON_HEIGHT}
|
<rect className="microwaveButtonHelper" x={START_X0} y={START_Y0} width={BUTTON_WIDTH} height={BUTTON_HEIGHT}
|
||||||
onMouseDown={() => raiseInput({name: "startPressed"})}
|
onMouseDown={() => raiseUIEvent({name: "startPressed"})}
|
||||||
onMouseUp={() => raiseInput({name: "startReleased"})}
|
onMouseUp={() => raiseUIEvent({name: "startReleased"})}
|
||||||
/>
|
/>
|
||||||
<rect className="microwaveButtonHelper" x={STOP_X0} y={STOP_Y0} width={BUTTON_WIDTH} height={BUTTON_HEIGHT}
|
<rect className="microwaveButtonHelper" x={STOP_X0} y={STOP_Y0} width={BUTTON_WIDTH} height={BUTTON_HEIGHT}
|
||||||
onMouseDown={() => raiseInput({name: "stopPressed"})}
|
onMouseDown={() => raiseUIEvent({name: "stopPressed"})}
|
||||||
onMouseUp={() => raiseInput({name: "stopReleased"})}
|
onMouseUp={() => raiseUIEvent({name: "stopReleased"})}
|
||||||
/>
|
/>
|
||||||
<rect className="microwaveButtonHelper" x={INCTIME_X0} y={INCTIME_Y0} width={BUTTON_WIDTH} height={BUTTON_HEIGHT}
|
<rect className="microwaveButtonHelper" x={INCTIME_X0} y={INCTIME_Y0} width={BUTTON_WIDTH} height={BUTTON_HEIGHT}
|
||||||
onMouseDown={() => raiseInput({name: "incTimePressed"})}
|
onMouseDown={() => raiseUIEvent({name: "incTimePressed"})}
|
||||||
onMouseUp={() => raiseInput({name: "incTimeReleased"})}
|
onMouseUp={() => raiseUIEvent({name: "incTimeReleased"})}
|
||||||
/>
|
/>
|
||||||
<rect className="microwaveDoorHelper"
|
<rect className="microwaveDoorHelper"
|
||||||
x={DOOR_X0} y={DOOR_Y0} width={DOOR_WIDTH} height={DOOR_HEIGHT}
|
x={DOOR_X0} y={DOOR_Y0} width={DOOR_WIDTH} height={DOOR_HEIGHT}
|
||||||
onMouseDown={() => raiseInput({name: "doorMouseDown"})}
|
onMouseDown={() => raiseUIEvent({name: "doorMouseDown"})}
|
||||||
onMouseUp={() => raiseInput({name: "doorMouseUp"})}
|
onMouseUp={() => raiseUIEvent({name: "doorMouseUp"})}
|
||||||
/>
|
/>
|
||||||
<text x={472} y={106} textAnchor="end" fontFamily="digital-font" fontSize={24} fill="lightgreen">{timeDisplay}</text>
|
<text x={472} y={106} textAnchor="end" fontFamily="digital-font" fontSize={24} fill="lightgreen">{timeDisplay}</text>
|
||||||
</svg>
|
</svg>
|
||||||
</>;
|
</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MicrowavePlant: Plant<BigStep> = {
|
const microwavePlantSpec: StatechartPlantSpec = {
|
||||||
inputEvents: [
|
ast: microwaveAbstractSyntax,
|
||||||
// events coming from statechart
|
render: Microwave,
|
||||||
{kind: "event", event: "setTimeDisplay", paramName: "t"},
|
uiEvents: [
|
||||||
{kind: "event", event: "setMagnetron", paramName: "state"},
|
|
||||||
{kind: "event", event: "ringBell"},
|
|
||||||
|
|
||||||
// events coming from UI:
|
|
||||||
{kind: "event", event: "doorMouseDown"},
|
{kind: "event", event: "doorMouseDown"},
|
||||||
{kind: "event", event: "doorMouseUp"},
|
{kind: "event", event: "doorMouseUp"},
|
||||||
{kind: "event", event: "startPressed"},
|
{kind: "event", event: "startPressed"},
|
||||||
{kind: "event", event: "stopPressed"},
|
|
||||||
{kind: "event", event: "incTimePressed"},
|
|
||||||
{kind: "event", event: "startReleased"},
|
{kind: "event", event: "startReleased"},
|
||||||
|
{kind: "event", event: "stopPressed"},
|
||||||
{kind: "event", event: "stopReleased"},
|
{kind: "event", event: "stopReleased"},
|
||||||
|
{kind: "event", event: "incTimePressed"},
|
||||||
{kind: "event", event: "incTimeReleased"},
|
{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 { ReactElement } from "react";
|
||||||
import { Statechart } from "@/statecharts/abstract_syntax";
|
import { Statechart } from "@/statecharts/abstract_syntax";
|
||||||
import { EventTrigger } from "@/statecharts/label_ast";
|
import { EventTrigger } from "@/statecharts/label_ast";
|
||||||
import { RaisedEvent } from "@/statecharts/runtime_types";
|
import { BigStep, RaisedEvent, RT_Statechart } from "@/statecharts/runtime_types";
|
||||||
import { Conns, TimedReactive } from "@/statecharts/timed_reactive";
|
import { statechartExecution, TimedReactive } from "@/statecharts/timed_reactive";
|
||||||
|
|
||||||
export type PlantRenderProps<StateType> = {
|
export type PlantRenderProps<StateType> = {
|
||||||
state: StateType,
|
state: StateType,
|
||||||
speed: number,
|
speed: number,
|
||||||
raiseInput: (e: RaisedEvent) => void,
|
raiseUIEvent: (e: RaisedEvent) => void,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Plant<StateType> = {
|
export type Plant<StateType> = {
|
||||||
|
uiEvents: EventTrigger[];
|
||||||
|
|
||||||
inputEvents: EventTrigger[];
|
inputEvents: EventTrigger[];
|
||||||
outputEvents: EventTrigger[];
|
outputEvents: EventTrigger[];
|
||||||
|
|
||||||
execution: TimedReactive<StateType>;
|
execution: TimedReactive<StateType>;
|
||||||
render: (props: PlantRenderProps<StateType>) => ReactElement;
|
render: (props: PlantRenderProps<StateType>) => ReactElement;
|
||||||
}
|
}
|
||||||
|
|
@ -48,3 +51,19 @@ export function exposePlantInputs(plant: Plant<any>, plantName: string, tfm = (s
|
||||||
}
|
}
|
||||||
return inputs
|
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 { TimeMode, timeTravel } from "../statecharts/time";
|
||||||
import { TraceItem, TraceState } from "./App";
|
import { TraceItem, TraceState } from "./App";
|
||||||
import { Environment } from "@/statecharts/environment";
|
import { Environment } from "@/statecharts/environment";
|
||||||
import { Conns } from "@/statecharts/timed_reactive";
|
|
||||||
|
|
||||||
type RTHistoryProps = {
|
type RTHistoryProps = {
|
||||||
trace: TraceState|null,
|
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 { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo";
|
||||||
import { InsertMode } from "@/App/VisualEditor/VisualEditor";
|
|
||||||
import { HistoryIcon, PseudoStateIcon, RountangleIcon } from "./Icons";
|
import { HistoryIcon, PseudoStateIcon, RountangleIcon } from "./Icons";
|
||||||
|
|
||||||
import TrendingFlatIcon from '@mui/icons-material/TrendingFlat';
|
import TrendingFlatIcon from '@mui/icons-material/TrendingFlat';
|
||||||
|
|
||||||
|
export type InsertMode = "and" | "or" | "pseudo" | "shallow" | "deep" | "transition" | "text";
|
||||||
|
|
||||||
const insertModes: [InsertMode, string, ReactElement, ReactElement][] = [
|
const insertModes: [InsertMode, string, ReactElement, ReactElement][] = [
|
||||||
["and", "AND-states", <RountangleIcon kind="and"/>, <kbd>A</kbd>],
|
["and", "AND-states", <RountangleIcon kind="and"/>, <kbd>A</kbd>],
|
||||||
["or", "OR-states", <RountangleIcon kind="or"/>, <kbd>O</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>>}) {
|
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;
|
const KeyInfo = showKeys ? KeyInfoVisible : KeyInfoHidden;
|
||||||
return <>{insertModes.map(([m, hint, buttonTxt, keyInfo]) => <KeyInfo key={m} keyInfo={keyInfo}>
|
return <>{insertModes.map(([m, hint, buttonTxt, keyInfo]) => <KeyInfo key={m} keyInfo={keyInfo}>
|
||||||
<button
|
<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 { Dispatch, memo, ReactElement, SetStateAction, useCallback, useEffect, useState } from "react";
|
||||||
import { TimerElapseEvent, Timers } from "../../statecharts/runtime_types";
|
import { TimerElapseEvent, Timers } from "../../statecharts/runtime_types";
|
||||||
import { getSimTime, setPaused, setRealtime, TimeMode } from "../../statecharts/time";
|
import { getSimTime, setPaused, setRealtime, TimeMode } from "../../statecharts/time";
|
||||||
import { InsertMode } from "../VisualEditor/VisualEditor";
|
import { InsertMode } from "./InsertModes";
|
||||||
import { About } from "../Modals/About";
|
import { About } from "../Modals/About";
|
||||||
import { EditHistory, TraceState } from "../App";
|
import { EditHistory, TraceState } from "../App";
|
||||||
import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo";
|
import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo";
|
||||||
|
|
@ -20,6 +20,7 @@ import StopIcon from '@mui/icons-material/Stop';
|
||||||
import { InsertModes } from "./InsertModes";
|
import { InsertModes } from "./InsertModes";
|
||||||
import { usePersistentState } from "@/App/persistent_state";
|
import { usePersistentState } from "@/App/persistent_state";
|
||||||
import { RotateButtons } from "./RotateButtons";
|
import { RotateButtons } from "./RotateButtons";
|
||||||
|
import { SpeedControl } from "./SpeedControl";
|
||||||
|
|
||||||
export type TopPanelProps = {
|
export type TopPanelProps = {
|
||||||
trace: TraceState | null,
|
trace: TraceState | null,
|
||||||
|
|
@ -79,24 +80,6 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on
|
||||||
updateDisplayedTime();
|
updateDisplayedTime();
|
||||||
}, [setTime, timescale, 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
|
// timestamp of next timed transition, in simulated time
|
||||||
const timers: Timers = config?.kind === "bigstep" && config.state.sc.environment.get("_timers") || [];
|
const timers: Timers = config?.kind === "bigstep" && config.state.sc.environment.get("_timers") || [];
|
||||||
const nextTimedTransition: [number, TimerElapseEvent] | undefined = timers[0];
|
const nextTimedTransition: [number, TimerElapseEvent] | undefined = timers[0];
|
||||||
|
|
@ -115,16 +98,10 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on
|
||||||
}
|
}
|
||||||
}, [nextTimedTransition, setTime]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
// don't capture keyboard events when focused on an input element:
|
// don't capture keyboard events when focused on an input element:
|
||||||
|
// @ts-ignore
|
||||||
if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target?.tagName)) return;
|
if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target?.tagName)) return;
|
||||||
|
|
||||||
if (!e.ctrlKey) {
|
if (!e.ctrlKey) {
|
||||||
|
|
@ -143,7 +120,7 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on
|
||||||
onClear();
|
onClear();
|
||||||
}
|
}
|
||||||
if (e.key === "Tab") {
|
if (e.key === "Tab") {
|
||||||
if (trace === null) {
|
if (config === null) {
|
||||||
onInit();
|
onInit();
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
|
|
@ -151,14 +128,6 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on
|
||||||
}
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
if (e.key === "s") {
|
|
||||||
e.preventDefault();
|
|
||||||
onSlower();
|
|
||||||
}
|
|
||||||
if (e.key === "f") {
|
|
||||||
e.preventDefault();
|
|
||||||
onFaster();
|
|
||||||
}
|
|
||||||
if (e.key === "`") {
|
if (e.key === "`") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setShowKeys(show => !show);
|
setShowKeys(show => !show);
|
||||||
|
|
@ -168,23 +137,12 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on
|
||||||
onBack();
|
onBack();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
|
||||||
// ctrl is down
|
|
||||||
if (e.key === "z") {
|
|
||||||
e.preventDefault();
|
|
||||||
onUndo();
|
|
||||||
}
|
|
||||||
if (e.key === "Z") {
|
|
||||||
e.preventDefault();
|
|
||||||
onRedo();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKeyDown);
|
window.addEventListener("keydown", onKeyDown);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("keydown", onKeyDown);
|
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">
|
return <div className="toolbar">
|
||||||
{/* shortcuts / about */}
|
{/* shortcuts / about */}
|
||||||
|
|
@ -241,14 +199,7 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on
|
||||||
|
|
||||||
{/* speed */}
|
{/* speed */}
|
||||||
<div className="toolbarGroup">
|
<div className="toolbarGroup">
|
||||||
<label htmlFor="number-timescale">speed</label>
|
<SpeedControl setTime={setTime} timescale={timescale} setTimescale={setTimescale} showKeys={showKeys} />
|
||||||
<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>
|
|
||||||
 
|
 
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,30 @@
|
||||||
import { memo } from "react";
|
import { memo, useCallback, useEffect } from "react";
|
||||||
import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo";
|
import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo";
|
||||||
|
|
||||||
import UndoIcon from '@mui/icons-material/Undo';
|
import UndoIcon from '@mui/icons-material/Undo';
|
||||||
import RedoIcon from '@mui/icons-material/Redo';
|
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}) {
|
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;
|
const KeyInfo = showKeys ? KeyInfoVisible : KeyInfoHidden;
|
||||||
return <>
|
return <>
|
||||||
<KeyInfo keyInfo={<><kbd>Ctrl</kbd>+<kbd>Z</kbd></>}>
|
<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.ctrlKey) {
|
||||||
if (e.key === "+") {
|
if (e.key === "+") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
onZoomIn();
|
onZoomIn();
|
||||||
}
|
}
|
||||||
if (e.key === "-") {
|
if (e.key === "-") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
onZoomOut();
|
onZoomOut();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Diamond, RectSide } from "@/statecharts/concrete_syntax";
|
import { Diamond, RectSide } from "@/statecharts/concrete_syntax";
|
||||||
import { rountangleMinSize } from "./VisualEditor";
|
import { rountangleMinSize } from "@/statecharts/concrete_syntax";
|
||||||
import { Vec2D } from "../../util/geometry";
|
import { Vec2D } from "../../util/geometry";
|
||||||
import { RectHelper } from "./RectHelpers";
|
import { RectHelper } from "./RectHelpers";
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { memo } from "react";
|
|
||||||
import { RectSide } from "../../statecharts/concrete_syntax";
|
import { RectSide } from "../../statecharts/concrete_syntax";
|
||||||
import { Vec2D } from "../../util/geometry";
|
import { Vec2D } from "../../util/geometry";
|
||||||
import { CORNER_HELPER_OFFSET, CORNER_HELPER_RADIUS } from "../parameters";
|
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, RectSide } from "../../statecharts/concrete_syntax";
|
||||||
import { ROUNTANGLE_RADIUS } from "../parameters";
|
import { ROUNTANGLE_RADIUS } from "../parameters";
|
||||||
import { RectHelper } from "./RectHelpers";
|
import { RectHelper } from "./RectHelpers";
|
||||||
import { rountangleMinSize } from "./VisualEditor";
|
import { rountangleMinSize } from "@/statecharts/concrete_syntax";
|
||||||
import { arraysEqual } from "@/util/util";
|
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 { TraceState } from "@/App/App";
|
||||||
|
import { InsertMode } from "../TopPanel/InsertModes";
|
||||||
import { Mode } from "@/statecharts/runtime_types";
|
import { Mode } from "@/statecharts/runtime_types";
|
||||||
import { arraysEqual, objectsEqual, setsEqual } from "@/util/util";
|
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 = {
|
export type ConcreteSyntax = {
|
||||||
rountangles: Rountangle[];
|
rountangles: Rountangle[];
|
||||||
|
|
@ -31,8 +32,6 @@ export type VisualEditorState = ConcreteSyntax & {
|
||||||
selection: Selection;
|
selection: Selection;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SelectingState = Rect2D | null;
|
|
||||||
|
|
||||||
export type RountangleSelectable = {
|
export type RountangleSelectable = {
|
||||||
// kind: "rountangle";
|
// kind: "rountangle";
|
||||||
parts: RectSide[];
|
parts: RectSide[];
|
||||||
|
|
@ -55,9 +54,6 @@ type Selectable = RountangleSelectable | ArrowSelectable | TextSelectable | Hist
|
||||||
|
|
||||||
export type Selection = Selectable[];
|
export type Selection = Selectable[];
|
||||||
|
|
||||||
export type InsertMode = "and"|"or"|"pseudo"|"shallow"|"deep"|"transition"|"text";
|
|
||||||
|
|
||||||
|
|
||||||
type VisualEditorProps = {
|
type VisualEditorProps = {
|
||||||
state: VisualEditorState,
|
state: VisualEditorState,
|
||||||
setState: Dispatch<(v:VisualEditorState) => 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) {
|
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
|
// uid's of selected rountangles
|
||||||
const selection = state.selection || [];
|
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);
|
const refSVG = useRef<SVGSVGElement>(null);
|
||||||
|
|
||||||
|
|
@ -102,344 +89,11 @@ export const VisualEditor = memo(function VisualEditor({state, setState, trace,
|
||||||
})
|
})
|
||||||
}, [trace && trace.idx]);
|
}, [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 {onCopy, onPaste, onCut, deleteSelection} = useCopyPaste(makeCheckPoint, state, setState, selection);
|
||||||
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) {
|
const {onMouseDown, selectionRect} = useMouse(makeCheckPoint, insertMode, zoom, refSVG, state, setState, deleteSelection);
|
||||||
// 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 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.
|
// 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[]} = {};
|
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) => {
|
const onEditText = useCallback((text: Text, newText: string) => {
|
||||||
if (newText === "") {
|
if (newText === "") {
|
||||||
// delete text node
|
// delete text node
|
||||||
|
|
@ -616,7 +175,7 @@ export const VisualEditor = memo(function VisualEditor({state, setState, trace,
|
||||||
const size = 4000*zoom;
|
const size = 4000*zoom;
|
||||||
|
|
||||||
return <svg width={size} height={size}
|
return <svg width={size} height={size}
|
||||||
className={"svgCanvas"+(active.has("root")?" active":"")+(dragging ? " dragging" : "")}
|
className={"svgCanvas"+(active.has("root")?" active":"")/*+(dragging ? " dragging" : "")*/}
|
||||||
onMouseDown={onMouseDown}
|
onMouseDown={onMouseDown}
|
||||||
onContextMenu={e => e.preventDefault()}
|
onContextMenu={e => e.preventDefault()}
|
||||||
ref={refSVG}
|
ref={refSVG}
|
||||||
|
|
@ -689,20 +248,10 @@ export const VisualEditor = memo(function VisualEditor({state, setState, trace,
|
||||||
|
|
||||||
<Texts texts={state.texts} {...{selection, textsToHighlight, errors, onEditText, setModal}}/>
|
<Texts texts={state.texts} {...{selection, textsToHighlight, errors, onEditText, setModal}}/>
|
||||||
|
|
||||||
{selectingState && <Selecting {...selectingState} />}
|
{selectionRect}
|
||||||
</svg>;
|
</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}) {
|
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 <>{rountangles.map(rountangle => {
|
||||||
return <RountangleSVG
|
return <RountangleSVG
|
||||||
|
|
@ -765,13 +314,3 @@ const Texts = memo(function Texts({texts, selection, textsToHighlight, errors, o
|
||||||
&& p.setModal === n.setModal;
|
&& 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 { 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 { VisualEditorState } from "../App/VisualEditor/VisualEditor";
|
||||||
import { sides } from "@/util/geometry";
|
import { sides } from "@/util/geometry";
|
||||||
|
|
||||||
|
|
@ -123,3 +123,14 @@ export function findNearestHistory(point: Vec2D, candidates: History[]): History
|
||||||
}
|
}
|
||||||
return best;
|
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 { AbstractState, computeArena, computePath, ConcreteState, getDescendants, HistoryState, isOverlapping, OrState, StableState, Statechart, stateDescription, Transition, transitionDescription, TransitionSrcTgt } from "./abstract_syntax";
|
||||||
import { evalExpr } from "./actionlang_interpreter";
|
import { evalExpr } from "./actionlang_interpreter";
|
||||||
import { Environment, FlatEnvironment, ScopedEnvironment } from "./environment";
|
import { Environment, FlatEnvironment } from "./environment";
|
||||||
import { Action, EventTrigger, TransitionLabel } from "./label_ast";
|
import { Action, EventTrigger, TransitionLabel } from "./label_ast";
|
||||||
import { BigStep, initialRaised, Mode, RaisedEvents, RT_Event, RT_History, RT_Statechart, TimerElapseEvent, Timers } from "./runtime_types";
|
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,
|
timeAdvance: (c: RT_Config) => number,
|
||||||
intTransition: (c: RT_Config) => [RaisedEvent[], RT_Config],
|
intTransition: (c: RT_Config) => [RaisedEvent[], RT_Config],
|
||||||
extTransition: (simtime: number, c: RT_Config, e: InputEvent) => [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> {
|
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) => {
|
extTransition: (simtime: number, c: RT_Statechart, e: InputEvent) => {
|
||||||
const {outputEvents, ...rest} = handleInputEvent(simtime, e, ast, c);
|
const {outputEvents, ...rest} = handleInputEvent(simtime, e, ast, c);
|
||||||
return [outputEvents, {outputEvents, ...rest}];
|
return [outputEvents, {outputEvents, ...rest}];
|
||||||
}
|
},
|
||||||
|
|
||||||
|
// inputEvents: ast.inputEvents.map(e => e.event),
|
||||||
|
// outputEvents: [...ast.outputEvents],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,24 +59,24 @@ export type OutputDestination = {
|
||||||
// kind: "nowhere",
|
// kind: "nowhere",
|
||||||
// };
|
// };
|
||||||
|
|
||||||
export function exposeStatechartInputsOutputs(ast: Statechart, model: string): Conns {
|
// export function exposeStatechartInputsOutputs(ast: Statechart, model: string): Conns {
|
||||||
return {
|
// return {
|
||||||
// all the coupled execution's input events become input events for the statechart
|
// // all the coupled execution's input events become input events for the statechart
|
||||||
inputEvents: exposeStatechartInputs(ast, model),
|
// inputEvents: exposeStatechartInputs(ast, model),
|
||||||
outputEvents: exposeStatechartOutputs(ast, model),
|
// outputEvents: exposeStatechartOutputs(ast, model),
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
export function exposeStatechartInputs(ast: Statechart, model: string, tfm = (s: string) => s): {[eventName: string]: ModelDestination} {
|
// 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}]));
|
// 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}} {
|
// export function exposeStatechartOutputs(ast: Statechart, model: string): {[modelName: string]: {[eventName: string]: EventDestination}} {
|
||||||
return {
|
// return {
|
||||||
// all the statechart's output events become output events of our coupled execution
|
// // 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}])),
|
// [model]: Object.fromEntries([...ast.outputEvents].map(e => [e, {kind: "output", model, eventName: e}])),
|
||||||
};
|
// };
|
||||||
}
|
// }
|
||||||
|
|
||||||
// export function hideStatechartOutputs(ast: Statechart, model: string) {
|
// export function hideStatechartOutputs(ast: Statechart, model: string) {
|
||||||
// return {
|
// return {
|
||||||
|
|
@ -78,15 +84,23 @@ export function exposeStatechartOutputs(ast: Statechart, model: string): {[model
|
||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
|
|
||||||
export type Conns = {
|
// export type Conns = {
|
||||||
// inputs coming from outside are routed to the right models
|
// // inputs coming from outside are routed to the right models
|
||||||
inputEvents: {[eventName: string]: ModelDestination},
|
// inputEvents: {[eventName: string]: ModelDestination},
|
||||||
|
|
||||||
// outputs coming from the models are routed to other models or to outside
|
// // outputs coming from the models are routed to other models or to outside
|
||||||
outputEvents: {[modelName: string]: {[eventName: string]: EventDestination}},
|
// 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) {
|
function makeModelExtTransition(simtime: number, c: T, model: string, e: InputEvent) {
|
||||||
const [outputEvents, newConfig] = models[model].extTransition(simtime, c[model], e);
|
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] {
|
function processOutputs(simtime: number, events: RaisedEvent[], model: string, c: T): [RaisedEvent[], T] {
|
||||||
if (events.length > 0) {
|
if (events.length > 0) {
|
||||||
const [event, ...rest] = events;
|
const [event, ...rest] = events;
|
||||||
const destination = conns.outputEvents[model]?.[event.name];
|
const destination = conns[model+'.'+event.name];
|
||||||
if (destination === undefined) {
|
if (destination === undefined) {
|
||||||
// ignore
|
// ignore
|
||||||
console.log(`${model}.${event.name} goes nowhere`);
|
console.log(`${model}.${event.name} goes nowhere`);
|
||||||
return processOutputs(simtime, rest, model, c);
|
return processOutputs(simtime, rest, model, c);
|
||||||
}
|
}
|
||||||
if (destination.kind === "model") {
|
const [destinationModel, destinationEventName] = destination;
|
||||||
|
if (destinationModel !== null) {
|
||||||
// output event is input for another model
|
// 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 = {
|
const inputEvent = {
|
||||||
kind: "input" as const,
|
kind: "input" as const,
|
||||||
name: destination.eventName,
|
name: destinationEventName,
|
||||||
param: event.param,
|
param: event.param,
|
||||||
};
|
};
|
||||||
const [outputEvents, newConfig] = makeModelExtTransition(simtime, c, destination.model, inputEvent);
|
const [outputEvents, newConfig] = makeModelExtTransition(simtime, c, destinationModel, inputEvent);
|
||||||
|
|
||||||
// proceed with 'rest':
|
// proceed with 'rest':
|
||||||
const [restOutputEvents, newConfig2] = processOutputs(simtime, rest, model, newConfig);
|
const [restOutputEvents, newConfig2] = processOutputs(simtime, rest, model, newConfig);
|
||||||
return [[...outputEvents, ...restOutputEvents], newConfig2];
|
return [[...outputEvents, ...restOutputEvents], newConfig2];
|
||||||
}
|
}
|
||||||
else if (destination.kind === "output") {
|
else {
|
||||||
// kind === "output"
|
// event is output event of our coupled execution
|
||||||
console.log(`${model}.${event.name} becomes ^${destination.eventName}`);
|
console.log(`${model}.${event.name} becomes ^${destinationEventName}`);
|
||||||
const [outputEvents, newConfig] = processOutputs(simtime, rest, model, c);
|
const [outputEvents, newConfig] = processOutputs(simtime, rest, model, c);
|
||||||
return [[event, ...outputEvents], newConfig];
|
return [[event, ...outputEvents], newConfig];
|
||||||
}
|
}
|
||||||
throw new Error("unreachable");
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
return [[], c];
|
return [[], c];
|
||||||
|
|
@ -146,7 +160,6 @@ export function coupledExecution<T extends {[name: string]: any}>(models: {[name
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
state[modelName] = modelState;
|
state[modelName] = modelState;
|
||||||
}
|
}
|
||||||
console.log({state});
|
|
||||||
// 2. handle all output events (models' outputs may be inputs for each other)
|
// 2. handle all output events (models' outputs may be inputs for each other)
|
||||||
let finalOutputs = [];
|
let finalOutputs = [];
|
||||||
for (const [modelName, outputEvents] of allOutputs) {
|
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");
|
throw new Error("cannot make intTransition - timeAdvance is infinity");
|
||||||
},
|
},
|
||||||
extTransition: (simtime, c, e) => {
|
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');
|
console.warn('input event', e.name, 'goes to nowhere');
|
||||||
return [[], c];
|
return [[], c];
|
||||||
}
|
}
|
||||||
else {
|
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}`);
|
console.log('input event', e.name, 'goes to', `${model}.${eventName}`);
|
||||||
const inputEvent: InputEvent = {
|
const inputEvent: InputEvent = {
|
||||||
kind: "input",
|
kind: "input",
|
||||||
|
|
@ -189,7 +203,13 @@ export function coupledExecution<T extends {[name: string]: any}>(models: {[name
|
||||||
};
|
};
|
||||||
return makeModelExtTransition(simtime, c, model, inputEvent);
|
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