cleanup the code a bit...

This commit is contained in:
Joeri Exelmans 2025-11-14 19:05:02 +01:00
parent 1bd801ce5d
commit 7994cd6eb0
19 changed files with 685 additions and 625 deletions

View file

@ -1,7 +1,7 @@
import "../index.css";
import "./App.css";
import { Dispatch, ReactElement, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { detectConnections } from "@/statecharts/detect_connections";
import { parseStatechart } from "../statecharts/parser";
@ -50,10 +50,7 @@ export function App() {
const [editHistory, setEditHistory] = useState<EditHistory|null>(null);
const [modal, setModal] = useState<ReactElement|null>(null);
// const [lightMode, setLightMode] = usePersistentState<LightMode>("lightMode", "auto");
const lightMode = "auto";
const {makeCheckPoint, onRedo, onUndo, onRotate} = useEditor(setEditHistory);
const {commitState, replaceState, onRedo, onUndo, onRotate} = useEditor(setEditHistory);
const editorState = editHistory && editHistory.current;
const setEditorState = useCallback((cb: (value: VisualEditorState) => VisualEditorState) => {
@ -172,7 +169,7 @@ export function App() {
{/* Editor */}
<div style={{flexGrow: 1, overflow: "auto"}}>
{editorState && conns && syntaxErrors &&
<VisualEditor {...{state: editorState, setState: setEditorState, conns, syntaxErrors: allErrors, highlightActive, highlightTransitions, setModal, makeCheckPoint, ...appState}}/>}
<VisualEditor {...{state: editorState, commitState, replaceState, conns, syntaxErrors: allErrors, highlightActive, highlightTransitions, setModal, ...appState}}/>}
</div>
{appState.showFindReplace &&

File diff suppressed because one or more lines are too long

View file

@ -1,5 +1,5 @@
import { useAudioContext } from "@/hooks/useAudioContext";
import { ConcreteSyntax } from "@/App/VisualEditor/VisualEditor";
import { ConcreteSyntax } from "@/statecharts/concrete_syntax";
import { detectConnections } from "@/statecharts/detect_connections";
import { parseStatechart } from "@/statecharts/parser";
import { RT_Statechart } from "@/statecharts/runtime_types";

View file

@ -18,7 +18,7 @@ import { detectConnections } from "@/statecharts/detect_connections";
import { parseStatechart } from "@/statecharts/parser";
import microwaveConcreteSyntax from "./model.json";
import { ConcreteSyntax } from "@/App/VisualEditor/VisualEditor";
import { ConcreteSyntax } from "@/statecharts/concrete_syntax";
import { objectsEqual } from "@/util/util";
export const [microwaveAbstractSyntax, microwaveErrors] = parseStatechart(microwaveConcreteSyntax as ConcreteSyntax, detectConnections(microwaveConcreteSyntax as ConcreteSyntax));

View file

@ -9,7 +9,7 @@ import { preload } from "react-dom";
import trafficLightConcreteSyntax from "./model.json";
import { parseStatechart } from "@/statecharts/parser";
import { ConcreteSyntax } from "@/App/VisualEditor/VisualEditor";
import { ConcreteSyntax } from "@/statecharts/concrete_syntax";
import { detectConnections } from "@/statecharts/detect_connections";
import { makeStatechartPlant, PlantRenderProps, StatechartPlantSpec } from "../Plant";
import { RT_Statechart } from "@/statecharts/runtime_types";

View file

@ -134,7 +134,7 @@ export function SideBar({showExecutionTrace, showConnections, plantName, showPla
inputEvents={ast.inputEvents}
onRaise={(e,p) => onRaise("debug."+e,p)}
disabled={trace===null || trace.trace[trace.idx].kind === "error"}
showKeys={true}/>}
/>}
</PersistentDetails>
{/* Internal events */}
<PersistentDetails state={showInternalEvents} setState={setShowInternalEvents}>

View file

@ -2,7 +2,7 @@ import { memo } from "react";
import { Arrow, ArrowPart } from "../../statecharts/concrete_syntax";
import { ArcDirection, euclideanDistance } from "../../util/geometry";
import { CORNER_HELPER_RADIUS } from "../parameters";
import { arraysEqual } from "@/util/util";
import { arraysEqual, jsonDeepEqual } from "@/util/util";
export const ArrowSVG = memo(function(props: { arrow: Arrow; selected: ArrowPart[]; error: string; highlight: boolean; fired: boolean; arc: ArcDirection; initialMarker: boolean }) {
@ -81,7 +81,7 @@ export const ArrowSVG = memo(function(props: { arrow: Arrow; selected: ArrowPart
</g>;
}, (prevProps, nextProps) => {
return prevProps.arrow === nextProps.arrow
return jsonDeepEqual(prevProps.arrow, nextProps.arrow)
&& arraysEqual(prevProps.selected, nextProps.selected)
&& prevProps.highlight === nextProps.highlight
&& prevProps.error === nextProps.error

View file

@ -3,7 +3,7 @@ import { rountangleMinSize } from "@/statecharts/concrete_syntax";
import { Vec2D } from "../../util/geometry";
import { RectHelper } from "./RectHelpers";
import { memo } from "react";
import { arraysEqual } from "@/util/util";
import { arraysEqual, jsonDeepEqual } from "@/util/util";
export const DiamondShape = memo(function DiamondShape(props: {size: Vec2D, extraAttrs: object}) {
const minSize = rountangleMinSize(props.size);
@ -42,7 +42,7 @@ export const DiamondSVG = memo(function DiamondSVG(props: { diamond: Diamond; se
<RectHelper uid={props.diamond.uid} size={minSize} highlight={props.highlight} selected={props.selected} />
</g>;
}, (prevProps, nextProps) => {
return prevProps.diamond === nextProps.diamond
return jsonDeepEqual(prevProps.diamond, nextProps.diamond)
&& arraysEqual(prevProps.selected, nextProps.selected)
&& arraysEqual(prevProps.highlight, nextProps.highlight)
&& prevProps.error === nextProps.error

View file

@ -3,7 +3,7 @@ import { Rountangle, RectSide } from "../../statecharts/concrete_syntax";
import { ROUNTANGLE_RADIUS } from "../parameters";
import { RectHelper } from "./RectHelpers";
import { rountangleMinSize } from "@/statecharts/concrete_syntax";
import { arraysEqual } from "@/util/util";
import { arraysEqual, jsonDeepEqual } from "@/util/util";
export const RountangleSVG = memo(function RountangleSVG(props: {rountangle: Rountangle; selected: RectSide[]; highlight: RectSide[]; error?: string; active: boolean; }) {
@ -40,7 +40,7 @@ export const RountangleSVG = memo(function RountangleSVG(props: {rountangle: Rou
highlight={props.highlight} />
</g>;
}, (prevProps, nextProps) => {
return prevProps.rountangle === nextProps.rountangle
return jsonDeepEqual(prevProps.rountangle, nextProps.rountangle)
&& arraysEqual(prevProps.selected, nextProps.selected)
&& arraysEqual(prevProps.highlight, nextProps.highlight)
&& prevProps.error === nextProps.error

View file

@ -2,6 +2,7 @@ import { TextDialog } from "@/App/Modals/TextDialog";
import { TraceableError } from "../../statecharts/parser";
import {Text} from "../../statecharts/concrete_syntax";
import { Dispatch, memo, ReactElement, SetStateAction } from "react";
import { jsonDeepEqual } from "@/util/util";
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 = {
@ -44,4 +45,11 @@ export const TextSVG = memo(function TextSVG(props: {text: Text, error: Traceabl
{textNode}
<text className="draggableText helper" textAnchor="middle" data-uid={props.text.uid} data-parts="text" style={{whiteSpace: "preserve"}}>{props.text.text}</text>
</g>;
}, (prevProps, newProps) => {
return jsonDeepEqual(prevProps.text, newProps)
&& prevProps.highlight === newProps.highlight
&& prevProps.onEdit === newProps.onEdit
&& prevProps.setModal === newProps.setModal
&& prevProps.error === newProps.error
&& prevProps.selected === newProps.selected
});

View file

@ -1,8 +1,8 @@
import { Dispatch, memo, ReactElement, SetStateAction, useCallback, useEffect, useRef } from "react";
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 { Arrow, ArrowPart, Diamond, History, RectSide, Rountangle, Text } from "../../statecharts/concrete_syntax";
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";
@ -16,14 +16,6 @@ import "./VisualEditor.css";
import { useCopyPaste } from "./hooks/useCopyPaste";
import { useMouse } from "./hooks/useMouse";
export type ConcreteSyntax = {
rountangles: Rountangle[];
texts: Text[];
arrows: Arrow[];
diamonds: Diamond[];
history: History[];
};
export type VisualEditorState = ConcreteSyntax & {
nextID: number;
selection: Selection;
@ -51,23 +43,26 @@ export type Selection = Selectable[];
type VisualEditorProps = {
state: VisualEditorState,
setState: Dispatch<(v:VisualEditorState) => VisualEditorState>,
commitState: Dispatch<(v:VisualEditorState) => VisualEditorState>,
replaceState: Dispatch<(v:VisualEditorState) => VisualEditorState>,
conns: Connections,
syntaxErrors: TraceableError[],
// trace: TraceState | null,
// activeStates: Set<string>,
insertMode: InsertMode,
highlightActive: Set<string>,
highlightTransitions: string[],
setModal: Dispatch<SetStateAction<ReactElement|null>>,
makeCheckPoint: () => void;
zoom: number;
};
export const VisualEditor = memo(function VisualEditor({state, setState, conns, syntaxErrors: errors, insertMode, highlightActive, highlightTransitions, setModal, makeCheckPoint, zoom}: VisualEditorProps) {
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 selection = state.selection;
const refSVG = useRef<SVGSVGElement>(null);
@ -86,9 +81,12 @@ export const VisualEditor = memo(function VisualEditor({state, setState, conns,
}, [highlightTransitions]);
const {onCopy, onPaste, onCut, deleteSelection} = useCopyPaste(makeCheckPoint, state, setState, selection);
const {onCopy, onPaste, onCut} = useCopyPaste(state, commitState, selection);
const {onMouseDown, selectionRect} = useMouse(makeCheckPoint, insertMode, zoom, refSVG, state, setState, deleteSelection);
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.
@ -138,13 +136,13 @@ export const VisualEditor = memo(function VisualEditor({state, setState, conns,
const onEditText = useCallback((text: Text, newText: string) => {
if (newText === "") {
// delete text node
setState(state => ({
commitState(state => ({
...state,
texts: state.texts.filter(t => t.uid !== text.uid),
}));
}
else {
setState(state => ({
commitState(state => ({
...state,
texts: state.texts.map(t => {
if (t.uid === text.uid) {
@ -159,14 +157,14 @@ export const VisualEditor = memo(function VisualEditor({state, setState, conns,
}),
}));
}
}, [setState]);
}, [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":"")/*+(dragging ? " dragging" : "")*/}
className={"svgCanvas"+(highlightActive.has("root")?" active":"")}
onMouseDown={onMouseDown}
onContextMenu={e => e.preventDefault()}
ref={refSVG}

View file

@ -7,14 +7,13 @@ import { useShortcuts } from "@/hooks/useShortcuts";
// 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) {
export function useCopyPaste(state: VisualEditorState, commitState: 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);
makeCheckPoint();
setState(state => {
commitState(state => {
try {
let nextID = state.nextID;
const copiedRountangles: Rountangle[] = parsed.rountangles.map((r: Rountangle) => ({
@ -73,7 +72,7 @@ export function useCopyPaste(makeCheckPoint: () => void, state: VisualEditorStat
}
e.preventDefault();
}
}, [makeCheckPoint, setState]);
}, [commitState]);
const copyInternal = useCallback((state: VisualEditorState, selection: Selection, e: ClipboardEvent) => {
const uidsToCopy = new Set(selection.map(shape => shape.uid));
@ -107,8 +106,7 @@ export function useCopyPaste(makeCheckPoint: () => void, state: VisualEditorStat
}, [state, selection]);
const deleteSelection = useCallback(() => {
makeCheckPoint();
setState(state => ({
commitState(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)),
@ -117,7 +115,7 @@ export function useCopyPaste(makeCheckPoint: () => void, state: VisualEditorStat
texts: state.texts.filter(t => !state.selection.some(ts => ts.uid === t.uid)),
selection: [],
}));
}, [makeCheckPoint, setState]);
}, [commitState]);
useShortcuts([
{keys: ["Delete"], action: deleteSelection},

View file

@ -8,7 +8,14 @@ import { Selecting, SelectingState } from "../Selection";
import { Selection, VisualEditorState } from "../VisualEditor";
import { useShortcuts } from "@/hooks/useShortcuts";
export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoom: number, refSVG: {current: SVGSVGElement|null}, state: VisualEditorState, setState: Dispatch<(v: VisualEditorState) => VisualEditorState>, deleteSelection: () => void) {
export function useMouse(
insertMode: InsertMode,
zoom: number,
refSVG: {current: SVGSVGElement|null},
state: VisualEditorState,
commitState: Dispatch<(v: VisualEditorState) => VisualEditorState>,
replaceState: Dispatch<(v: VisualEditorState) => VisualEditorState>)
{
const [dragging, setDragging] = useState(false);
const [shiftOrCtrlPressed, setShiftOrCtrlPressed] = useState(false);
@ -16,8 +23,13 @@ export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoo
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 commitSelection = useCallback((cb: (oldSelection: Selection) => Selection) => {
commitState(oldState => ({...oldState, selection: cb(oldState.selection)}));
},[commitState]);
const replaceSelection = useCallback((cb: (oldSelection: Selection) => Selection) =>
replaceState(oldState => ({...oldState, selection: cb(oldState.selection)})),[replaceState]);
const getCurrentPointer = useCallback((e: {pageX: number, pageY: number}) => {
const bbox = refSVG.current!.getBoundingClientRect();
@ -30,9 +42,8 @@ export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoo
const onMouseDown = useCallback((e: {button: number, target: any, pageX: number, pageY: number}) => {
const currentPointer = getCurrentPointer(e);
if (e.button === 2) {
makeCheckPoint();
// ignore selection, right mouse button always inserts
setState(state => {
commitState(state => {
const newID = state.nextID.toString();
if (insertMode === "and" || insertMode === "or") {
// insert rountangle
@ -102,64 +113,81 @@ export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoo
return;
}
let appendTo: Selection;
if (shiftOrCtrlPressed) {
appendTo = selection;
}
else {
appendTo = [];
}
const startMakingSelection = () => {
setDragging(false);
setSelectingState({
topLeft: currentPointer,
size: {x: 0, y: 0},
});
commitSelection(_ => appendTo);
}
if (e.button === 0) {
if (!shiftOrCtrlPressed) {
// 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.
// left mouse button
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
// mouse hovers over a shape or part of a shape
let allPartsInSelection = true;
for (const part of parts) {
// is there anything in our existing selection that is not under the cursor?
if (!(selection.some(s => (s.uid === uid) && (s.part === part)))) {
allPartsInSelection = false;
break;
}
}
if (!allPartsInSelection) {
// the part is not in existing selection
if (e.target.classList.contains("helper")) {
setSelection(() => parts.map(part => ({uid, part})) as Selection);
// it's only a helper
// -> update selection by the part and start dragging it
commitSelection(() => [
...appendTo,
...parts.map(part => ({uid, part})) as Selection,
]);
setDragging(true);
}
else {
setDragging(false);
setSelectingState({
topLeft: currentPointer,
size: {x: 0, y: 0},
});
setSelection(() => []);
return;
// it's an actual shape
// (we treat shapes differently from helpers because in a big hierarchical model it is nearly impossible to click anywhere without clicking inside a shape)
startMakingSelection();
}
}
// start dragging
else {
// the part is in existing selection
// -> just start dragging
commitSelection(s => s); // <-- but also create an undo-checkpoint!
setDragging(true);
return;
}
}
else {
// mouse is not on any shape
startMakingSelection();
}
// otherwise, just start making a selection
setDragging(false);
setSelectingState({
topLeft: currentPointer,
size: {x: 0, y: 0},
});
if (!shiftOrCtrlPressed) {
setSelection(() => []);
}
}, [getCurrentPointer, makeCheckPoint, insertMode, selection, shiftOrCtrlPressed]);
else {
// any other mouse button (e.g., middle mouse button)
// -> just start making a selection
startMakingSelection();
}
}, [commitState, commitSelection, getCurrentPointer, insertMode, selection, shiftOrCtrlPressed]);
const onMouseMove = useCallback((e: {pageX: number, pageY: number, movementX: number, movementY: number}) => {
const currentPointer = getCurrentPointer(e);
if (dragging) {
// const pointerDelta = subtractV2D(currentPointer, dragging.lastMousePos);
// we're moving / resizing
const pointerDelta = {x: e.movementX/zoom, y: e.movementY/zoom};
const getParts = (uid: string) => {
return state.selection.filter(s => s.uid === uid).map(s => s.part);
return selection.filter(s => s.uid === uid).map(s => s.part);
}
setState(state => ({
replaceState(state => ({
...state,
rountangles: state.rountangles.map(r => {
const selectedParts = getParts(r.uid);
@ -216,6 +244,7 @@ export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoo
setDragging(true);
}
else if (selectingState) {
// we're making a selection
setSelectingState(ss => {
const selectionSize = subtractV2D(currentPointer, ss!.topLeft);
return {
@ -224,13 +253,15 @@ export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoo
};
});
}
}, [getCurrentPointer, selectingState, dragging]);
}, [replaceState, getCurrentPointer, selectingState, setSelectingState, selection, dragging]);
const onMouseUp = useCallback((e: {target: any, pageX: number, pageY: number}) => {
if (dragging) {
// we were moving / resizing
setDragging(false);
// do not persist sizes smaller than 40x40
setState(state => {
replaceState(state => {
return {
...state,
rountangles: state.rountangles.map(r => ({
@ -245,12 +276,16 @@ export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoo
});
}
if (selectingState) {
// we were making a selection
if (selectingState.size.x === 0 && selectingState.size.y === 0) {
// it was only a click (mouse didn't move)
// -> select the clicked part(s)
// (btw, this is only here to allow selecting rountangles by clicking inside them, all other shapes can be selected entirely by their 'helpers')
const uid = e.target?.dataset.uid;
if (uid) {
const parts = e.target?.dataset.parts.split(' ').filter((p: string) => p!=="") || [];
if (uid) {
setSelection(oldSelection => [
replaceSelection(oldSelection => [
...oldSelection,
...parts.map((part: string) => ({uid, part})),
]);
@ -258,7 +293,7 @@ export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoo
}
}
else {
// we were making a selection
// complete selection
const normalizedSS = normalizeRect(selectingState);
const shapes = Array.from(refSVG.current?.querySelectorAll("rect, line, circle, text") || []) as SVGGraphicsElement[];
const shapesInSelection = shapes.filter(el => {
@ -271,9 +306,8 @@ export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoo
}).filter(el => !el.classList.contains("corner"));
// @ts-ignore
setSelection(oldSelection => {
replaceSelection(oldSelection => {
const newSelection = [...oldSelection];
const common = [];
for (const shape of shapesInSelection) {
const uid = shape.dataset.uid;
if (uid) {
@ -281,8 +315,6 @@ export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoo
for (const part of parts) {
if (newSelection.some(({uid: oldUid, part: oldPart}) =>
uid === oldUid && part === oldPart)) {
// common.push({uid, part});
}
else {
// @ts-ignore
@ -291,14 +323,12 @@ export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoo
}
}
}
// console.log({newSelection, oldSelection, common});
// return [...oldSelection, ...newSelection];
return newSelection;
})
}
}
setSelectingState(null); // no longer making a selection
}, [dragging, selectingState, refSVG.current]);
}, [replaceState, replaceSelection, dragging, selectingState, setSelectingState, refSVG.current]);
const trackShiftKey = useCallback((e: KeyboardEvent) => {
setShiftOrCtrlPressed(e.shiftKey || e.ctrlKey);
@ -306,7 +336,7 @@ export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoo
const onSelectAll = useCallback(() => {
setDragging(false);
setState(state => ({
commitState(state => ({
...state,
// @ts-ignore
selection: [
@ -317,15 +347,14 @@ export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoo
...state.history.map(h => ({uid: h.uid, part: "history"})),
],
}));
}, [setState, setDragging]);
}, [commitState, setDragging]);
const convertSelection = useCallback((kind: "or"|"and") => {
makeCheckPoint();
setState(state => ({
commitState(state => ({
...state,
rountangles: state.rountangles.map(r => state.selection.some(rs => rs.uid === r.uid) ? ({...r, kind}) : r),
}));
}, [makeCheckPoint, setState]);
}, [commitState]);
useShortcuts([
{keys: ["o"], action: useCallback(() => convertSelection("or"), [convertSelection])},

View file

@ -2,6 +2,8 @@ import { addV2D, rotateLine90CCW, rotateLine90CW, rotatePoint90CCW, rotatePoint9
import { HISTORY_RADIUS } from "../parameters";
import { Dispatch, SetStateAction, useCallback, useEffect } from "react";
import { EditHistory } from "../App";
import { jsonDeepEqual } from "@/util/util";
import { VisualEditorState } from "../VisualEditor/VisualEditor";
export function useEditor(setEditHistory: Dispatch<SetStateAction<EditHistory|null>>) {
useEffect(() => {
@ -11,13 +13,27 @@ export function useEditor(setEditHistory: Dispatch<SetStateAction<EditHistory|nu
}
}, []);
// append editor state to undo history
const makeCheckPoint = useCallback(() => {
setEditHistory(historyState => historyState && ({
...historyState,
const commitState = useCallback((callback: (oldState: VisualEditorState) => VisualEditorState) => {
setEditHistory(historyState => {
if (historyState === null) return null; // no change
const newEditorState = callback(historyState.current);
return {
current: newEditorState,
history: [...historyState.history, historyState.current],
future: [],
}));
}
// }
});
}, [setEditHistory]);
const replaceState = useCallback((callback: (oldState: VisualEditorState) => VisualEditorState) => {
setEditHistory(historyState => {
if (historyState === null) return null; // no change
const newEditorState = callback(historyState.current);
return {
...historyState,
current: newEditorState,
};
});
}, [setEditHistory]);
const onUndo = useCallback(() => {
setEditHistory(historyState => {
@ -46,62 +62,54 @@ export function useEditor(setEditHistory: Dispatch<SetStateAction<EditHistory|nu
});
}, [setEditHistory]);
const onRotate = useCallback((direction: "ccw" | "cw") => {
makeCheckPoint();
setEditHistory(historyState => {
if (historyState === null) return null;
const selection = historyState.current.selection;
commitState(editorState => {
const selection = editorState.selection;
if (selection.length === 0) {
return historyState;
return editorState;
}
// 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) {
for (const rt of editorState.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) {
for (const d of editorState.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) {
for (const arr of editorState.arrows) {
if (selection.some(s => s.uid === arr.uid)) {
addPointToBBox(arr.start);
addPointToBBox(arr.end);
}
}
for (const txt of historyState.current.texts) {
for (const txt of editorState.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) {
for (const h of editorState.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);
@ -110,12 +118,9 @@ export function useEditor(setEditHistory: Dispatch<SetStateAction<EditHistory|nu
return shape;
}
}
return {
...historyState,
current: {
...historyState.current,
rountangles: historyState.current.rountangles.map(rt => mapIfSelected(rt, rt => {
...editorState,
rountangles: editorState.rountangles.map(rt => mapIfSelected(rt, rt => {
return {
...rt,
...(direction === "ccw"
@ -123,7 +128,7 @@ export function useEditor(setEditHistory: Dispatch<SetStateAction<EditHistory|nu
: rotateRect90CW(rt, center)),
}
})),
arrows: historyState.current.arrows.map(arr => mapIfSelected(arr, arr => {
arrows: editorState.arrows.map(arr => mapIfSelected(arr, arr => {
return {
...arr,
...(direction === "ccw"
@ -131,7 +136,7 @@ export function useEditor(setEditHistory: Dispatch<SetStateAction<EditHistory|nu
: rotateLine90CW(arr, center)),
};
})),
diamonds: historyState.current.diamonds.map(d => mapIfSelected(d, d => {
diamonds: editorState.diamonds.map(d => mapIfSelected(d, d => {
return {
...d,
...(direction === "ccw"
@ -139,7 +144,7 @@ export function useEditor(setEditHistory: Dispatch<SetStateAction<EditHistory|nu
: rotateRect90CW(d, center)),
};
})),
texts: historyState.current.texts.map(txt => mapIfSelected(txt, txt => {
texts: editorState.texts.map(txt => mapIfSelected(txt, txt => {
return {
...txt,
topLeft: (direction === "ccw"
@ -147,7 +152,7 @@ export function useEditor(setEditHistory: Dispatch<SetStateAction<EditHistory|nu
: rotatePoint90CW(txt.topLeft, center)),
};
})),
history: historyState.current.history.map(h => mapIfSelected(h, h => {
history: editorState.history.map(h => mapIfSelected(h, h => {
return {
...h,
topLeft: (direction === "ccw"
@ -156,10 +161,8 @@ export function useEditor(setEditHistory: Dispatch<SetStateAction<EditHistory|nu
),
};
})),
},
}
})
};
});
}, [setEditHistory]);
return {makeCheckPoint, onUndo, onRedo, onRotate};
return {commitState, replaceState, onUndo, onRedo, onRotate};
}

View file

@ -11,9 +11,9 @@ import { App } from "./App/App";
const elem = document.getElementById("root")!;
const app = (
<StrictMode>
// <StrictMode>
<App />
</StrictMode>
// </StrictMode>
);
if (import.meta.hot) {

View file

@ -28,6 +28,14 @@ export type History = {
topLeft: Vec2D;
};
export type ConcreteSyntax = {
rountangles: Rountangle[];
texts: Text[];
arrows: Arrow[];
diamonds: Diamond[];
history: History[];
};
// independently moveable parts of our shapes:
export type RectSide = "left" | "top" | "right" | "bottom";
export type ArrowPart = "start" | "end";

View file

@ -1,4 +1,5 @@
import { ConcreteSyntax, VisualEditorState } from "@/App/VisualEditor/VisualEditor";
import { VisualEditorState } from "@/App/VisualEditor/VisualEditor";
import { ConcreteSyntax } from "./concrete_syntax";
import { findNearestArrow, findNearestHistory, findNearestSide, findRountangle, RectSide } from "./concrete_syntax";
export type Connections = {

View file

@ -5,7 +5,7 @@ import { Action, EventTrigger, Expression, ParsedText } from "./label_ast";
import { parse as parseLabel, SyntaxError } from "./label_parser";
import { Connections } from "./detect_connections";
import { HISTORY_RADIUS } from "../App/parameters";
import { ConcreteSyntax } from "@/App/VisualEditor/VisualEditor";
import { ConcreteSyntax } from "./concrete_syntax";
import { memoize } from "@/util/util";
export type TraceableError = {

View file

@ -24,6 +24,24 @@ export function memoize<InType,OutType>(fn: (i: InType) => OutType) {
}
}
// author: ChatGPT
export function jsonDeepEqual(a: any, b: any) {
if (a === b) return true;
if (a && b && typeof a === "object" && typeof b === "object") {
if (Array.isArray(a) !== Array.isArray(b)) return false;
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
if (!jsonDeepEqual(a[key], b[key])) return false;
}
return true;
}
return false;
}
// 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)