move some files around to confuse everyone
|
|
@ -2,13 +2,13 @@ import { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from
|
|||
|
||||
import { handleInputEvent, initialize, RuntimeError } from "../statecharts/interpreter";
|
||||
import { BigStep, RT_Event } from "../statecharts/runtime_types";
|
||||
import { InsertMode, VisualEditor, VisualEditorState } from "../VisualEditor/VisualEditor";
|
||||
import { InsertMode, VisualEditor, VisualEditorState } from "./VisualEditor/VisualEditor";
|
||||
import { getSimTime, getWallClkDelay, TimeMode } from "../statecharts/time";
|
||||
|
||||
import "../index.css";
|
||||
import "./App.css";
|
||||
|
||||
import { TopPanel } from "./TopPanel";
|
||||
import { TopPanel } from "./TopPanel/TopPanel";
|
||||
import { ShowAST, ShowInputEvents, ShowInternalEvents, ShowOutputEvents } from "./ShowAST";
|
||||
import { parseStatechart } from "../statecharts/parser";
|
||||
import { getKeyHandler } from "./shortcut_handler";
|
||||
|
|
@ -18,7 +18,7 @@ import { PersistentDetails } from "./PersistentDetails";
|
|||
import { DigitalWatchPlant } from "@/Plant/DigitalWatch/DigitalWatch";
|
||||
import { DummyPlant } from "@/Plant/Dummy/Dummy";
|
||||
import { Plant } from "@/Plant/Plant";
|
||||
import { usePersistentState } from "@/util/persistent_state";
|
||||
import { usePersistentState } from "@/App/persistent_state";
|
||||
import { RTHistory } from "./RTHistory";
|
||||
import { detectConnections } from "@/statecharts/detect_connections";
|
||||
import { MicrowavePlant } from "@/Plant/Microwave/Microwave";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { usePersistentState } from "@/util/persistent_state"
|
||||
import { usePersistentState } from "@/App/persistent_state"
|
||||
import { DetailsHTMLAttributes, PropsWithChildren } from "react";
|
||||
|
||||
type Props = {
|
||||
|
|
|
|||
13
src/App/Plant/DigitalWatch/DigitalWatch.css
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
.watchButtonHelper {
|
||||
fill-opacity: 0;
|
||||
}
|
||||
|
||||
.watchButtonHelper:hover {
|
||||
fill: beige;
|
||||
fill-opacity: 0.5;
|
||||
}
|
||||
|
||||
.watchButtonHelper:active {
|
||||
fill: red;
|
||||
fill-opacity: 1;
|
||||
}
|
||||
133
src/App/Plant/DigitalWatch/DigitalWatch.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import imgNote from "./noteSmall.png";
|
||||
import imgWatch from "./watch.png";
|
||||
import digitalFont from "./digital-font.ttf";
|
||||
import { Plant } from "../Plant";
|
||||
import { RaisedEvent } from "@/statecharts/runtime_types";
|
||||
|
||||
import "./DigitalWatch.css";
|
||||
|
||||
type DigitalWatchState = {
|
||||
light: boolean;
|
||||
h: number;
|
||||
m: number;
|
||||
s: number;
|
||||
alarm: boolean;
|
||||
}
|
||||
|
||||
type DigitalWatchProps = {
|
||||
state: DigitalWatchState,
|
||||
callbacks: {
|
||||
onTopLeftPressed: () => void;
|
||||
onTopRightPressed: () => void;
|
||||
onBottomRightPressed: () => void;
|
||||
onBottomLeftPressed: () => void;
|
||||
onTopLeftReleased: () => void;
|
||||
onTopRightReleased: () => void;
|
||||
onBottomRightReleased: () => void;
|
||||
onBottomLeftReleased: () => void;
|
||||
},
|
||||
}
|
||||
|
||||
export function DigitalWatch({state: {light, h, m, s, alarm}, callbacks}: DigitalWatchProps) {
|
||||
const twoDigits = (n: number) => n < 0 ? " " : ("0"+n.toString()).slice(-2);
|
||||
const hhmmss = `${twoDigits(h)}:${twoDigits(m)}:${twoDigits(s)}`;
|
||||
|
||||
return <>
|
||||
<style>{`
|
||||
@font-face{
|
||||
font-family: 'digital-font';
|
||||
src: url(${digitalFont});
|
||||
}
|
||||
`}</style>
|
||||
<svg version="1.1" width="222" height="236" style={{userSelect: 'none'}}>
|
||||
<image width="222" height="236" xlinkHref={imgWatch}/>
|
||||
|
||||
{light &&
|
||||
<rect x={52} y={98} width={120} height={52} fill="#deeaffff" rx={5} ry={5} />}
|
||||
|
||||
<text x="111" y="126" dominantBaseline="middle" textAnchor="middle" fontFamily="digital-font" fontSize={28} style={{whiteSpace:'preserve'}}>{hhmmss}</text>
|
||||
|
||||
<rect className="watchButtonHelper" x={0} y={54} width={24} height={24}
|
||||
onMouseDown={() => callbacks.onTopLeftPressed()}
|
||||
onMouseUp={() => callbacks.onTopLeftReleased()}
|
||||
/>
|
||||
<rect className="watchButtonHelper" x={198} y={54} width={24} height={24}
|
||||
onMouseDown={() => callbacks.onTopRightPressed()}
|
||||
onMouseUp={() => callbacks.onTopRightReleased()}
|
||||
/>
|
||||
<rect className="watchButtonHelper" x={0} y={154} width={24} height={24}
|
||||
onMouseDown={() => callbacks.onBottomLeftPressed()}
|
||||
onMouseUp={() => callbacks.onBottomLeftReleased()}
|
||||
/>
|
||||
<rect className="watchButtonHelper" x={198} y={154} width={24} height={24}
|
||||
onMouseDown={() => callbacks.onBottomRightPressed()}
|
||||
onMouseUp={() => callbacks.onBottomRightReleased()}
|
||||
/>
|
||||
|
||||
{alarm &&
|
||||
<image x="54" y="98" xlinkHref={imgNote} />
|
||||
}
|
||||
</svg>
|
||||
</>;
|
||||
}
|
||||
|
||||
export const DigitalWatchPlant: Plant<DigitalWatchState> = {
|
||||
inputEvents: [
|
||||
{ kind: "event", event: "setH", paramName: 'h' },
|
||||
{ kind: "event", event: "setM", paramName: 'm' },
|
||||
{ kind: "event", event: "setS", paramName: 's' },
|
||||
{ kind: "event", event: "setLight", paramName: 'lightOn'},
|
||||
{ kind: "event", event: "setAlarm", paramName: 'alarmOn'},
|
||||
],
|
||||
outputEvents: [
|
||||
{ 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" },
|
||||
],
|
||||
initial: {
|
||||
light: false,
|
||||
alarm: false,
|
||||
h: 12,
|
||||
m: 0,
|
||||
s: 0,
|
||||
},
|
||||
reduce: (inputEvent: RaisedEvent, state: DigitalWatchState) => {
|
||||
if (inputEvent.name === "setH") {
|
||||
return { ...state, h: inputEvent.param };
|
||||
}
|
||||
if (inputEvent.name === "setM") {
|
||||
return { ...state, m: inputEvent.param };
|
||||
}
|
||||
if (inputEvent.name === "setS") {
|
||||
return { ...state, s: inputEvent.param };
|
||||
}
|
||||
if (inputEvent.name === "lightOn") {
|
||||
return { ...state, light: true };
|
||||
}
|
||||
if (inputEvent.name === "lightOff") {
|
||||
return { ...state, light: false };
|
||||
}
|
||||
if (inputEvent.name === "setAlarm") {
|
||||
return { ...state, alarm: true };
|
||||
}
|
||||
if (inputEvent.name === "unsetAlarm") {
|
||||
return { ...state, alarm: false };
|
||||
}
|
||||
return state; // unknown event - ignore it
|
||||
},
|
||||
render: (state, raiseEvent) => <DigitalWatch state={state} callbacks={{
|
||||
onTopLeftPressed: () => raiseEvent({name: "topLeftPressed"}),
|
||||
onTopRightPressed: () => raiseEvent({name: "topRightPressed"}),
|
||||
onBottomRightPressed: () => raiseEvent({name: "bottomRightPressed"}),
|
||||
onBottomLeftPressed: () => raiseEvent({name: "bottomLeftPressed"}),
|
||||
onTopLeftReleased: () => raiseEvent({name: "topLeftReleased"}),
|
||||
onTopRightReleased: () => raiseEvent({name: "topRightReleased"}),
|
||||
onBottomRightReleased: () => raiseEvent({name: "bottomRightReleased"}),
|
||||
onBottomLeftReleased: () => raiseEvent({name: "bottomLeftReleased"}),
|
||||
}}/>,
|
||||
}
|
||||
BIN
src/App/Plant/DigitalWatch/digital-font.ttf
Normal file
BIN
src/App/Plant/DigitalWatch/noteSmall.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
src/App/Plant/DigitalWatch/watch-light.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
src/App/Plant/DigitalWatch/watch.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
10
src/App/Plant/Dummy/Dummy.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { RaisedEvent } from "@/statecharts/runtime_types";
|
||||
import { Plant } from "../Plant";
|
||||
|
||||
export const DummyPlant: Plant<{}> = {
|
||||
inputEvents: [],
|
||||
outputEvents: [],
|
||||
initial: () => ({}),
|
||||
reduce: (_inputEvent: RaisedEvent, _state: {}) => ({}),
|
||||
render: (_state: {}, _raise: (event: RaisedEvent) => void) => <></>,
|
||||
}
|
||||
18
src/App/Plant/Microwave/Microwave.css
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
rect.microwaveButtonHelper {
|
||||
fill-opacity: 0;
|
||||
}
|
||||
|
||||
rect.microwaveButtonHelper:hover {
|
||||
fill: rgba(46, 211, 197);
|
||||
fill-opacity: 0.5;
|
||||
}
|
||||
|
||||
rect.microwaveButtonHelper:active {
|
||||
/* fill: rgba(47, 0, 255); */
|
||||
fill: red;
|
||||
fill-opacity: 0.5;
|
||||
}
|
||||
|
||||
rect.microwaveDoorHelper {
|
||||
fill-opacity: 0;
|
||||
}
|
||||
234
src/App/Plant/Microwave/Microwave.tsx
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import { preload } from "react-dom";
|
||||
import imgSmallClosedOff from "./small_closed_off.png";
|
||||
import imgSmallClosedOn from "./small_closed_on.png";
|
||||
import imgSmallOpenedOff from "./small_opened_off.png";
|
||||
import imgSmallOpenedOn from "./small_opened_off.png";
|
||||
|
||||
import fontDigital from "../DigitalWatch/digital-font.ttf";
|
||||
|
||||
import sndBell from "./bell.wav";
|
||||
import sndRunning from "./running.wav";
|
||||
import { Plant } from "../Plant";
|
||||
import { RaisedEvent } from "@/statecharts/runtime_types";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import "./Microwave.css";
|
||||
|
||||
export type MagnetronState = "on" | "off";
|
||||
export type DoorState = "open" | "closed";
|
||||
|
||||
export function toggleDoor(d: DoorState) {
|
||||
if (d === "open") {
|
||||
return "closed";
|
||||
}
|
||||
else return "open";
|
||||
}
|
||||
|
||||
export function toggleMagnetron(m: MagnetronState) {
|
||||
if (m === "on") {
|
||||
return "off";
|
||||
}
|
||||
return "on";
|
||||
}
|
||||
|
||||
export type MicrowaveState = {
|
||||
// Note: the door state is not part of the MicrowaveState because it is not controlled by the statechart, but by the plant.
|
||||
timeDisplay: number,
|
||||
bell: boolean, // whether the bell should ring
|
||||
magnetron: MagnetronState,
|
||||
}
|
||||
|
||||
export type MicrowaveProps = {
|
||||
state: MicrowaveState,
|
||||
speed: number,
|
||||
callbacks: {
|
||||
startPressed: () => void;
|
||||
stopPressed: () => void;
|
||||
incTimePressed: () => void;
|
||||
incTimeReleased: () => void;
|
||||
doorOpened: () => void;
|
||||
doorClosed: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
const imgs = {
|
||||
closed: { off: imgSmallClosedOff, on: imgSmallClosedOn },
|
||||
open: { off: imgSmallOpenedOff, on: imgSmallOpenedOn },
|
||||
}
|
||||
|
||||
|
||||
const BUTTON_HEIGHT = 18;
|
||||
const BUTTON_WIDTH = 60;
|
||||
|
||||
const BUTTON_X0 = 412;
|
||||
const BUTTON_X1 = BUTTON_X0 + BUTTON_WIDTH;
|
||||
|
||||
const START_X0 = BUTTON_X0;
|
||||
const START_Y0 = 234;
|
||||
const START_X1 = BUTTON_X1;
|
||||
const START_Y1 = START_Y0 + BUTTON_HEIGHT;
|
||||
|
||||
const STOP_X0 = BUTTON_X0;
|
||||
const STOP_Y0 = 211;
|
||||
const STOP_X1 = BUTTON_X1;
|
||||
const STOP_Y1 = STOP_Y0 + BUTTON_HEIGHT;
|
||||
|
||||
const INCTIME_X0 = BUTTON_X0;
|
||||
const INCTIME_Y0 = 188;
|
||||
const INCTIME_X1 = BUTTON_X1;
|
||||
const INCTIME_Y1 = INCTIME_Y0 + BUTTON_HEIGHT;
|
||||
|
||||
const DOOR_X0 = 26;
|
||||
const DOOR_Y0 = 68;
|
||||
const DOOR_WIDTH = 353;
|
||||
const DOOR_HEIGHT = 217;
|
||||
|
||||
const ctx = new AudioContext();
|
||||
|
||||
function fetchAudioBuffer(url: string): Promise<AudioBuffer> {
|
||||
return fetch(url).then(res => {
|
||||
return res.arrayBuffer();
|
||||
}).then(buf => {
|
||||
return ctx.decodeAudioData(buf);
|
||||
});
|
||||
}
|
||||
|
||||
// Using the Web Audio API was the only way I could get the 'microwave running' sound to properly play gapless in Chrome.
|
||||
function playAudioBufer(buf: AudioBuffer, loop: boolean, speed: number): AudioCallbacks {
|
||||
const src = ctx.createBufferSource();
|
||||
src.buffer = buf;
|
||||
|
||||
const lowPass = ctx.createBiquadFilter();
|
||||
lowPass.type = 'highpass';
|
||||
lowPass.frequency.value = 20; // let's not blow up anyone's speakers
|
||||
|
||||
src.connect(lowPass);
|
||||
lowPass.connect(ctx.destination);
|
||||
|
||||
if (loop) src.loop = true;
|
||||
src.start();
|
||||
return [
|
||||
() => src.stop(),
|
||||
(speed: number) => {
|
||||
// instead of setting playback rate to 0 (which browsers seem to handle as if playback rate was set to 1, we just set it to a very small value, making it "almost paused")
|
||||
// combined with the lowpass filter above, this should produce any audible results.
|
||||
src.playbackRate.value = (speed===0) ? 0.00001 : speed;
|
||||
return;
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
type AudioCallbacks = [
|
||||
() => void,
|
||||
(speed: number) => void,
|
||||
];
|
||||
|
||||
export function Magnetron({state: {timeDisplay, bell, magnetron}, speed, callbacks}: MicrowaveProps) {
|
||||
const [door, setDoor] = useState<DoorState>("closed");
|
||||
|
||||
const [soundsPlaying, setSoundsPlaying] = useState<AudioCallbacks[]>([]);
|
||||
const [bufRunningPromise] = useState(() => fetchAudioBuffer(sndRunning));
|
||||
const [bufBellPromise] = useState(() => fetchAudioBuffer(sndBell));
|
||||
|
||||
// a bit hacky: when the bell-state changes to true, we play the bell sound...
|
||||
useEffect(() => {
|
||||
if (bell) {
|
||||
bufBellPromise.then(buf => {
|
||||
const cbs = playAudioBufer(buf, false, speed);
|
||||
setSoundsPlaying(sounds => [...sounds, cbs]);
|
||||
});
|
||||
}
|
||||
}, [bell]);
|
||||
|
||||
useEffect(() => {
|
||||
if (magnetron === "on") {
|
||||
const stop = bufRunningPromise.then(buf => {
|
||||
const cbs = playAudioBufer(buf, true, speed);
|
||||
setSoundsPlaying(sounds => [...sounds, cbs]);
|
||||
return () => {
|
||||
cbs[0]();
|
||||
setSoundsPlaying(sounds => sounds.filter(cbs_ => cbs_ !== cbs));
|
||||
}
|
||||
});
|
||||
return () => stop.then(stop => {
|
||||
stop();
|
||||
});
|
||||
}
|
||||
return () => {};
|
||||
}, [magnetron])
|
||||
|
||||
useEffect(() => {
|
||||
soundsPlaying.forEach(([_, setSpeed]) => setSpeed(speed));
|
||||
}, [soundsPlaying, speed])
|
||||
|
||||
// preload(imgSmallClosedOff, {as: "image"});
|
||||
preload(imgSmallClosedOn, {as: "image"});
|
||||
preload(imgSmallOpenedOff, {as: "image"});
|
||||
preload(imgSmallOpenedOn, {as: "image"});
|
||||
|
||||
const openDoor = () => {
|
||||
setDoor("open");
|
||||
callbacks.doorOpened();
|
||||
}
|
||||
const closeDoor = () => {
|
||||
setDoor("closed");
|
||||
callbacks.doorClosed();
|
||||
}
|
||||
|
||||
return <>
|
||||
<style>{`
|
||||
@font-face{
|
||||
font-family: 'digital-font';
|
||||
src: url(${fontDigital});
|
||||
}
|
||||
`}</style>
|
||||
<svg width='400px' height='auto' viewBox="0 0 520 348">
|
||||
<image xlinkHref={imgs[door][magnetron]} width={520} height={348}/>
|
||||
|
||||
<rect className="microwaveButtonHelper" x={START_X0} y={START_Y0} width={BUTTON_WIDTH} height={BUTTON_HEIGHT}
|
||||
onMouseDown={() => callbacks.startPressed()}
|
||||
/>
|
||||
<rect className="microwaveButtonHelper" x={STOP_X0} y={STOP_Y0} width={BUTTON_WIDTH} height={BUTTON_HEIGHT}
|
||||
onMouseDown={() => callbacks.stopPressed()}
|
||||
/>
|
||||
<rect className="microwaveButtonHelper" x={INCTIME_X0} y={INCTIME_Y0} width={BUTTON_WIDTH} height={BUTTON_HEIGHT}
|
||||
onMouseDown={() => callbacks.incTimePressed()}
|
||||
onMouseUp={() => callbacks.incTimeReleased()}
|
||||
/>
|
||||
<rect className="microwaveDoorHelper" x={DOOR_X0} y={DOOR_Y0} width={DOOR_WIDTH} height={DOOR_HEIGHT} onMouseDown={() => door === "open" ? closeDoor() : openDoor()}
|
||||
/>
|
||||
|
||||
<text x={472} y={106} textAnchor="end" fontFamily="digital-font" fontSize={24} fill="lightgreen">{timeDisplay}</text>
|
||||
</svg>
|
||||
</>;
|
||||
}
|
||||
|
||||
export const MicrowavePlant: Plant<MicrowaveState> = {
|
||||
inputEvents: [],
|
||||
outputEvents: [],
|
||||
initial: {
|
||||
timeDisplay: 0,
|
||||
magnetron: "off",
|
||||
bell: false,
|
||||
},
|
||||
reduce: (inputEvent: RaisedEvent, state: MicrowaveState) => {
|
||||
if (inputEvent.name === "setMagnetron") {
|
||||
return { ...state, magnetron: inputEvent.param, bell: false };
|
||||
}
|
||||
if (inputEvent.name === "setTimeDisplay") {
|
||||
return { ...state, timeDisplay: inputEvent.param, bell: false };
|
||||
}
|
||||
if (inputEvent.name === "ringBell") {
|
||||
return { ...state, bell: true };
|
||||
}
|
||||
return state; // unknown event - ignore it
|
||||
},
|
||||
render: (state, raiseEvent, speed) => <Magnetron state={state} speed={speed} callbacks={{
|
||||
startPressed: () => raiseEvent({name: "startPressed"}),
|
||||
stopPressed: () => raiseEvent({name: "stopPressed"}),
|
||||
incTimePressed: () => raiseEvent({name: "incTimePressed"}),
|
||||
incTimeReleased: () => raiseEvent({name: "incTimeReleased"}),
|
||||
doorOpened: () => raiseEvent({name: "door", param: "open"}),
|
||||
doorClosed: () => raiseEvent({name: "door", param: "closed"}),
|
||||
}}/>,
|
||||
}
|
||||
BIN
src/App/Plant/Microwave/bell.wav
Normal file
BIN
src/App/Plant/Microwave/running.wav
Normal file
BIN
src/App/Plant/Microwave/small_closed_off.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
src/App/Plant/Microwave/small_closed_on.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
src/App/Plant/Microwave/small_opened_off.png
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
src/App/Plant/Microwave/small_opened_on.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
12
src/App/Plant/Plant.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { EventTrigger } from "@/statecharts/label_ast";
|
||||
import { RaisedEvent } from "@/statecharts/runtime_types";
|
||||
import { ReactElement } from "react";
|
||||
|
||||
export type Plant<StateType> = {
|
||||
inputEvents: EventTrigger[];
|
||||
outputEvents: EventTrigger[];
|
||||
|
||||
initial: StateType;
|
||||
reduce: (inputEvent: RaisedEvent, state: StateType) => StateType;
|
||||
render: (state: StateType, raise: (event: RaisedEvent) => void, timescale: number) => ReactElement;
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { Dispatch, memo, Ref, SetStateAction, useCallback } from "react";
|
||||
import { Statechart, stateDescription } from "../statecharts/abstract_syntax";
|
||||
import { BigStep, Environment, Mode, RaisedEvent, RT_Event } from "../statecharts/runtime_types";
|
||||
import { formatTime } from "./util";
|
||||
import { formatTime } from "../util/util";
|
||||
import { TimeMode } from "../statecharts/time";
|
||||
import { TraceItem, TraceState } from "./App";
|
||||
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ export const ShowAST = memo(function ShowASTx(props: {root: ConcreteState | Unst
|
|||
});
|
||||
|
||||
import BoltIcon from '@mui/icons-material/Bolt';
|
||||
import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo";
|
||||
import { KeyInfoHidden, KeyInfoVisible } from "./TopPanel/KeyInfo";
|
||||
import { memo, useEffect } from "react";
|
||||
|
||||
export function ShowInputEvents({inputEvents, onRaise, disabled, showKeys}: {inputEvents: EventTrigger[], onRaise: (e: string, p: any) => void, disabled: boolean, showKeys: boolean}) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Dispatch, memo, ReactElement, SetStateAction } from "react";
|
||||
import { KeyInfoHidden, KeyInfoVisible } from "../KeyInfo";
|
||||
import { InsertMode } from "@/VisualEditor/VisualEditor";
|
||||
import { HistoryIcon, PseudoStateIcon, RountangleIcon } from "../Icons";
|
||||
import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo";
|
||||
import { InsertMode } from "@/App/VisualEditor/VisualEditor";
|
||||
import { HistoryIcon, PseudoStateIcon, RountangleIcon } from "./Icons";
|
||||
|
||||
import TrendingFlatIcon from '@mui/icons-material/TrendingFlat';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { Dispatch, memo, ReactElement, SetStateAction, useCallback, useEffect, useState } from "react";
|
||||
import { TimerElapseEvent, Timers } from "../statecharts/runtime_types";
|
||||
import { getSimTime, setPaused, setRealtime, TimeMode } from "../statecharts/time";
|
||||
import { TimerElapseEvent, Timers } from "../../statecharts/runtime_types";
|
||||
import { getSimTime, setPaused, setRealtime, TimeMode } from "../../statecharts/time";
|
||||
import { InsertMode } from "../VisualEditor/VisualEditor";
|
||||
import { About } from "./About";
|
||||
import { EditHistory, TraceState } from "./App";
|
||||
import { About } from "../Modals/About";
|
||||
import { EditHistory, TraceState } from "../App";
|
||||
import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo";
|
||||
import { UndoRedoButtons } from "./TopPanel/UndoRedoButtons";
|
||||
import { ZoomButtons } from "./TopPanel/ZoomButtons";
|
||||
import { formatTime } from "./util";
|
||||
import { UndoRedoButtons } from "./UndoRedoButtons";
|
||||
import { ZoomButtons } from "./ZoomButtons";
|
||||
import { formatTime } from "../../util/util";
|
||||
|
||||
import AccessAlarmIcon from '@mui/icons-material/AccessAlarm';
|
||||
import CachedIcon from '@mui/icons-material/Cached';
|
||||
|
|
@ -17,8 +17,8 @@ import PauseIcon from '@mui/icons-material/Pause';
|
|||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import SkipNextIcon from '@mui/icons-material/SkipNext';
|
||||
import StopIcon from '@mui/icons-material/Stop';
|
||||
import { InsertModes } from "./TopPanel/InsertModes";
|
||||
import { usePersistentState } from "@/util/persistent_state";
|
||||
import { InsertModes } from "./InsertModes";
|
||||
import { usePersistentState } from "@/App/persistent_state";
|
||||
|
||||
export type TopPanelProps = {
|
||||
trace: TraceState | null,
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { memo } from "react";
|
||||
import { KeyInfoHidden, KeyInfoVisible } from "../KeyInfo";
|
||||
import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo";
|
||||
|
||||
import UndoIcon from '@mui/icons-material/Undo';
|
||||
import RedoIcon from '@mui/icons-material/Redo';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { ZOOM_MAX, ZOOM_MIN, ZOOM_STEP } from "@/VisualEditor/parameters";
|
||||
import { ZOOM_MAX, ZOOM_MIN, ZOOM_STEP } from "@/App/VisualEditor/parameters";
|
||||
import { Dispatch, memo, SetStateAction, useEffect } from "react";
|
||||
import { KeyInfoHidden, KeyInfoVisible } from "../KeyInfo";
|
||||
import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo";
|
||||
|
||||
import ZoomInIcon from '@mui/icons-material/ZoomIn';
|
||||
import ZoomOutIcon from '@mui/icons-material/ZoomOut';
|
||||
|
|
|
|||
91
src/App/VisualEditor/ArrowSVG.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { memo } from "react";
|
||||
import { Arrow, ArrowPart } from "../../statecharts/concrete_syntax";
|
||||
import { ArcDirection, euclideanDistance } from "./geometry";
|
||||
import { CORNER_HELPER_RADIUS } from "./parameters";
|
||||
import { arraysEqual } from "@/util/util";
|
||||
|
||||
|
||||
export const ArrowSVG = memo(function(props: { arrow: Arrow; selected: ArrowPart[]; error: string; highlight: boolean; fired: boolean; arc: ArcDirection; initialMarker: boolean }) {
|
||||
const { start, end, uid } = props.arrow;
|
||||
const radius = euclideanDistance(start, end) / 1.6;
|
||||
let largeArc = "1";
|
||||
let arcOrLine = props.arc === "no" ? "L" :
|
||||
`A ${radius} ${radius} 0 ${largeArc} ${props.arc === "ccw" ? "0" : "1"}`;
|
||||
if (props.initialMarker) {
|
||||
// largeArc = "0";
|
||||
arcOrLine = `A ${radius*2} ${radius*2} 0 0 1`
|
||||
}
|
||||
return <g>
|
||||
<path
|
||||
className={"arrow"
|
||||
+ (props.selected.length === 2 ? " selected" : "")
|
||||
+ (props.error ? " error" : "")
|
||||
+ (props.highlight ? " highlight" : "")
|
||||
+ (props.fired ? " fired" : "")
|
||||
}
|
||||
markerStart={props.initialMarker ? 'url(#initialMarker)' : undefined}
|
||||
markerEnd='url(#arrowEnd)'
|
||||
d={`M ${start.x} ${start.y}
|
||||
${arcOrLine}
|
||||
${end.x} ${end.y}`}
|
||||
data-uid={uid}
|
||||
data-parts="start end" />
|
||||
|
||||
{props.error && <text
|
||||
className="error"
|
||||
x={(start.x + end.x) / 2 + 5}
|
||||
y={(start.y + end.y) / 2}
|
||||
textAnchor="middle"
|
||||
data-uid={uid}
|
||||
data-parts="start end">{props.error}</text>}
|
||||
|
||||
<path
|
||||
className="helper"
|
||||
d={`M ${start.x} ${start.y}
|
||||
${arcOrLine}
|
||||
${end.x} ${end.y}`}
|
||||
data-uid={uid}
|
||||
data-parts="start end" />
|
||||
|
||||
{/* selection helper circles */}
|
||||
<circle
|
||||
className="helper"
|
||||
cx={start.x}
|
||||
cy={start.y}
|
||||
r={CORNER_HELPER_RADIUS}
|
||||
data-uid={uid}
|
||||
data-parts="start" />
|
||||
<circle
|
||||
className="helper"
|
||||
cx={end.x}
|
||||
cy={end.y}
|
||||
r={CORNER_HELPER_RADIUS}
|
||||
data-uid={uid}
|
||||
data-parts="end" />
|
||||
|
||||
{/* selection indicator circles */}
|
||||
{props.selected.includes("start") && <circle
|
||||
className="selected"
|
||||
cx={start.x}
|
||||
cy={start.y}
|
||||
r={CORNER_HELPER_RADIUS}
|
||||
data-uid={uid}
|
||||
data-parts="start" />}
|
||||
{props.selected.includes("end") && <circle
|
||||
className="selected"
|
||||
cx={end.x}
|
||||
cy={end.y}
|
||||
r={CORNER_HELPER_RADIUS}
|
||||
data-uid={uid}
|
||||
data-parts="end" />}
|
||||
|
||||
</g>;
|
||||
}, (prevProps, nextProps) => {
|
||||
return prevProps.arrow === nextProps.arrow
|
||||
&& arraysEqual(prevProps.selected, nextProps.selected)
|
||||
&& prevProps.highlight === nextProps.highlight
|
||||
&& prevProps.error === nextProps.error
|
||||
&& prevProps.fired === nextProps.fired
|
||||
&& prevProps.arc === nextProps.arc
|
||||
&& prevProps.initialMarker === nextProps.initialMarker
|
||||
})
|
||||
49
src/App/VisualEditor/DiamondSVG.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { Diamond, RectSide } from "@/statecharts/concrete_syntax";
|
||||
import { rountangleMinSize } from "./VisualEditor";
|
||||
import { Vec2D } from "./geometry";
|
||||
import { RectHelper } from "./RectHelpers";
|
||||
import { memo } from "react";
|
||||
import { arraysEqual } from "@/util/util";
|
||||
|
||||
export const DiamondShape = memo(function DiamondShape(props: {size: Vec2D, extraAttrs: object}) {
|
||||
const minSize = rountangleMinSize(props.size);
|
||||
return <polygon
|
||||
points={`
|
||||
${minSize.x/2} ${0},
|
||||
${minSize.x} ${minSize.y/2},
|
||||
${minSize.x/2} ${minSize.y},
|
||||
${0} ${minSize.y/2}
|
||||
`}
|
||||
fill="white"
|
||||
stroke="black"
|
||||
strokeWidth={2}
|
||||
{...props.extraAttrs}
|
||||
/>;
|
||||
});
|
||||
|
||||
export const DiamondSVG = memo(function DiamondSVG(props: { diamond: Diamond; selected: RectSide[]; highlight: RectSide[]; error?: string; active: boolean; }) {
|
||||
const minSize = rountangleMinSize(props.diamond.size);
|
||||
const extraAttrs = {
|
||||
className: ''
|
||||
+ (props.selected.length === 4 ? " selected" : "")
|
||||
+ (props.error ? " error" : "")
|
||||
+ (props.active ? " active" : ""),
|
||||
"data-uid": props.diamond.uid,
|
||||
"data-parts": "left top right bottom",
|
||||
};
|
||||
return <g transform={`translate(${props.diamond.topLeft.x} ${props.diamond.topLeft.y})`}>
|
||||
<DiamondShape size={minSize} extraAttrs={extraAttrs}/>
|
||||
|
||||
<text x={minSize.x/2} y={minSize.y/2}
|
||||
className="uid"
|
||||
textAnchor="middle">{props.diamond.uid}</text>
|
||||
|
||||
<RectHelper uid={props.diamond.uid} size={minSize} highlight={props.highlight} selected={props.selected} />
|
||||
</g>;
|
||||
}, (prevProps, nextProps) => {
|
||||
return prevProps.diamond === nextProps.diamond
|
||||
&& arraysEqual(prevProps.selected, nextProps.selected)
|
||||
&& arraysEqual(prevProps.highlight, nextProps.highlight)
|
||||
&& prevProps.error === nextProps.error
|
||||
&& prevProps.active === nextProps.active
|
||||
});
|
||||
42
src/App/VisualEditor/HistorySVG.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { memo } from "react";
|
||||
import { Vec2D } from "./geometry";
|
||||
import { HISTORY_RADIUS } from "./parameters";
|
||||
|
||||
export const HistorySVG = memo(function HistorySVG(props: {uid: string, topLeft: Vec2D, kind: "shallow"|"deep", selected: boolean, highlight: boolean}) {
|
||||
const text = props.kind === "shallow" ? "H" : "H*";
|
||||
return <>
|
||||
<circle
|
||||
cx={props.topLeft.x+HISTORY_RADIUS}
|
||||
cy={props.topLeft.y+HISTORY_RADIUS}
|
||||
r={HISTORY_RADIUS}
|
||||
fill="white"
|
||||
stroke="black"
|
||||
strokeWidth={2}
|
||||
data-uid={props.uid}
|
||||
data-parts="history"
|
||||
/>
|
||||
<text
|
||||
x={props.topLeft.x+HISTORY_RADIUS}
|
||||
y={props.topLeft.y+HISTORY_RADIUS+5}
|
||||
textAnchor="middle"
|
||||
fontWeight={500}
|
||||
>{text}</text>
|
||||
<circle
|
||||
className="helper"
|
||||
cx={props.topLeft.x+HISTORY_RADIUS}
|
||||
cy={props.topLeft.y+HISTORY_RADIUS}
|
||||
r={HISTORY_RADIUS}
|
||||
data-uid={props.uid}
|
||||
data-parts="history"
|
||||
/>
|
||||
{(props.selected || props.highlight) &&
|
||||
<circle
|
||||
className={props.selected ? "selected" : props.highlight ? "highlight" : ""}
|
||||
cx={props.topLeft.x+HISTORY_RADIUS}
|
||||
cy={props.topLeft.y+HISTORY_RADIUS}
|
||||
r={HISTORY_RADIUS}
|
||||
data-uid={props.uid}
|
||||
data-parts="history"
|
||||
/>}
|
||||
</>;
|
||||
});
|
||||
58
src/App/VisualEditor/RectHelpers.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { memo } from "react";
|
||||
import { RectSide } from "../../statecharts/concrete_syntax";
|
||||
import { Vec2D } from "./geometry";
|
||||
import { CORNER_HELPER_OFFSET, CORNER_HELPER_RADIUS } from "./parameters";
|
||||
|
||||
function lineGeometryProps(size: Vec2D): [RectSide, object][] {
|
||||
return [
|
||||
["top", {x1: 0, y1: 0, x2: size.x, y2: 0 }],
|
||||
["right", {x1: size.x, y1: 0, x2: size.x, y2: size.y}],
|
||||
["bottom", {x1: 0, y1: size.y, x2: size.x, y2: size.y}],
|
||||
["left", {x1: 0, y1: 0, x2: 0, y2: size.y}],
|
||||
];
|
||||
}
|
||||
|
||||
// no need to memo() this component, the parent component is already memoized
|
||||
export const RectHelper = function RectHelper(props: { uid: string, size: Vec2D, selected: RectSide[], highlight: string[] }) {
|
||||
const geomProps = lineGeometryProps(props.size);
|
||||
return <>
|
||||
{geomProps.map(([side, ps]) => <g key={side}>
|
||||
{(props.selected.includes(side) || props.highlight.includes(side)) && <line className={""
|
||||
+ (props.selected.includes(side) ? " selected" : "")
|
||||
+ (props.highlight.includes(side) ? " highlight" : "")}
|
||||
{...ps} data-uid={props.uid} data-parts={side}/>
|
||||
}
|
||||
<line className="helper" {...ps} data-uid={props.uid} data-parts={side}/>
|
||||
</g>)}
|
||||
|
||||
{/* The corner-helpers have the DOM class 'corner' added to them, because we ignore them when the user is making a selection. Only if the user clicks directly on them, do we select their respective parts. */}
|
||||
<circle
|
||||
className="helper corner"
|
||||
cx={CORNER_HELPER_OFFSET}
|
||||
cy={CORNER_HELPER_OFFSET}
|
||||
r={CORNER_HELPER_RADIUS}
|
||||
data-uid={props.uid}
|
||||
data-parts="top left" />
|
||||
<circle
|
||||
className="helper corner"
|
||||
cx={props.size.x - CORNER_HELPER_OFFSET}
|
||||
cy={CORNER_HELPER_OFFSET}
|
||||
r={CORNER_HELPER_RADIUS}
|
||||
data-uid={props.uid}
|
||||
data-parts="top right" />
|
||||
<circle
|
||||
className="helper corner"
|
||||
cx={props.size.x - CORNER_HELPER_OFFSET}
|
||||
cy={props.size.y - CORNER_HELPER_OFFSET}
|
||||
r={CORNER_HELPER_RADIUS}
|
||||
data-uid={props.uid}
|
||||
data-parts="bottom right" />
|
||||
<circle
|
||||
className="helper corner"
|
||||
cx={CORNER_HELPER_OFFSET}
|
||||
cy={props.size.y - CORNER_HELPER_OFFSET}
|
||||
r={CORNER_HELPER_RADIUS}
|
||||
data-uid={props.uid}
|
||||
data-parts="bottom left" />
|
||||
</>;
|
||||
};
|
||||
48
src/App/VisualEditor/RountangleSVG.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { memo } from "react";
|
||||
import { Rountangle, RectSide } from "../../statecharts/concrete_syntax";
|
||||
import { ROUNTANGLE_RADIUS } from "./parameters";
|
||||
import { RectHelper } from "./RectHelpers";
|
||||
import { rountangleMinSize } from "./VisualEditor";
|
||||
import { arraysEqual } from "@/util/util";
|
||||
|
||||
|
||||
export const RountangleSVG = memo(function RountangleSVG(props: {rountangle: Rountangle; selected: RectSide[]; highlight: RectSide[]; error?: string; active: boolean; }) {
|
||||
const { topLeft, size, uid } = props.rountangle;
|
||||
// always draw a rountangle with a minimum size
|
||||
// during resizing, rountangle can be smaller than this size and even have a negative size, but we don't show it
|
||||
const minSize = rountangleMinSize(size);
|
||||
const extraAttrs = {
|
||||
className: 'rountangle'
|
||||
+ (props.selected.length === 4 ? " selected" : "")
|
||||
+ (' ' + props.rountangle.kind)
|
||||
+ (props.error ? " error" : "")
|
||||
+ (props.active ? " active" : ""),
|
||||
"data-uid": uid,
|
||||
"data-parts": "left top right bottom",
|
||||
};
|
||||
return <g transform={`translate(${topLeft.x} ${topLeft.y})`}>
|
||||
<rect
|
||||
rx={ROUNTANGLE_RADIUS} ry={ROUNTANGLE_RADIUS}
|
||||
x={0}
|
||||
y={0}
|
||||
width={minSize.x}
|
||||
height={minSize.y}
|
||||
{...extraAttrs}
|
||||
/>
|
||||
|
||||
<text x={10} y={20} className="uid">{props.rountangle.uid}</text>
|
||||
|
||||
{props.error &&
|
||||
<text className="error" x={10} y={40} data-uid={uid} data-parts="left top right bottom">{props.error}</text>}
|
||||
|
||||
<RectHelper uid={uid} size={minSize}
|
||||
selected={props.selected}
|
||||
highlight={props.highlight} />
|
||||
</g>;
|
||||
}, (prevProps, nextProps) => {
|
||||
return prevProps.rountangle === nextProps.rountangle
|
||||
&& arraysEqual(prevProps.selected, nextProps.selected)
|
||||
&& arraysEqual(prevProps.highlight, nextProps.highlight)
|
||||
&& prevProps.error === nextProps.error
|
||||
&& prevProps.active === nextProps.active
|
||||
})
|
||||
47
src/App/VisualEditor/TextSVG.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { TextDialog } from "@/App/Modals/TextDialog";
|
||||
import { TraceableError } from "../../statecharts/parser";
|
||||
import {Text} from "../../statecharts/concrete_syntax";
|
||||
import { Dispatch, memo, ReactElement, SetStateAction } from "react";
|
||||
|
||||
export const TextSVG = memo(function TextSVG(props: {text: Text, error: TraceableError|undefined, selected: boolean, highlight: boolean, onEdit: (text: Text, newText: string) => void, setModal: Dispatch<SetStateAction<ReactElement|null>>}) {
|
||||
const commonProps = {
|
||||
"data-uid": props.text.uid,
|
||||
"data-parts": "text",
|
||||
textAnchor: "middle" as "middle",
|
||||
className: "draggableText"
|
||||
+ (props.selected ? " selected":"")
|
||||
+ (props.highlight ? " highlight":""),
|
||||
style: {whiteSpace: "preserve"},
|
||||
}
|
||||
|
||||
let textNode;
|
||||
if (props.error?.data?.location) {
|
||||
const {start,end} = props.error.data.location;
|
||||
textNode = <><text {...commonProps}>
|
||||
{props.text.text.slice(0, start.offset)}
|
||||
<tspan className="error" data-uid={props.text.uid} data-parts="text">
|
||||
{props.text.text.slice(start.offset, end.offset)}
|
||||
{start.offset === end.offset && <>_</>}
|
||||
</tspan>
|
||||
{props.text.text.slice(end.offset)}
|
||||
</text>
|
||||
<text className="error errorHover" y={20} textAnchor="middle">{props.error.message}</text></>;
|
||||
}
|
||||
else {
|
||||
textNode = <text {...commonProps}>{props.text.text}</text>;
|
||||
}
|
||||
|
||||
return <g
|
||||
key={props.text.uid}
|
||||
transform={`translate(${props.text.topLeft.x} ${props.text.topLeft.y})`}
|
||||
onDoubleClick={() => {
|
||||
props.setModal(<TextDialog setModal={props.setModal} text={props.text.text} done={newText => {
|
||||
if (newText) {
|
||||
props.onEdit(props.text, newText);
|
||||
}
|
||||
}} />)
|
||||
}}>
|
||||
{textNode}
|
||||
<text className="draggableText helper" textAnchor="middle" data-uid={props.text.uid} data-parts="text" style={{whiteSpace: "preserve"}}>{props.text.text}</text>
|
||||
</g>;
|
||||
});
|
||||
195
src/App/VisualEditor/VisualEditor.css
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
.svgCanvas {
|
||||
cursor: crosshair;
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
.svgCanvas.dragging {
|
||||
cursor: grabbing !important;
|
||||
}
|
||||
|
||||
/* do not render helpers while dragging something */
|
||||
.svgCanvas.dragging .helper:hover {
|
||||
visibility: hidden !important;
|
||||
}
|
||||
|
||||
|
||||
.svgCanvas text {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* rectangle drawn while a selection is being made */
|
||||
.selecting {
|
||||
fill: blue;
|
||||
fill-opacity: 0.2;
|
||||
stroke-width: 1px;
|
||||
stroke:black;
|
||||
stroke-dasharray: 7 6;
|
||||
}
|
||||
|
||||
.rountangle {
|
||||
fill: white;
|
||||
stroke: black;
|
||||
stroke-width: 2px;
|
||||
}
|
||||
|
||||
.rountangle.selected {
|
||||
/* fill: rgba(0, 0, 255, 0.2); */
|
||||
}
|
||||
.rountangle.error {
|
||||
stroke: var(--error-color);
|
||||
}
|
||||
.rountangle.active {
|
||||
stroke: rgb(205, 133, 0);
|
||||
/* stroke: none; */
|
||||
fill:rgb(255, 240, 214);
|
||||
/* filter: drop-shadow( 2px 2px 2px rgba(124, 37, 10, 0.729)); */
|
||||
}
|
||||
|
||||
.selected:hover:not(:active) {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
line.helper {
|
||||
stroke: rgba(0, 0, 0, 0);
|
||||
stroke-width: 16px;
|
||||
}
|
||||
line.helper:hover:not(:active) {
|
||||
stroke: blue;
|
||||
stroke-opacity: 0.2;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
path.helper {
|
||||
fill: none;
|
||||
stroke: rgba(0, 0, 0, 0);
|
||||
stroke-width: 16px;
|
||||
}
|
||||
path.helper:hover:not(:active) {
|
||||
stroke: blue;
|
||||
stroke-opacity: 0.2;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
circle.helper {
|
||||
fill: rgba(0, 0, 0, 0);
|
||||
}
|
||||
circle.helper:hover:not(:active) {
|
||||
fill: blue;
|
||||
fill-opacity: 0.2;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.rountangle.or {
|
||||
stroke-dasharray: 7 6;
|
||||
fill: #eee;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
fill: none;
|
||||
stroke: black;
|
||||
stroke-width: 2px;
|
||||
}
|
||||
.arrow.selected {
|
||||
stroke: blue;
|
||||
stroke-width: 3px;
|
||||
}
|
||||
|
||||
.arrow::marker {
|
||||
fill: content-stroke;
|
||||
}
|
||||
|
||||
#arrowEnd {
|
||||
fill: context-stroke;
|
||||
}
|
||||
#initialMarker {
|
||||
fill: context-stroke;
|
||||
}
|
||||
|
||||
.arrow:hover {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
line.selected, circle.selected {
|
||||
fill: rgba(0, 0, 255, 0.2);
|
||||
/* stroke-dasharray: 7 6; */
|
||||
stroke: blue;
|
||||
stroke-width: 4px;
|
||||
}
|
||||
|
||||
.draggableText.selected, .draggableText.selected:hover {
|
||||
fill: blue;
|
||||
font-weight: 600;
|
||||
}
|
||||
.draggableText:hover:not(:active) {
|
||||
/* fill: blue; */
|
||||
/* cursor: grab; */
|
||||
}
|
||||
text.helper {
|
||||
fill: rgba(0,0,0,0);
|
||||
stroke: rgba(0,0,0,0);
|
||||
stroke-width: 6px;
|
||||
}
|
||||
text.helper:hover {
|
||||
stroke: blue;
|
||||
stroke-opacity: 0.2;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.draggableText, .draggableText.highlight {
|
||||
paint-order: stroke;
|
||||
stroke: white;
|
||||
stroke-width: 4px;
|
||||
stroke-linecap: butt;
|
||||
stroke-linejoin: miter;
|
||||
stroke-opacity: 1;
|
||||
fill-opacity:1;
|
||||
}
|
||||
|
||||
.draggableText.highlight:not(.selected) {
|
||||
fill: green;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.highlight:not(.selected):not(text) {
|
||||
stroke: green;
|
||||
stroke-width: 3px;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.arrow.error {
|
||||
stroke: var(--error-color);
|
||||
}
|
||||
.arrow.fired {
|
||||
stroke: rgb(160 0 168);
|
||||
stroke-width: 3px;
|
||||
animation: blinkTransition 1s;
|
||||
}
|
||||
|
||||
@keyframes blinkTransition {
|
||||
0% {
|
||||
stroke: rgb(255, 128, 9);
|
||||
stroke-width: 6px;
|
||||
filter: drop-shadow(0 0 5px rgba(255, 128, 9, 1));
|
||||
}
|
||||
100% {
|
||||
stroke: rgb(160 0 168);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
text.error, tspan.error {
|
||||
fill: var(--error-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.errorHover {
|
||||
display: none;
|
||||
}
|
||||
|
||||
g:hover > .errorHover {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
text.uid {
|
||||
fill: lightgrey;
|
||||
}
|
||||
776
src/App/VisualEditor/VisualEditor.tsx
Normal file
|
|
@ -0,0 +1,776 @@
|
|||
import { ClipboardEvent, Dispatch, memo, ReactElement, SetStateAction, useCallback, useEffect, useMemo, 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, Line2D, Rect2D, Vec2D, addV2D, arcDirection, area, getBottomSide, getLeftSide, getRightSide, getTopSide, isEntirelyWithin, normalizeRect, scaleV2D, subtractV2D, transformLine, transformRect } from "./geometry";
|
||||
import { MIN_ROUNTANGLE_SIZE } from "./parameters";
|
||||
import { getBBoxInSvgCoords } from "./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 { Mode } from "@/statecharts/runtime_types";
|
||||
import { arraysEqual, objectsEqual, setsEqual } from "@/util/util";
|
||||
|
||||
export type VisualEditorState = {
|
||||
rountangles: Rountangle[];
|
||||
texts: Text[];
|
||||
arrows: Arrow[];
|
||||
diamonds: Diamond[];
|
||||
history: History[];
|
||||
nextID: number;
|
||||
selection: Selection;
|
||||
};
|
||||
|
||||
type SelectingState = Rect2D | null;
|
||||
|
||||
export type RountangleSelectable = {
|
||||
// kind: "rountangle";
|
||||
parts: RectSide[];
|
||||
uid: string;
|
||||
}
|
||||
type ArrowSelectable = {
|
||||
// kind: "arrow";
|
||||
parts: ArrowPart[];
|
||||
uid: string;
|
||||
}
|
||||
type TextSelectable = {
|
||||
parts: ["text"];
|
||||
uid: string;
|
||||
}
|
||||
type HistorySelectable = {
|
||||
parts: ["history"];
|
||||
uid: string;
|
||||
}
|
||||
type Selectable = RountangleSelectable | ArrowSelectable | TextSelectable | HistorySelectable;
|
||||
|
||||
type Selection = Selectable[];
|
||||
|
||||
|
||||
export const sides: [RectSide, (r:Rect2D)=>Line2D][] = [
|
||||
["left", getLeftSide],
|
||||
["top", getTopSide],
|
||||
["right", getRightSide],
|
||||
["bottom", getBottomSide],
|
||||
];
|
||||
|
||||
export type InsertMode = "and"|"or"|"pseudo"|"shallow"|"deep"|"transition"|"text";
|
||||
|
||||
type VisualEditorProps = {
|
||||
state: VisualEditorState,
|
||||
setState: Dispatch<(v:VisualEditorState) => VisualEditorState>,
|
||||
conns: Connections,
|
||||
syntaxErrors: TraceableError[],
|
||||
trace: TraceState | null,
|
||||
insertMode: InsertMode,
|
||||
highlightActive: Set<string>,
|
||||
highlightTransitions: string[],
|
||||
setModal: Dispatch<SetStateAction<ReactElement|null>>,
|
||||
makeCheckPoint: () => void;
|
||||
zoom: number;
|
||||
};
|
||||
|
||||
export const VisualEditor = memo(function VisualEditor({state, setState, trace, conns, syntaxErrors: errors, insertMode, highlightActive, highlightTransitions, setModal, makeCheckPoint, zoom}: VisualEditorProps) {
|
||||
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
// uid's of selected rountangles
|
||||
const selection = state.selection || [];
|
||||
const setSelection = useCallback((cb: (oldSelection: Selection) => Selection) =>
|
||||
setState(oldState => ({...oldState, selection: cb(oldState.selection)})),[setState]);
|
||||
|
||||
// not null while the user is making a selection
|
||||
const [selectingState, setSelectingState] = useState<SelectingState>(null);
|
||||
|
||||
const refSVG = useRef<SVGSVGElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// bit of a hacky way to force the animation on fired transitions to replay, if the new 'rt' contains the same fired transitions as the previous one
|
||||
requestAnimationFrame(() => {
|
||||
document.querySelectorAll(".arrow.fired").forEach(el => {
|
||||
// @ts-ignore
|
||||
el.style.animation = 'none';
|
||||
requestAnimationFrame(() => {
|
||||
// @ts-ignore
|
||||
el.style.animation = '';
|
||||
})
|
||||
});
|
||||
})
|
||||
}, [trace && trace.idx]);
|
||||
|
||||
const getCurrentPointer = useCallback((e: {pageX: number, pageY: number}) => {
|
||||
const bbox = refSVG.current!.getBoundingClientRect();
|
||||
return {
|
||||
x: (e.pageX - bbox.left)/zoom,
|
||||
y: (e.pageY - bbox.top)/zoom,
|
||||
}
|
||||
}, [refSVG.current, zoom]);
|
||||
|
||||
const onMouseDown = useCallback((e: {button: number, target: any, pageX: number, pageY: number}) => {
|
||||
const currentPointer = getCurrentPointer(e);
|
||||
if (e.button === 2) {
|
||||
makeCheckPoint();
|
||||
// ignore selection, middle mouse button always inserts
|
||||
setState(state => {
|
||||
const newID = state.nextID.toString();
|
||||
if (insertMode === "and" || insertMode === "or") {
|
||||
// insert rountangle
|
||||
return {
|
||||
...state,
|
||||
rountangles: [...state.rountangles, {
|
||||
uid: newID,
|
||||
topLeft: currentPointer,
|
||||
size: MIN_ROUNTANGLE_SIZE,
|
||||
kind: insertMode,
|
||||
}],
|
||||
nextID: state.nextID+1,
|
||||
selection: [{uid: newID, parts: ["bottom", "right"]}],
|
||||
};
|
||||
}
|
||||
else if (insertMode === "pseudo") {
|
||||
return {
|
||||
...state,
|
||||
diamonds: [...state.diamonds, {
|
||||
uid: newID,
|
||||
topLeft: currentPointer,
|
||||
size: MIN_ROUNTANGLE_SIZE,
|
||||
}],
|
||||
nextID: state.nextID+1,
|
||||
selection: [{uid: newID, parts: ["bottom", "right"]}],
|
||||
};
|
||||
}
|
||||
else if (insertMode === "shallow" || insertMode === "deep") {
|
||||
return {
|
||||
...state,
|
||||
history: [...state.history, {
|
||||
uid: newID,
|
||||
kind: insertMode,
|
||||
topLeft: currentPointer,
|
||||
}],
|
||||
nextID: state.nextID+1,
|
||||
selection: [{uid: newID, parts: ["history"]}],
|
||||
}
|
||||
}
|
||||
else if (insertMode === "transition") {
|
||||
return {
|
||||
...state,
|
||||
arrows: [...state.arrows, {
|
||||
uid: newID,
|
||||
start: currentPointer,
|
||||
end: currentPointer,
|
||||
}],
|
||||
nextID: state.nextID+1,
|
||||
selection: [{uid: newID, parts: ["end"]}],
|
||||
}
|
||||
}
|
||||
else if (insertMode === "text") {
|
||||
return {
|
||||
...state,
|
||||
texts: [...state.texts, {
|
||||
uid: newID,
|
||||
text: "// Double-click to edit",
|
||||
topLeft: currentPointer,
|
||||
}],
|
||||
nextID: state.nextID+1,
|
||||
selection: [{uid: newID, parts: ["text"]}],
|
||||
}
|
||||
}
|
||||
throw new Error("unreachable, mode=" + insertMode); // shut up typescript
|
||||
});
|
||||
setDragging(true);
|
||||
return;
|
||||
}
|
||||
|
||||
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 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) => {
|
||||
if (e.key === "Delete") {
|
||||
// delete selection
|
||||
makeCheckPoint();
|
||||
deleteSelection();
|
||||
}
|
||||
if (e.key === "o") {
|
||||
// selected states become OR-states
|
||||
setState(state => ({
|
||||
...state,
|
||||
rountangles: state.rountangles.map(r => state.selection.some(rs => rs.uid === r.uid) ? ({...r, kind: "or"}) : r),
|
||||
}));
|
||||
}
|
||||
if (e.key === "a") {
|
||||
// selected states become AND-states
|
||||
setState(state => ({
|
||||
...state,
|
||||
rountangles: state.rountangles.map(r => state.selection.some(rs => rs.uid === r.uid) ? ({...r, kind: "and"}) : r),
|
||||
}));
|
||||
}
|
||||
// if (e.key === "p") {
|
||||
// // selected states become pseudo-states
|
||||
// setSelection(selection => {
|
||||
// setState(state => ({
|
||||
// ...state,
|
||||
// rountangles: state.rountangles.map(r => selection.some(rs => rs.uid === r.uid) ? ({...r, kind: "pseudo"}) : r),
|
||||
// }));
|
||||
// return selection;
|
||||
// });
|
||||
// }
|
||||
if (e.ctrlKey) {
|
||||
if (e.key === "a") {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
setState(state => ({
|
||||
...state,
|
||||
// @ts-ignore
|
||||
selection: [
|
||||
...state.rountangles.map(r => ({uid: r.uid, parts: ["left", "top", "right", "bottom"]})),
|
||||
...state.diamonds.map(d => ({uid: d.uid, parts: ["left", "top", "right", "bottom"]})),
|
||||
...state.arrows.map(a => ({uid: a.uid, parts: ["start", "end"]})),
|
||||
...state.texts.map(t => ({uid: t.uid, parts: ["text"]})),
|
||||
]
|
||||
}))
|
||||
}
|
||||
}
|
||||
}, [makeCheckPoint, deleteSelection, setState, setDragging]);
|
||||
|
||||
useEffect(() => {
|
||||
// mousemove and mouseup are global event handlers so they keep working when pointer is outside of browser window
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
}, [selectingState, dragging]);
|
||||
|
||||
// for visual feedback, when selecting/moving one thing, we also highlight (in green) all the things that belong to the thing we selected.
|
||||
const sidesToHighlight: {[key: string]: RectSide[]} = {};
|
||||
const arrowsToHighlight: {[key: string]: boolean} = {};
|
||||
const textsToHighlight: {[key: string]: boolean} = {};
|
||||
const rountanglesToHighlight: {[key: string]: boolean} = {};
|
||||
const historyToHighlight: {[key: string]: boolean} = {};
|
||||
for (const selected of selection) {
|
||||
const sides = conns.arrow2SideMap.get(selected.uid);
|
||||
if (sides) {
|
||||
const [startSide, endSide] = sides;
|
||||
if (startSide) sidesToHighlight[startSide.uid] = [...sidesToHighlight[startSide.uid]||[], startSide.part];
|
||||
if (endSide) sidesToHighlight[endSide.uid] = [...sidesToHighlight[endSide.uid]||[], endSide.part];
|
||||
}
|
||||
const texts = [
|
||||
...(conns.arrow2TextMap.get(selected.uid) || []),
|
||||
...(conns.rountangle2TextMap.get(selected.uid) || []),
|
||||
];
|
||||
for (const textUid of texts) {
|
||||
textsToHighlight[textUid] = true;
|
||||
}
|
||||
for (const part of selected.parts) {
|
||||
const arrows = conns.side2ArrowMap.get(selected.uid + '/' + part) || [];
|
||||
if (arrows) {
|
||||
for (const [arrowPart, arrowUid] of arrows) {
|
||||
arrowsToHighlight[arrowUid] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
const arrow2 = conns.text2ArrowMap.get(selected.uid);
|
||||
if (arrow2) {
|
||||
arrowsToHighlight[arrow2] = true;
|
||||
}
|
||||
const rountangleUid = conns.text2RountangleMap.get(selected.uid)
|
||||
if (rountangleUid) {
|
||||
rountanglesToHighlight[rountangleUid] = true;
|
||||
}
|
||||
const history = conns.arrow2HistoryMap.get(selected.uid);
|
||||
if (history) {
|
||||
historyToHighlight[history] = true;
|
||||
}
|
||||
const arrow3 = conns.history2ArrowMap.get(selected.uid) || [];
|
||||
for (const arrow of arrow3) {
|
||||
arrowsToHighlight[arrow] = true;
|
||||
}
|
||||
}
|
||||
|
||||
const onPaste = useCallback((e: ClipboardEvent) => {
|
||||
const data = e.clipboardData?.getData("text/plain");
|
||||
if (data) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(data);
|
||||
}
|
||||
catch (e) {
|
||||
return;
|
||||
}
|
||||
// const offset = {x: 40, y: 40};
|
||||
const offset = {x: 0, y: 0};
|
||||
setState(state => {
|
||||
let nextID = state.nextID;
|
||||
const copiedRountangles: Rountangle[] = parsed.rountangles.map((r: Rountangle) => ({
|
||||
...r,
|
||||
uid: (nextID++).toString(),
|
||||
topLeft: addV2D(r.topLeft, offset),
|
||||
} as Rountangle));
|
||||
const copiedDiamonds: Diamond[] = parsed.diamonds.map((r: Diamond) => ({
|
||||
...r,
|
||||
uid: (nextID++).toString(),
|
||||
topLeft: addV2D(r.topLeft, offset),
|
||||
} as Diamond));
|
||||
const copiedArrows: Arrow[] = parsed.arrows.map((a: Arrow) => ({
|
||||
...a,
|
||||
uid: (nextID++).toString(),
|
||||
start: addV2D(a.start, offset),
|
||||
end: addV2D(a.end, offset),
|
||||
} as Arrow));
|
||||
const copiedTexts: Text[] = parsed.texts.map((t: Text) => ({
|
||||
...t,
|
||||
uid: (nextID++).toString(),
|
||||
topLeft: addV2D(t.topLeft, offset),
|
||||
} as Text));
|
||||
const copiedHistories: History[] = parsed.history.map((h: History) => ({
|
||||
...h,
|
||||
uid: (nextID++).toString(),
|
||||
topLeft: addV2D(h.topLeft, offset),
|
||||
}))
|
||||
// @ts-ignore
|
||||
const newSelection: Selection = [
|
||||
...copiedRountangles.map(r => ({uid: r.uid, parts: ["left", "top", "right", "bottom"]})),
|
||||
...copiedDiamonds.map(d => ({uid: d.uid, parts: ["left", "top", "right", "bottom"]})),
|
||||
...copiedArrows.map(a => ({uid: a.uid, parts: ["start", "end"]})),
|
||||
...copiedTexts.map(t => ({uid: t.uid, parts: ["text"]})),
|
||||
...copiedHistories.map(h => ({uid: h.uid, parts: ["history"]})),
|
||||
];
|
||||
return {
|
||||
...state,
|
||||
rountangles: [...state.rountangles, ...copiedRountangles],
|
||||
diamonds: [...state.diamonds, ...copiedDiamonds],
|
||||
arrows: [...state.arrows, ...copiedArrows],
|
||||
texts: [...state.texts, ...copiedTexts],
|
||||
history: [...state.history, ...copiedHistories],
|
||||
nextID: nextID,
|
||||
selection: newSelection,
|
||||
};
|
||||
});
|
||||
// copyInternal(newSelection, e); // doesn't work
|
||||
e.preventDefault();
|
||||
}
|
||||
}, [setState]);
|
||||
|
||||
const copyInternal = useCallback((state: VisualEditorState, selection: Selection, e: ClipboardEvent) => {
|
||||
const uidsToCopy = new Set(selection.map(shape => shape.uid));
|
||||
const rountanglesToCopy = state.rountangles.filter(r => uidsToCopy.has(r.uid));
|
||||
const diamondsToCopy = state.diamonds.filter(d => uidsToCopy.has(d.uid));
|
||||
const historiesToCopy = state.history.filter(h => uidsToCopy.has(h.uid));
|
||||
const arrowsToCopy = state.arrows.filter(a => uidsToCopy.has(a.uid));
|
||||
const textsToCopy = state.texts.filter(t => uidsToCopy.has(t.uid));
|
||||
e.clipboardData?.setData("text/plain", JSON.stringify({
|
||||
rountangles: rountanglesToCopy,
|
||||
diamonds: diamondsToCopy,
|
||||
history: historiesToCopy,
|
||||
arrows: arrowsToCopy,
|
||||
texts: textsToCopy,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const onCopy = useCallback((e: ClipboardEvent) => {
|
||||
if (selection.length > 0) {
|
||||
e.preventDefault();
|
||||
copyInternal(state, selection, e);
|
||||
}
|
||||
}, [state, selection]);
|
||||
|
||||
const onCut = useCallback((e: ClipboardEvent) => {
|
||||
if (selection.length > 0) {
|
||||
copyInternal(state, selection, e);
|
||||
deleteSelection();
|
||||
e.preventDefault();
|
||||
}
|
||||
}, [state, selection]);
|
||||
|
||||
const onEditText = useCallback((text: Text, newText: string) => {
|
||||
if (newText === "") {
|
||||
// delete text node
|
||||
setState(state => ({
|
||||
...state,
|
||||
texts: state.texts.filter(t => t.uid !== text.uid),
|
||||
}));
|
||||
}
|
||||
else {
|
||||
setState(state => ({
|
||||
...state,
|
||||
texts: state.texts.map(t => {
|
||||
if (t.uid === text.uid) {
|
||||
return {
|
||||
...text,
|
||||
text: newText,
|
||||
}
|
||||
}
|
||||
else {
|
||||
return t;
|
||||
}
|
||||
}),
|
||||
}));
|
||||
}
|
||||
}, [setState]);
|
||||
|
||||
// @ts-ignore
|
||||
const active = trace && trace.trace[trace.idx].mode || new Set();
|
||||
|
||||
const rootErrors = errors.filter(({shapeUid}) => shapeUid === "root").map(({message}) => message);
|
||||
|
||||
const size = 4000*zoom;
|
||||
|
||||
return <svg width={size} height={size}
|
||||
className={"svgCanvas"+(active.has("root")?" active":"")+(dragging ? " dragging" : "")}
|
||||
onMouseDown={onMouseDown}
|
||||
onContextMenu={e => e.preventDefault()}
|
||||
ref={refSVG}
|
||||
|
||||
viewBox={`0 0 4000 4000`}
|
||||
|
||||
onCopy={onCopy}
|
||||
onPaste={onPaste}
|
||||
onCut={onCut}
|
||||
>
|
||||
<defs>
|
||||
<marker
|
||||
id="initialMarker"
|
||||
viewBox="0 0 9 9"
|
||||
refX="4.5"
|
||||
refY="4.5"
|
||||
markerWidth="9"
|
||||
markerHeight="9"
|
||||
markerUnits="userSpaceOnUse">
|
||||
<circle cx={4.5} cy={4.5} r={4.5}/>
|
||||
</marker>
|
||||
<marker
|
||||
id="arrowEnd"
|
||||
viewBox="0 0 10 10"
|
||||
refX="5"
|
||||
refY="5"
|
||||
markerWidth="12"
|
||||
markerHeight="12"
|
||||
orient="auto-start-reverse"
|
||||
markerUnits="userSpaceOnUse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z"/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{(rootErrors.length>0) && <text className="error" x={5} y={20}>{rootErrors.join(' ')}</text>}
|
||||
|
||||
<Rountangles rountangles={state.rountangles} {...{selection, sidesToHighlight, rountanglesToHighlight, errors, highlightActive}}/>
|
||||
<Diamonds diamonds={state.diamonds} {...{selection, sidesToHighlight, rountanglesToHighlight, errors}}/>
|
||||
|
||||
{state.history.map(history => <>
|
||||
<HistorySVG
|
||||
key={history.uid}
|
||||
selected={Boolean(selection.find(h => h.uid === history.uid))}
|
||||
highlight={Boolean(historyToHighlight[history.uid])}
|
||||
{...history}
|
||||
/>
|
||||
</>)}
|
||||
|
||||
{state.arrows.map(arrow => {
|
||||
const sides = conns.arrow2SideMap.get(arrow.uid);
|
||||
let arc = "no" as ArcDirection;
|
||||
if (sides && sides[0]?.uid === sides[1]?.uid && sides[0]!.uid !== undefined) {
|
||||
arc = arcDirection(sides[0]!.part, sides[1]!.part);
|
||||
}
|
||||
const initialMarker = sides && sides[0] === undefined && sides[1] !== undefined;
|
||||
return <ArrowSVG
|
||||
key={arrow.uid}
|
||||
arrow={arrow}
|
||||
selected={selection.find(a => a.uid === arrow.uid)?.parts as ArrowPart[] || []}
|
||||
error={errors
|
||||
.filter(({shapeUid}) => shapeUid === arrow.uid)
|
||||
.map(({message}) => message).join(', ')}
|
||||
highlight={arrowsToHighlight.hasOwnProperty(arrow.uid)}
|
||||
fired={highlightTransitions.includes(arrow.uid)}
|
||||
arc={arc}
|
||||
initialMarker={Boolean(initialMarker)}
|
||||
/>;
|
||||
}
|
||||
)}
|
||||
|
||||
<Texts texts={state.texts} {...{selection, textsToHighlight, errors, onEditText, setModal}}/>
|
||||
|
||||
{selectingState && <Selecting {...selectingState} />}
|
||||
</svg>;
|
||||
});
|
||||
|
||||
export function rountangleMinSize(size: Vec2D): Vec2D {
|
||||
if (size.x >= 40 && size.y >= 40) {
|
||||
return size;
|
||||
}
|
||||
return {
|
||||
x: Math.max(40, size.x),
|
||||
y: Math.max(40, size.y),
|
||||
};
|
||||
}
|
||||
|
||||
const Rountangles = memo(function Rountangles({rountangles, selection, sidesToHighlight, rountanglesToHighlight, errors, highlightActive}: {rountangles: Rountangle[], selection: Selection, sidesToHighlight: {[key: string]: RectSide[]}, rountanglesToHighlight: {[key: string]: boolean}, errors: TraceableError[], highlightActive: Mode}) {
|
||||
return <>{rountangles.map(rountangle => {
|
||||
return <RountangleSVG
|
||||
key={rountangle.uid}
|
||||
rountangle={rountangle}
|
||||
selected={selection.find(r => r.uid === rountangle.uid)?.parts as RectSide[] || []}
|
||||
highlight={[...(sidesToHighlight[rountangle.uid] || []), ...(rountanglesToHighlight[rountangle.uid]?["left","right","top","bottom"]:[]) as RectSide[]]}
|
||||
error={errors
|
||||
.filter(({shapeUid}) => shapeUid === rountangle.uid)
|
||||
.map(({message}) => message).join(', ')}
|
||||
active={highlightActive.has(rountangle.uid)}
|
||||
/>})}</>;
|
||||
}, (p, n) => {
|
||||
return arraysEqual(p.rountangles, n.rountangles)
|
||||
&& arraysEqual(p.selection, n.selection)
|
||||
&& objectsEqual(p.sidesToHighlight, n.sidesToHighlight)
|
||||
&& objectsEqual(p.rountanglesToHighlight, n.rountanglesToHighlight)
|
||||
&& arraysEqual(p.errors, n.errors)
|
||||
&& setsEqual(p.highlightActive, n.highlightActive);
|
||||
});
|
||||
|
||||
const Diamonds = memo(function Diamonds({diamonds, selection, sidesToHighlight, rountanglesToHighlight, errors}: {diamonds: Diamond[], selection: Selection, sidesToHighlight: {[key: string]: RectSide[]}, rountanglesToHighlight: {[key: string]: boolean}, errors: TraceableError[]}) {
|
||||
return <>{diamonds.map(diamond => <>
|
||||
<DiamondSVG
|
||||
key={diamond.uid}
|
||||
diamond={diamond}
|
||||
selected={selection.find(r => r.uid === diamond.uid)?.parts as RectSide[] || []}
|
||||
highlight={[...(sidesToHighlight[diamond.uid] || []), ...(rountanglesToHighlight[diamond.uid]?["left","right","top","bottom"]:[]) as RectSide[]]}
|
||||
error={errors
|
||||
.filter(({shapeUid}) => shapeUid === diamond.uid)
|
||||
.map(({message}) => message).join(', ')}
|
||||
active={false}/>
|
||||
</>)}</>;
|
||||
}, (p, n) => {
|
||||
return arraysEqual(p.diamonds, n.diamonds)
|
||||
&& arraysEqual(p.selection, n.selection)
|
||||
&& objectsEqual(p.sidesToHighlight, n.sidesToHighlight)
|
||||
&& objectsEqual(p.rountanglesToHighlight, n.rountanglesToHighlight)
|
||||
&& arraysEqual(p.errors, n.errors);
|
||||
});
|
||||
|
||||
const Texts = memo(function Texts({texts, selection, textsToHighlight, errors, onEditText, setModal}: {texts: Text[], selection: Selection, textsToHighlight: {[key: string]: boolean}, errors: TraceableError[], onEditText: (text: Text, newText: string) => void, setModal: Dispatch<SetStateAction<ReactElement|null>>}) {
|
||||
return <>{texts.map(txt => {
|
||||
return <TextSVG
|
||||
key={txt.uid}
|
||||
error={errors.find(({shapeUid}) => txt.uid === shapeUid)}
|
||||
text={txt}
|
||||
selected={Boolean(selection.find(s => s.uid === txt.uid)?.parts?.length)}
|
||||
highlight={textsToHighlight.hasOwnProperty(txt.uid)}
|
||||
onEdit={onEditText}
|
||||
setModal={setModal}
|
||||
/>
|
||||
})}</>;
|
||||
}, (p, n) => {
|
||||
return arraysEqual(p.texts, n.texts)
|
||||
&& arraysEqual(p.selection, n.selection)
|
||||
&& objectsEqual(p.textsToHighlight, n.textsToHighlight)
|
||||
&& arraysEqual(p.errors, n.errors)
|
||||
&& p.onEditText === n.onEditText
|
||||
&& 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}
|
||||
/>;
|
||||
}
|
||||
0
src/App/VisualEditor/editor_types.ts
Normal file
198
src/App/VisualEditor/geometry.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import { RectSide } from "../../statecharts/concrete_syntax";
|
||||
|
||||
export type Vec2D = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export type Rect2D = {
|
||||
topLeft: Vec2D;
|
||||
size: Vec2D;
|
||||
};
|
||||
|
||||
export type Line2D = {
|
||||
start: Vec2D;
|
||||
end: Vec2D;
|
||||
};
|
||||
|
||||
// make sure size is always positive
|
||||
export function normalizeRect(rect: Rect2D) {
|
||||
return {
|
||||
topLeft: {
|
||||
x: rect.size.x < 0 ? (rect.topLeft.x + rect.size.x) : rect.topLeft.x,
|
||||
y: rect.size.y < 0 ? (rect.topLeft.y + rect.size.y) : rect.topLeft.y,
|
||||
},
|
||||
size: {
|
||||
x: rect.size.x < 0 ? -rect.size.x : rect.size.x,
|
||||
y: rect.size.y < 0 ? -rect.size.y : rect.size.y,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function isEntirelyWithin(child: Rect2D, parent: Rect2D) {
|
||||
return (
|
||||
child.topLeft.x >= parent.topLeft.x
|
||||
&& child.topLeft.y >= parent.topLeft.y
|
||||
&& child.topLeft.x + child.size.x <= parent.topLeft.x + parent.size.x
|
||||
&& child.topLeft.y + child.size.y <= parent.topLeft.y + parent.size.y
|
||||
);
|
||||
}
|
||||
|
||||
export function isWithin(p: Vec2D, r: Rect2D) {
|
||||
return (
|
||||
p.x >= r.topLeft.x && p.x <= r.topLeft.x + r.size.x
|
||||
&& p.y >= r.topLeft.y && p.y <= r.topLeft.y + r.size.y
|
||||
);
|
||||
}
|
||||
|
||||
export function addV2D(a: Vec2D, b: Vec2D) {
|
||||
return {
|
||||
x: a.x + b.x,
|
||||
y: a.y + b.y,
|
||||
};
|
||||
}
|
||||
|
||||
export function subtractV2D(a: Vec2D, b: Vec2D) {
|
||||
return {
|
||||
x: a.x - b.x,
|
||||
y: a.y - b.y,
|
||||
};
|
||||
}
|
||||
|
||||
export function scaleV2D(p: Vec2D, scale: number) {
|
||||
return {
|
||||
x: p.x * scale,
|
||||
y: p.y * scale,
|
||||
};
|
||||
}
|
||||
|
||||
export function area(rect: Rect2D) {
|
||||
return rect.size.x * rect.size.y;
|
||||
}
|
||||
|
||||
export function lineBBox(line: Line2D, margin=0): Rect2D {
|
||||
return {
|
||||
topLeft: {
|
||||
x: line.start.x - margin,
|
||||
y: line.start.y - margin,
|
||||
},
|
||||
size: {
|
||||
x: line.end.x - line.start.x + margin*2,
|
||||
y: line.end.y - line.start.y + margin*2,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function transformRect(rect: Rect2D, parts: string[], delta: Vec2D): Rect2D {
|
||||
return {
|
||||
topLeft: {
|
||||
x: parts.includes("left") ? rect.topLeft.x + delta.x : rect.topLeft.x,
|
||||
y: parts.includes("top") ? rect.topLeft.y + delta.y : rect.topLeft.y,
|
||||
},
|
||||
size: {
|
||||
x: /*Math.max(40,*/ rect.size.x
|
||||
+ (parts.includes("right") ? delta.x : 0)
|
||||
- (parts.includes("left") ? delta.x : 0),
|
||||
y: /*Math.max(40,*/ rect.size.y
|
||||
+ (parts.includes("bottom") ? delta.y : 0)
|
||||
- (parts.includes("top") ? delta.y : 0),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function transformLine(line: Line2D, parts: string[], delta: Vec2D): Line2D {
|
||||
return {
|
||||
start: parts.includes("start") ? addV2D(line.start, {x: delta.x, y: delta.y}) : line.start,
|
||||
end: parts.includes("end") ? addV2D(line.end, {x: delta.x, y: delta.y}) : line.end,
|
||||
};
|
||||
}
|
||||
|
||||
// intersection point of two lines
|
||||
// note: point may not be part of the lines
|
||||
// author: ChatGPT
|
||||
export function intersectLines(a: Line2D, b: Line2D): Vec2D | null {
|
||||
const { start: A1, end: A2 } = a;
|
||||
const { start: B1, end: B2 } = b;
|
||||
|
||||
const den =
|
||||
(A1.x - A2.x) * (B1.y - B2.y) - (A1.y - A2.y) * (B1.x - B2.x);
|
||||
|
||||
if (den === 0) return null; // parallel or coincident
|
||||
|
||||
const x =
|
||||
((A1.x * A2.y - A1.y * A2.x) * (B1.x - B2.x) -
|
||||
(A1.x - A2.x) * (B1.x * B2.y - B1.y * B2.x)) /
|
||||
den;
|
||||
|
||||
const y =
|
||||
((A1.x * A2.y - A1.y * A2.x) * (B1.y - B2.y) -
|
||||
(A1.y - A2.y) * (B1.x * B2.y - B1.y * B2.x)) /
|
||||
den;
|
||||
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
export function euclideanDistance(a: Vec2D, b: Vec2D): number {
|
||||
const diffX = a.x - b.x;
|
||||
const diffY = a.y - b.y;
|
||||
return Math.hypot(diffX, diffY);
|
||||
// return Math.sqrt(diffX*diffX + diffY*diffY);
|
||||
}
|
||||
|
||||
export function getLeftSide(rect: Rect2D): Line2D {
|
||||
return {
|
||||
start: rect.topLeft,
|
||||
end: {x: rect.topLeft.x, y: rect.topLeft.y + rect.size.y},
|
||||
};
|
||||
}
|
||||
export function getTopSide(rect: Rect2D): Line2D {
|
||||
return {
|
||||
start: rect.topLeft,
|
||||
end: { x: rect.topLeft.x + rect.size.x, y: rect.topLeft.y },
|
||||
};
|
||||
}
|
||||
export function getRightSide(rect: Rect2D): Line2D {
|
||||
return {
|
||||
start: { x: rect.topLeft.x + rect.size.x, y: rect.topLeft.y },
|
||||
end: { x: rect.topLeft.x + rect.size.x, y: rect.topLeft.y + rect.size.y },
|
||||
};
|
||||
}
|
||||
export function getBottomSide(rect: Rect2D): Line2D {
|
||||
return {
|
||||
start: { x: rect.topLeft.x, y: rect.topLeft.y + rect.size.y },
|
||||
end: { x: rect.topLeft.x + rect.size.x, y: rect.topLeft.y + rect.size.y },
|
||||
};
|
||||
}
|
||||
|
||||
export type ArcDirection = "no" | "cw" | "ccw";
|
||||
|
||||
export function arcDirection(start: RectSide, end: RectSide): ArcDirection {
|
||||
if (start === end) {
|
||||
if (start === "left" || start === "top") {
|
||||
return "ccw";
|
||||
}
|
||||
else {
|
||||
return "cw";
|
||||
}
|
||||
}
|
||||
const both = [start, end];
|
||||
if (both.includes("top") && both.includes("bottom")) {
|
||||
return "no";
|
||||
}
|
||||
if (both.includes("left") && both.includes("right")) {
|
||||
return "no";
|
||||
}
|
||||
if (start === "top" && end === "left") {
|
||||
return "ccw";
|
||||
}
|
||||
if (start === "left" && end === "bottom") {
|
||||
return "ccw";
|
||||
}
|
||||
if (start === "bottom" && end === "right") {
|
||||
return "ccw";
|
||||
}
|
||||
if (start === "right" && end === "top") {
|
||||
return "ccw";
|
||||
}
|
||||
return "cw";
|
||||
}
|
||||
17
src/App/VisualEditor/parameters.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
|
||||
export const ARROW_SNAP_THRESHOLD = 20;
|
||||
export const TEXT_SNAP_THRESHOLD = 40;
|
||||
|
||||
export const ROUNTANGLE_RADIUS = 20;
|
||||
export const MIN_ROUNTANGLE_SIZE = { x: ROUNTANGLE_RADIUS*2, y: ROUNTANGLE_RADIUS*2 };
|
||||
|
||||
// those hoverable green transparent circles in the corners of rountangles:
|
||||
export const CORNER_HELPER_OFFSET = 4;
|
||||
export const CORNER_HELPER_RADIUS = 16;
|
||||
|
||||
export const HISTORY_RADIUS = 20;
|
||||
|
||||
|
||||
export const ZOOM_STEP = 1.25;
|
||||
export const ZOOM_MIN = (1/ZOOM_STEP)**6;
|
||||
export const ZOOM_MAX = ZOOM_STEP**6;
|
||||
30
src/App/VisualEditor/svg_helper.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { Rect2D } from "./geometry";
|
||||
|
||||
// author: ChatGPT
|
||||
export function getBBoxInSvgCoords(el: SVGGraphicsElement, svg: SVGSVGElement): Rect2D {
|
||||
const b = el.getBBox();
|
||||
const m = el.getCTM()!;
|
||||
const toSvg = (x: number, y: number) => {
|
||||
const p = svg.createSVGPoint();
|
||||
p.x = x; p.y = y;
|
||||
return p.matrixTransform(m);
|
||||
};
|
||||
const pts = [
|
||||
toSvg(b.x, b.y),
|
||||
toSvg(b.x + b.width, b.y),
|
||||
toSvg(b.x, b.y + b.height),
|
||||
toSvg(b.x + b.width, b.y + b.height)
|
||||
];
|
||||
const xs = pts.map(p => p.x);
|
||||
const ys = pts.map(p => p.y);
|
||||
return {
|
||||
topLeft: {
|
||||
x: Math.min(...xs),
|
||||
y: Math.min(...ys),
|
||||
},
|
||||
size: {
|
||||
x: Math.max(...xs) - Math.min(...xs),
|
||||
y: Math.max(...ys) - Math.min(...ys),
|
||||
},
|
||||
};
|
||||
}
|
||||
38
src/App/persistent_state.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { Dispatch, SetStateAction, useCallback, useState } from "react";
|
||||
|
||||
// like useState, but it is persisted in localStorage
|
||||
// important: values must be JSON-(de-)serializable
|
||||
export function usePersistentState<T>(key: string, initial: T): [T, Dispatch<SetStateAction<T>>] {
|
||||
const [state, setState] = useState(() => {
|
||||
const recovered = localStorage.getItem(key);
|
||||
let parsed;
|
||||
if (recovered !== null) {
|
||||
try {
|
||||
parsed = JSON.parse(recovered);
|
||||
return parsed;
|
||||
} catch (e) {
|
||||
// console.warn(`failed to recover state for option '${key}'`, e,
|
||||
// '(this is normal when running the app for the first time)');
|
||||
}
|
||||
}
|
||||
return initial;
|
||||
});
|
||||
|
||||
const setStateWrapped = useCallback((val: SetStateAction<T>) => {
|
||||
setState((oldState: T) => {
|
||||
let newVal;
|
||||
if (typeof val === 'function') {
|
||||
// @ts-ignore: i don't understand why 'val' might not be callable
|
||||
newVal = val(oldState);
|
||||
}
|
||||
else {
|
||||
newVal = val;
|
||||
}
|
||||
const serialized = JSON.stringify(newVal);
|
||||
localStorage.setItem(key, serialized);
|
||||
return newVal;
|
||||
});
|
||||
}, [setState]);
|
||||
|
||||
return [state, setStateWrapped];
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { Dispatch, SetStateAction } from "react";
|
||||
import { InsertMode } from "../VisualEditor/VisualEditor";
|
||||
import { InsertMode } from "./VisualEditor/VisualEditor";
|
||||
|
||||
export function getKeyHandler(setMode: Dispatch<SetStateAction<InsertMode>>) {
|
||||
return function onKeyDown(e: KeyboardEvent) {
|
||||
|
|
|
|||
|
|
@ -1,68 +0,0 @@
|
|||
export function formatTime(timeMs: number) {
|
||||
const leadingZeros = "00" + Math.floor(timeMs) % 1000;
|
||||
const formatted = `${Math.floor(timeMs / 1000)}.${(leadingZeros).substring(leadingZeros.length-3)}`;
|
||||
return formatted;
|
||||
}
|
||||
|
||||
export function compactTime(timeMs: number) {
|
||||
if (timeMs % 1000 === 0) {
|
||||
return `${timeMs / 1000}s`;
|
||||
}
|
||||
return `${timeMs} ms`;
|
||||
}
|
||||
|
||||
export function memoize<InType,OutType>(fn: (i: InType) => OutType) {
|
||||
const cache = new Map();
|
||||
return (i: InType) => {
|
||||
const found = cache.get(i);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
const result = fn(i);
|
||||
cache.set(i, result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// compare arrays by value
|
||||
export function arraysEqual<T>(a: T[], b: T[], cmp: (a: T, b: T) => boolean = (a,b)=>a===b): boolean {
|
||||
if (a === b)
|
||||
return true;
|
||||
|
||||
if (a.length !== b.length)
|
||||
return false;
|
||||
|
||||
for (let i=0; i<a.length; i++)
|
||||
if (!cmp(a[i],b[i]))
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function setsEqual<T>(a: Set<T>, b: Set<T>): boolean {
|
||||
if (a === b)
|
||||
return true;
|
||||
|
||||
if (a.size !== b.size)
|
||||
return false;
|
||||
|
||||
for (const itemA of a)
|
||||
if (!b.has(itemA))
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function objectsEqual<T>(a: {[key: string]: T}, b: {[key: string]: T}, cmp: (a: T, b: T) => boolean = (a,b)=>a===b): boolean {
|
||||
if (a === b)
|
||||
return true;
|
||||
|
||||
if (Object.keys(a).length !== Object.keys(b).length)
|
||||
return false;
|
||||
|
||||
for (const [keyA, valueA] of Object.entries(a))
|
||||
if (!cmp(b[keyA], valueA))
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||