statebuddy/src/App/VisualEditor/VisualEditor.tsx

305 lines
11 KiB
TypeScript

import { Dispatch, memo, ReactElement, SetStateAction, useCallback, useEffect, useRef, useState } from "react";
import { Mode } from "@/statecharts/runtime_types";
import { arraysEqual, objectsEqual, setsEqual } from "@/util/util";
import { ArrowPart, ConcreteSyntax, Diamond, 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 { InsertMode } from "../TopPanel/InsertModes";
import { ArrowSVG } from "./ArrowSVG";
import { DiamondSVG } from "./DiamondSVG";
import { HistorySVG } from "./HistorySVG";
import { RountangleSVG } from "./RountangleSVG";
import { TextSVG } from "./TextSVG";
import "./VisualEditor.css";
import { useCopyPaste } from "./hooks/useCopyPaste";
import { useMouse } from "./hooks/useMouse";
export type VisualEditorState = ConcreteSyntax & {
nextID: number;
selection: Selection;
};
export type RountangleSelectable = {
part: RectSide;
uid: string;
}
type ArrowSelectable = {
part: ArrowPart;
uid: string;
}
type TextSelectable = {
part: "text";
uid: string;
}
type HistorySelectable = {
part: "history";
uid: string;
}
type Selectable = RountangleSelectable | ArrowSelectable | TextSelectable | HistorySelectable;
export type Selection = Selectable[];
type VisualEditorProps = {
state: VisualEditorState,
commitState: Dispatch<(v:VisualEditorState) => VisualEditorState>,
replaceState: Dispatch<(v:VisualEditorState) => VisualEditorState>,
conns: Connections,
syntaxErrors: TraceableError[],
insertMode: InsertMode,
highlightActive: Set<string>,
highlightTransitions: string[],
setModal: Dispatch<SetStateAction<ReactElement|null>>,
zoom: number;
};
export const VisualEditor = memo(function VisualEditor({state, commitState, replaceState, conns, syntaxErrors: errors, insertMode, highlightActive, highlightTransitions, setModal, zoom}: VisualEditorProps) {
// While dragging, the editor is in a temporary state (a state that is not committed to the edit history). If the temporary state is not null, then this state will be what you see.
// const [temporaryState, setTemporaryState] = useState<VisualEditorState | null>(null);
// const state = temporaryState || committedState;
// uid's of selected rountangles
const selection = state.selection;
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 = '';
})
});
})
}, [highlightTransitions]);
const {onCopy, onPaste, onCut} = useCopyPaste(state, commitState, selection);
const {onMouseDown, selectionRect} = useMouse(insertMode, zoom, refSVG,
state,
commitState,
replaceState);
// 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;
}
const arrows = conns.side2ArrowMap.get(selected.uid + '/' + selected.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 onEditText = useCallback((text: Text, newText: string) => {
if (newText === "") {
// delete text node
commitState(state => ({
...state,
texts: state.texts.filter(t => t.uid !== text.uid),
}));
}
else {
commitState(state => ({
...state,
texts: state.texts.map(t => {
if (t.uid === text.uid) {
return {
...text,
text: newText,
}
}
else {
return t;
}
}),
}));
}
}, [commitState]);
const rootErrors = errors.filter(({shapeUid}) => shapeUid === "root").map(({message}) => message);
const size = 4000*zoom;
return <svg width={size} height={size}
className={"svgCanvas"+(highlightActive.has("root")?" active":"")}
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.filter(a => a.uid === arrow.uid).map(({part})=> part 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}}/>
{selectionRect}
</svg>;
});
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.filter(r => r.uid === rountangle.uid).map(({part}) => part 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.filter(r => r.uid === diamond.uid).map(({part})=>part 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.filter(s => s.uid === txt.uid).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;
});