cleanup the code a bit...
This commit is contained in:
parent
1bd801ce5d
commit
7994cd6eb0
19 changed files with 685 additions and 625 deletions
|
|
@ -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
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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])},
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue