undo/redo working

This commit is contained in:
Joeri Exelmans 2025-10-05 12:06:22 +02:00
parent 924019e81c
commit 6e75866d4e
2 changed files with 201 additions and 96 deletions

View file

@ -1,5 +1,5 @@
import { MouseEventHandler, useEffect, useRef, useState } from "react"; import { Dispatch, MouseEventHandler, SetStateAction, useEffect, useRef, useState } from "react";
import { Line2D, Rect2D, Vec2D, addV2D, area, isEntirelyWithin, normalizeRect, scaleV2D, subtractV2D, transformLine, transformRect } from "./geometry"; import { Line2D, Rect2D, Vec2D, addV2D, area, isEntirelyWithin, normalizeRect, subtractV2D, transformLine, transformRect } from "./geometry";
import "./VisualEditor.css"; import "./VisualEditor.css";
import { getBBoxInSvgCoords } from "./svg_helper"; import { getBBoxInSvgCoords } from "./svg_helper";
@ -65,7 +65,7 @@ type ArrowSelectable = {
uid: string; uid: string;
} }
type TextSelectable = { type TextSelectable = {
parts: "text"; parts: ["text"];
uid: string; uid: string;
} }
type Selectable = RountangleSelectable | ArrowSelectable | TextSelectable; type Selectable = RountangleSelectable | ArrowSelectable | TextSelectable;
@ -73,8 +73,66 @@ type Selection = Selectable[];
const minStateSize = {x: 40, y: 40}; const minStateSize = {x: 40, y: 40};
type HistoryState = {
current: VisualEditorState,
history: VisualEditorState[],
future: VisualEditorState[],
}
export function VisualEditor() { export function VisualEditor() {
const [state, setState] = useState<VisualEditorState>(onOffStateMachine); const [historyState, setHistoryState] = useState<HistoryState>({current: emptyState, history: [], future: []});
const state = historyState.current;
const setState = (s: SetStateAction<VisualEditorState>) => {
setHistoryState(historyState => {
let newState;
if (typeof s === 'function') {
newState = s(historyState.current);
}
else {
newState = s;
}
return {
...historyState,
current: newState,
};
});
}
function checkPoint() {
setHistoryState(historyState => ({
...historyState,
history: [...historyState.history, historyState.current],
future: [],
}));
}
function undo() {
setHistoryState(historyState => {
if (historyState.history.length === 0) {
return historyState; // no change
}
return {
current: historyState.history.at(-1)!,
history: historyState.history.slice(0,-1),
future: [...historyState.future, historyState.current],
}
})
}
function redo() {
setHistoryState(historyState => {
if (historyState.future.length === 0) {
return historyState; // no change
}
return {
current: historyState.future.at(-1)!,
history: [...historyState.history, historyState.current],
future: historyState.future.slice(0,-1),
}
});
}
const [dragging, setDragging] = useState<DraggingState>(null); const [dragging, setDragging] = useState<DraggingState>(null);
const [mode, setMode] = useState<"state"|"transition"|"text">("state"); const [mode, setMode] = useState<"state"|"transition"|"text">("state");
@ -94,6 +152,10 @@ export function VisualEditor() {
setState(recoveredState); setState(recoveredState);
}, []); }, []);
// useEffect(() => {
// console.log(`history: ${history.length}, future: ${future.length}`);
// }, [editorState]);
useEffect(() => { useEffect(() => {
// delay is necessary for 2 reasons: // delay is necessary for 2 reasons:
// 1) it's a hack - prevents us from writing the initial state to localstorage (before having recovered the state that was in localstorage) // 1) it's a hack - prevents us from writing the initial state to localstorage (before having recovered the state that was in localstorage)
@ -110,6 +172,7 @@ export function VisualEditor() {
const currentPointer = {x: e.pageX, y: e.pageY}; const currentPointer = {x: e.pageX, y: e.pageY};
if (e.button === 1) { if (e.button === 1) {
checkPoint();
// ignore selection, middle mouse button always inserts // ignore selection, middle mouse button always inserts
setState(state => { setState(state => {
const newID = state.nextID.toString(); const newID = state.nextID.toString();
@ -163,6 +226,8 @@ export function VisualEditor() {
const uid = e.target?.dataset.uid; const uid = e.target?.dataset.uid;
const parts: string[] = e.target?.dataset.parts?.split(' ') || []; const parts: string[] = e.target?.dataset.parts?.split(' ') || [];
if (uid) { if (uid) {
checkPoint();
let allPartsInSelection = true; let allPartsInSelection = true;
for (const part of parts) { for (const part of parts) {
if (!(selection.find(s => s.uid === uid)?.parts || []).includes(part)) { if (!(selection.find(s => s.uid === uid)?.parts || []).includes(part)) {
@ -192,9 +257,7 @@ export function VisualEditor() {
const onMouseMove = (e: MouseEvent) => { const onMouseMove = (e: MouseEvent) => {
const currentPointer = {x: e.pageX, y: e.pageY}; const currentPointer = {x: e.pageX, y: e.pageY};
if (dragging) { if (dragging) {
setDragging(prevDragState => {
const pointerDelta = subtractV2D(currentPointer, dragging.lastMousePos); const pointerDelta = subtractV2D(currentPointer, dragging.lastMousePos);
const halfPointerDelta = scaleV2D(pointerDelta, 0.5);
setState(state => ({ setState(state => ({
...state, ...state,
rountangles: state.rountangles.map(r => { rountangles: state.rountangles.map(r => {
@ -205,7 +268,7 @@ export function VisualEditor() {
return { return {
uid: r.uid, uid: r.uid,
kind: r.kind, kind: r.kind,
...transformRect(r, parts, halfPointerDelta), ...transformRect(r, parts, pointerDelta),
}; };
}) })
.toSorted((a,b) => area(b) - area(a)), // sort: smaller rountangles are drawn on top .toSorted((a,b) => area(b) - area(a)), // sort: smaller rountangles are drawn on top
@ -216,7 +279,7 @@ export function VisualEditor() {
} }
return { return {
uid: a.uid, uid: a.uid,
...transformLine(a, parts, halfPointerDelta), ...transformLine(a, parts, pointerDelta),
} }
}), }),
texts: state.texts.map(t => { texts: state.texts.map(t => {
@ -227,12 +290,11 @@ export function VisualEditor() {
return { return {
uid: t.uid, uid: t.uid,
text: t.text, text: t.text,
topLeft: addV2D(t.topLeft, halfPointerDelta), topLeft: addV2D(t.topLeft, pointerDelta),
} }
}) }),
})); }));
return {lastMousePos: currentPointer}; setDragging({lastMousePos: currentPointer});
});
} }
else if (selectingState) { else if (selectingState) {
setSelectingState(ss => { setSelectingState(ss => {
@ -246,19 +308,27 @@ export function VisualEditor() {
}; };
const onMouseUp = (e: MouseEvent) => { const onMouseUp = (e: MouseEvent) => {
if (dragging) {
setDragging(null); setDragging(null);
setSelectingState(ss => { // do not persist sizes smaller than 40x40
if (ss) { setState(state => {
return {
...state,
rountangles: state.rountangles.map(r => ({
...r,
size: rountangleMinSize(r.size),
})),
};
});
}
if (selectingState) {
// we were making a selection // we were making a selection
const normalizedSS = normalizeRect(ss); const normalizedSS = normalizeRect(selectingState);
const shapes = Array.from(refSVG.current?.querySelectorAll("rect, line, circle, text") || []) as SVGGraphicsElement[]; const shapes = Array.from(refSVG.current?.querySelectorAll("rect, line, circle, text") || []) as SVGGraphicsElement[];
const shapesInSelection = shapes.filter(el => { const shapesInSelection = shapes.filter(el => {
const bbox = getBBoxInSvgCoords(el, refSVG.current!); const bbox = getBBoxInSvgCoords(el, refSVG.current!);
return isEntirelyWithin(bbox, normalizedSS); return isEntirelyWithin(bbox, normalizedSS);
}).filter(el => !el.classList.contains("corner")); }).filter(el => !el.classList.contains("corner"));
const uidToParts = new Map(); const uidToParts = new Map();
for (const shape of shapesInSelection) { for (const shape of shapesInSelection) {
const uid = shape.dataset.uid; const uid = shape.dataset.uid;
@ -270,29 +340,28 @@ export function VisualEditor() {
uidToParts.set(uid, parts); uidToParts.set(uid, parts);
} }
} }
setSelection(() => [...uidToParts.entries()].map(([uid,parts]) => ({ setSelection(() => [...uidToParts.entries()].map(([uid,parts]) => ({
kind: "rountangle", kind: "rountangle",
uid, uid,
parts: [...parts], parts: [...parts],
}))); })));
setSelectingState(null); // no longer making a selection
} }
return null; // no longer selecting
});
}; };
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Delete") { if (e.key === "Delete") {
// delete selection // delete selection
setSelection(selection => { if (selection.length > 0) {
checkPoint();
setState(state => ({ setState(state => ({
...state, ...state,
rountangles: state.rountangles.filter(r => !selection.some(rs => rs.uid === r.uid)), rountangles: state.rountangles.filter(r => !selection.some(rs => rs.uid === r.uid)),
arrows: state.arrows.filter(a => !selection.some(as => as.uid === a.uid)), arrows: state.arrows.filter(a => !selection.some(as => as.uid === a.uid)),
texts: state.texts.filter(t => !selection.some(ts => ts.uid === t.uid)), texts: state.texts.filter(t => !selection.some(ts => ts.uid === t.uid)),
})); }));
return []; setSelection([]);
}); }
} }
if (e.key === "o") { if (e.key === "o") {
// selected states become OR-states // selected states become OR-states
@ -326,6 +395,29 @@ export function VisualEditor() {
if (e.key === "x") { if (e.key === "x") {
setMode("text"); setMode("text");
} }
if (e.key === "z") {
e.preventDefault();
undo();
}
if (e.key === "Z") {
e.preventDefault();
redo();
}
if (e.ctrlKey) {
if (e.key === "a") {
e.preventDefault();
setDragging(null);
// @ts-ignore
setSelection([
...state.rountangles.map(r => ({uid: r.uid, parts: ["left", "top", "right", "bottom"]})),
...state.arrows.map(a => ({uid: a.uid, parts: ["start", "end"]})),
...state.texts.map(t => ({uid: t.uid, parts: ["text"]})),
]);
}
}
}; };
useEffect(() => { useEffect(() => {
@ -435,8 +527,21 @@ export function VisualEditor() {
const cornerOffset = 4; const cornerOffset = 4;
const cornerRadius = 16; const cornerRadius = 16;
function rountangleMinSize(size: Vec2D): Vec2D {
if (size.x >= 40 && size.y >= 40) {
return size;
}
return {
x: Math.max(40, size.x),
y: Math.max(40, size.y),
};
}
export function RountangleSVG(props: {rountangle: Rountangle, selected: string[]}) { export function RountangleSVG(props: {rountangle: Rountangle, selected: string[]}) {
const {topLeft, size, uid} = props.rountangle; const {topLeft, size, uid} = props.rountangle;
// always draw a rountangle with a minimum size
// during resizing, rountangle can be smaller than this size and even have a negative size, but we don't show it
const minSize = rountangleMinSize(size);
return <g transform={`translate(${topLeft.x} ${topLeft.y})`}> return <g transform={`translate(${topLeft.x} ${topLeft.y})`}>
<rect <rect
className={"rountangle" className={"rountangle"
@ -445,8 +550,8 @@ export function RountangleSVG(props: {rountangle: Rountangle, selected: string[]
rx={20} ry={20} rx={20} ry={20}
x={0} x={0}
y={0} y={0}
width={size.x} width={minSize.x}
height={size.y} height={minSize.y}
data-uid={uid} data-uid={uid}
data-parts="left top right bottom" data-parts="left top right bottom"
/> />
@ -455,7 +560,7 @@ export function RountangleSVG(props: {rountangle: Rountangle, selected: string[]
+(props.selected.includes("top")?" selected":"")} +(props.selected.includes("top")?" selected":"")}
x1={0} x1={0}
y1={0} y1={0}
x2={size.x} x2={minSize.x}
y2={0} y2={0}
data-uid={uid} data-uid={uid}
data-parts="top" data-parts="top"
@ -463,10 +568,10 @@ export function RountangleSVG(props: {rountangle: Rountangle, selected: string[]
<line <line
className={"lineHelper" className={"lineHelper"
+(props.selected.includes("right")?" selected":"")} +(props.selected.includes("right")?" selected":"")}
x1={size.x} x1={minSize.x}
y1={0} y1={0}
x2={size.x} x2={minSize.x}
y2={size.y} y2={minSize.y}
data-uid={uid} data-uid={uid}
data-parts="right" data-parts="right"
/> />
@ -474,9 +579,9 @@ export function RountangleSVG(props: {rountangle: Rountangle, selected: string[]
className={"lineHelper" className={"lineHelper"
+(props.selected.includes("bottom")?" selected":"")} +(props.selected.includes("bottom")?" selected":"")}
x1={0} x1={0}
y1={size.y} y1={minSize.y}
x2={size.x} x2={minSize.x}
y2={size.y} y2={minSize.y}
data-uid={uid} data-uid={uid}
data-parts="bottom" data-parts="bottom"
/> />
@ -486,7 +591,7 @@ export function RountangleSVG(props: {rountangle: Rountangle, selected: string[]
x1={0} x1={0}
y1={0} y1={0}
x2={0} x2={0}
y2={size.y} y2={minSize.y}
data-uid={uid} data-uid={uid}
data-parts="left" data-parts="left"
/> />
@ -502,7 +607,7 @@ export function RountangleSVG(props: {rountangle: Rountangle, selected: string[]
<circle <circle
className="circleHelper corner" className="circleHelper corner"
cx={size.x-cornerOffset} cx={minSize.x-cornerOffset}
cy={cornerOffset} cy={cornerOffset}
r={cornerRadius} r={cornerRadius}
data-uid={uid} data-uid={uid}
@ -511,8 +616,8 @@ export function RountangleSVG(props: {rountangle: Rountangle, selected: string[]
<circle <circle
className="circleHelper corner" className="circleHelper corner"
cx={size.x-cornerOffset} cx={minSize.x-cornerOffset}
cy={size.y-cornerOffset} cy={minSize.y-cornerOffset}
r={cornerRadius} r={cornerRadius}
data-uid={uid} data-uid={uid}
data-parts="bottom right" data-parts="bottom right"
@ -521,7 +626,7 @@ export function RountangleSVG(props: {rountangle: Rountangle, selected: string[]
<circle <circle
className="circleHelper corner" className="circleHelper corner"
cx={cornerOffset} cx={cornerOffset}
cy={size.y-cornerOffset} cy={minSize.y-cornerOffset}
r={cornerRadius} r={cornerRadius}
data-uid={uid} data-uid={uid}
data-parts="bottom left" data-parts="bottom left"

View file

@ -75,12 +75,12 @@ export function transformRect(rect: Rect2D, parts: string[], delta: Vec2D): Rect
y: parts.includes("top") ? rect.topLeft.y + delta.y : rect.topLeft.y, y: parts.includes("top") ? rect.topLeft.y + delta.y : rect.topLeft.y,
}, },
size: { size: {
x: Math.max(40, rect.size.x x: /*Math.max(40,*/ rect.size.x
+ (parts.includes("right") ? delta.x : 0) + (parts.includes("right") ? delta.x : 0)
- (parts.includes("left") ? delta.x : 0)), - (parts.includes("left") ? delta.x : 0),
y: Math.max(40, rect.size.y y: /*Math.max(40,*/ rect.size.y
+ (parts.includes("bottom") ? delta.y : 0) + (parts.includes("bottom") ? delta.y : 0)
- (parts.includes("top") ? delta.y : 0)), - (parts.includes("top") ? delta.y : 0),
}, },
}; };
} }