diff --git a/src/App/App.tsx b/src/App/App.tsx index 34595a2..9855a60 100644 --- a/src/App/App.tsx +++ b/src/App/App.tsx @@ -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(null); const [modal, setModal] = useState(null); - // const [lightMode, setLightMode] = usePersistentState("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 */}
{editorState && conns && syntaxErrors && - } + }
{appState.showFindReplace && diff --git a/src/App/Logo/Logo.tsx b/src/App/Logo/Logo.tsx index 833335d..1aa429f 100644 --- a/src/App/Logo/Logo.tsx +++ b/src/App/Logo/Logo.tsx @@ -6,18 +6,18 @@ export function Logo() { fill: var(--text-color); } `} - - - + + + - + - - - + + + @@ -94,14 +94,14 @@ export function Logo() { - - - - - - - - + + + + + + + + @@ -168,14 +168,14 @@ export function Logo() { - - - - - - - - + + + + + + + + @@ -238,23 +238,23 @@ export function Logo() { - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + @@ -352,12 +352,12 @@ export function Logo() { - - - - - - + + + + + + @@ -410,14 +410,14 @@ export function Logo() { - - - - - - - - + + + + + + + + @@ -584,20 +584,20 @@ export function Logo() { - - - - - - - - - - - - - - + + + + + + + + + + + + + + @@ -734,19 +734,19 @@ export function Logo() { - - - - - - - - - - - - - + + + + + + + + + + + + + @@ -768,17 +768,17 @@ export function Logo() { - - - - - - - - - - - + + + + + + + + + + + @@ -797,19 +797,19 @@ export function Logo() { - - - - - - - - - - - - - + + + + + + + + + + + + + @@ -830,18 +830,18 @@ export function Logo() { - - - - - - - - - - - - + + + + + + + + + + + + @@ -862,18 +862,18 @@ export function Logo() { - - - - - - - - - - - - + + + + + + + + + + + + @@ -894,16 +894,16 @@ export function Logo() { - - - - - - - - - - + + + + + + + + + + @@ -985,31 +985,31 @@ export function Logo() { - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1072,26 +1072,26 @@ export function Logo() { - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + @@ -1113,22 +1113,22 @@ export function Logo() { - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + @@ -1137,12 +1137,12 @@ export function Logo() { - - - - - - + + + + + + @@ -1157,14 +1157,14 @@ export function Logo() { - - - - - - - - + + + + + + + + @@ -1208,15 +1208,15 @@ export function Logo() { - - - - - - - - - + + + + + + + + + @@ -1260,17 +1260,17 @@ export function Logo() { - - - - - - - - - - - + + + + + + + + + + + @@ -1326,41 +1326,41 @@ export function Logo() { - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1379,200 +1379,200 @@ export function Logo() { - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - - - - - - - + + + + + + + - + - - - + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + ; } diff --git a/src/App/Plant/DigitalWatch/DigitalWatch.tsx b/src/App/Plant/DigitalWatch/DigitalWatch.tsx index 3cb5f55..95f0750 100644 --- a/src/App/Plant/DigitalWatch/DigitalWatch.tsx +++ b/src/App/Plant/DigitalWatch/DigitalWatch.tsx @@ -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"; diff --git a/src/App/Plant/Microwave/Microwave.tsx b/src/App/Plant/Microwave/Microwave.tsx index 078aeb2..194a0bc 100644 --- a/src/App/Plant/Microwave/Microwave.tsx +++ b/src/App/Plant/Microwave/Microwave.tsx @@ -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)); diff --git a/src/App/Plant/TrafficLight/TrafficLight.tsx b/src/App/Plant/TrafficLight/TrafficLight.tsx index 5c5c833..a361726 100644 --- a/src/App/Plant/TrafficLight/TrafficLight.tsx +++ b/src/App/Plant/TrafficLight/TrafficLight.tsx @@ -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"; diff --git a/src/App/SideBar/SideBar.tsx b/src/App/SideBar/SideBar.tsx index 1d24181..488bd38 100644 --- a/src/App/SideBar/SideBar.tsx +++ b/src/App/SideBar/SideBar.tsx @@ -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}/>} + />} {/* Internal events */} diff --git a/src/App/VisualEditor/ArrowSVG.tsx b/src/App/VisualEditor/ArrowSVG.tsx index 9b12a3a..d09128d 100644 --- a/src/App/VisualEditor/ArrowSVG.tsx +++ b/src/App/VisualEditor/ArrowSVG.tsx @@ -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 ; }, (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 diff --git a/src/App/VisualEditor/DiamondSVG.tsx b/src/App/VisualEditor/DiamondSVG.tsx index f051319..fe8ef8e 100644 --- a/src/App/VisualEditor/DiamondSVG.tsx +++ b/src/App/VisualEditor/DiamondSVG.tsx @@ -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 ; }, (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 diff --git a/src/App/VisualEditor/RountangleSVG.tsx b/src/App/VisualEditor/RountangleSVG.tsx index 744abfd..07bca96 100644 --- a/src/App/VisualEditor/RountangleSVG.tsx +++ b/src/App/VisualEditor/RountangleSVG.tsx @@ -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} /> ; }, (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 diff --git a/src/App/VisualEditor/TextSVG.tsx b/src/App/VisualEditor/TextSVG.tsx index 8a80aba..9c6bfdf 100644 --- a/src/App/VisualEditor/TextSVG.tsx +++ b/src/App/VisualEditor/TextSVG.tsx @@ -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>}) { const commonProps = { @@ -44,4 +45,11 @@ export const TextSVG = memo(function TextSVG(props: {text: Text, error: Traceabl {textNode} {props.text.text} ; +}, (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 }); diff --git a/src/App/VisualEditor/VisualEditor.tsx b/src/App/VisualEditor/VisualEditor.tsx index 850108e..0a0b2d6 100644 --- a/src/App/VisualEditor/VisualEditor.tsx +++ b/src/App/VisualEditor/VisualEditor.tsx @@ -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, insertMode: InsertMode, highlightActive: Set, highlightTransitions: string[], setModal: Dispatch>, - 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(null); + + // const state = temporaryState || committedState; // uid's of selected rountangles - const selection = state.selection || []; + const selection = state.selection; const refSVG = useRef(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 e.preventDefault()} ref={refSVG} diff --git a/src/App/VisualEditor/hooks/useCopyPaste.ts b/src/App/VisualEditor/hooks/useCopyPaste.ts index 1cc01e8..7ac9a2d 100644 --- a/src/App/VisualEditor/hooks/useCopyPaste.ts +++ b/src/App/VisualEditor/hooks/useCopyPaste.ts @@ -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}, diff --git a/src/App/VisualEditor/hooks/useMouse.tsx b/src/App/VisualEditor/hooks/useMouse.tsx index 733910f..a89b08b 100644 --- a/src/App/VisualEditor/hooks/useMouse.tsx +++ b/src/App/VisualEditor/hooks/useMouse.tsx @@ -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(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; } - 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. - const uid = e.target?.dataset.uid; - const parts: string[] = e.target?.dataset.parts?.split(' ').filter((p:string) => p!=="") || []; - if (uid && parts.length > 0) { - makeCheckPoint(); + let appendTo: Selection; + if (shiftOrCtrlPressed) { + appendTo = selection; + } + else { + appendTo = []; + } - // 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) { - // 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; - } + const startMakingSelection = () => { + setDragging(false); + setSelectingState({ + topLeft: currentPointer, + size: {x: 0, y: 0}, + }); + commitSelection(_ => appendTo); + } + + if (e.button === 0) { + // 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) { + // mouse hovers over a shape or part of a shape + let allPartsInSelection = true; + for (const part of parts) { + if (!(selection.some(s => (s.uid === uid) && (s.part === part)))) { + allPartsInSelection = false; + break; } - if (!allPartsInSelection) { - if (e.target.classList.contains("helper")) { - setSelection(() => parts.map(part => ({uid, part})) as Selection); - } - else { - setDragging(false); - setSelectingState({ - topLeft: currentPointer, - size: {x: 0, y: 0}, - }); - setSelection(() => []); - return; - } + } + if (!allPartsInSelection) { + // the part is not in existing selection + if (e.target.classList.contains("helper")) { + // 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); } - // start dragging + else { + // 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(); + } + } + 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(() => []); + else { + // any other mouse button (e.g., middle mouse button) + // -> just start making a selection + startMakingSelection(); } - }, [getCurrentPointer, makeCheckPoint, insertMode, selection, shiftOrCtrlPressed]); + }, [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])}, diff --git a/src/App/hooks/useEditor.ts b/src/App/hooks/useEditor.ts index 058a5d5..6028ef3 100644 --- a/src/App/hooks/useEditor.ts +++ b/src/App/hooks/useEditor.ts @@ -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>) { useEffect(() => { @@ -11,13 +13,27 @@ export function useEditor(setEditHistory: Dispatch { - setEditHistory(historyState => historyState && ({ - ...historyState, - history: [...historyState.history, historyState.current], - future: [], - })); + 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 { - 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,56 +118,51 @@ export function useEditor(setEditHistory: Dispatch mapIfSelected(rt, rt => { - return { - ...rt, - ...(direction === "ccw" - ? rotateRect90CCW(rt, center) - : rotateRect90CW(rt, center)), - } - })), - arrows: historyState.current.arrows.map(arr => mapIfSelected(arr, arr => { - return { - ...arr, - ...(direction === "ccw" - ? rotateLine90CCW(arr, center) - : rotateLine90CW(arr, center)), - }; - })), - diamonds: historyState.current.diamonds.map(d => mapIfSelected(d, d => { - return { - ...d, - ...(direction === "ccw" - ? rotateRect90CCW(d, center) - : rotateRect90CW(d, center)), - }; - })), - texts: historyState.current.texts.map(txt => mapIfSelected(txt, txt => { - return { - ...txt, - topLeft: (direction === "ccw" - ? rotatePoint90CCW(txt.topLeft, center) - : rotatePoint90CW(txt.topLeft, center)), - }; - })), - history: historyState.current.history.map(h => mapIfSelected(h, h => { - return { - ...h, - topLeft: (direction === "ccw" - ? subtractV2D(rotatePoint90CCW(addV2D(h.topLeft, historySize), center), historySize) - : subtractV2D(rotatePoint90CW(addV2D(h.topLeft, historySize), center), historySize) - ), - }; - })), - }, - } - }) + ...editorState, + rountangles: editorState.rountangles.map(rt => mapIfSelected(rt, rt => { + return { + ...rt, + ...(direction === "ccw" + ? rotateRect90CCW(rt, center) + : rotateRect90CW(rt, center)), + } + })), + arrows: editorState.arrows.map(arr => mapIfSelected(arr, arr => { + return { + ...arr, + ...(direction === "ccw" + ? rotateLine90CCW(arr, center) + : rotateLine90CW(arr, center)), + }; + })), + diamonds: editorState.diamonds.map(d => mapIfSelected(d, d => { + return { + ...d, + ...(direction === "ccw" + ? rotateRect90CCW(d, center) + : rotateRect90CW(d, center)), + }; + })), + texts: editorState.texts.map(txt => mapIfSelected(txt, txt => { + return { + ...txt, + topLeft: (direction === "ccw" + ? rotatePoint90CCW(txt.topLeft, center) + : rotatePoint90CW(txt.topLeft, center)), + }; + })), + history: editorState.history.map(h => mapIfSelected(h, h => { + return { + ...h, + topLeft: (direction === "ccw" + ? subtractV2D(rotatePoint90CCW(addV2D(h.topLeft, historySize), center), historySize) + : subtractV2D(rotatePoint90CW(addV2D(h.topLeft, historySize), center), historySize) + ), + }; + })), + }; + }); }, [setEditHistory]); - - return {makeCheckPoint, onUndo, onRedo, onRotate}; + return {commitState, replaceState, onUndo, onRedo, onRotate}; } \ No newline at end of file diff --git a/src/frontend.tsx b/src/frontend.tsx index e95c329..1e00a46 100644 --- a/src/frontend.tsx +++ b/src/frontend.tsx @@ -11,9 +11,9 @@ import { App } from "./App/App"; const elem = document.getElementById("root")!; const app = ( - + // - + // ); if (import.meta.hot) { diff --git a/src/statecharts/concrete_syntax.ts b/src/statecharts/concrete_syntax.ts index 85aae51..c74b9b9 100644 --- a/src/statecharts/concrete_syntax.ts +++ b/src/statecharts/concrete_syntax.ts @@ -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"; diff --git a/src/statecharts/detect_connections.ts b/src/statecharts/detect_connections.ts index eefc02e..d5fc0f9 100644 --- a/src/statecharts/detect_connections.ts +++ b/src/statecharts/detect_connections.ts @@ -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 = { diff --git a/src/statecharts/parser.ts b/src/statecharts/parser.ts index 43b9bb7..fbef792 100644 --- a/src/statecharts/parser.ts +++ b/src/statecharts/parser.ts @@ -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 = { diff --git a/src/util/util.ts b/src/util/util.ts index c105ae6..4213a8b 100644 --- a/src/util/util.ts +++ b/src/util/util.ts @@ -24,6 +24,24 @@ export function memoize(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(a: T[], b: T[], cmp: (a: T, b: T) => boolean = (a,b)=>a===b): boolean { if (a === b)