undo/redo working
This commit is contained in:
parent
924019e81c
commit
6e75866d4e
2 changed files with 201 additions and 96 deletions
|
|
@ -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,47 +257,44 @@ 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);
|
setState(state => ({
|
||||||
const halfPointerDelta = scaleV2D(pointerDelta, 0.5);
|
...state,
|
||||||
setState(state => ({
|
rountangles: state.rountangles.map(r => {
|
||||||
...state,
|
const parts = selection.find(selected => selected.uid === r.uid)?.parts || [];
|
||||||
rountangles: state.rountangles.map(r => {
|
if (parts.length === 0) {
|
||||||
const parts = selection.find(selected => selected.uid === r.uid)?.parts || [];
|
return r;
|
||||||
if (parts.length === 0) {
|
}
|
||||||
return r;
|
return {
|
||||||
}
|
uid: r.uid,
|
||||||
return {
|
kind: r.kind,
|
||||||
uid: r.uid,
|
...transformRect(r, parts, pointerDelta),
|
||||||
kind: r.kind,
|
};
|
||||||
...transformRect(r, parts, halfPointerDelta),
|
})
|
||||||
};
|
.toSorted((a,b) => area(b) - area(a)), // sort: smaller rountangles are drawn on top
|
||||||
})
|
arrows: state.arrows.map(a => {
|
||||||
.toSorted((a,b) => area(b) - area(a)), // sort: smaller rountangles are drawn on top
|
const parts = selection.find(selected => selected.uid === a.uid)?.parts || [];
|
||||||
arrows: state.arrows.map(a => {
|
if (parts.length === 0) {
|
||||||
const parts = selection.find(selected => selected.uid === a.uid)?.parts || [];
|
return a;
|
||||||
if (parts.length === 0) {
|
}
|
||||||
return a;
|
return {
|
||||||
}
|
uid: a.uid,
|
||||||
return {
|
...transformLine(a, parts, pointerDelta),
|
||||||
uid: a.uid,
|
}
|
||||||
...transformLine(a, parts, halfPointerDelta),
|
}),
|
||||||
}
|
texts: state.texts.map(t => {
|
||||||
}),
|
const parts = selection.find(selected => selected.uid === t.uid)?.parts || [];
|
||||||
texts: state.texts.map(t => {
|
if (parts.length === 0) {
|
||||||
const parts = selection.find(selected => selected.uid === t.uid)?.parts || [];
|
return t;
|
||||||
if (parts.length === 0) {
|
}
|
||||||
return t;
|
return {
|
||||||
}
|
uid: t.uid,
|
||||||
return {
|
text: t.text,
|
||||||
uid: t.uid,
|
topLeft: addV2D(t.topLeft, pointerDelta),
|
||||||
text: t.text,
|
}
|
||||||
topLeft: addV2D(t.topLeft, halfPointerDelta),
|
}),
|
||||||
}
|
}));
|
||||||
})
|
setDragging({lastMousePos: currentPointer});
|
||||||
}));
|
|
||||||
return {lastMousePos: currentPointer};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
else if (selectingState) {
|
else if (selectingState) {
|
||||||
setSelectingState(ss => {
|
setSelectingState(ss => {
|
||||||
|
|
@ -246,53 +308,60 @@ export function VisualEditor() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const onMouseUp = (e: MouseEvent) => {
|
const onMouseUp = (e: MouseEvent) => {
|
||||||
setDragging(null);
|
if (dragging) {
|
||||||
setSelectingState(ss => {
|
setDragging(null);
|
||||||
if (ss) {
|
// do not persist sizes smaller than 40x40
|
||||||
// we were making a selection
|
setState(state => {
|
||||||
const normalizedSS = normalizeRect(ss);
|
return {
|
||||||
|
...state,
|
||||||
const shapes = Array.from(refSVG.current?.querySelectorAll("rect, line, circle, text") || []) as SVGGraphicsElement[];
|
rountangles: state.rountangles.map(r => ({
|
||||||
|
...r,
|
||||||
const shapesInSelection = shapes.filter(el => {
|
size: rountangleMinSize(r.size),
|
||||||
const bbox = getBBoxInSvgCoords(el, refSVG.current!);
|
})),
|
||||||
return isEntirelyWithin(bbox, normalizedSS);
|
};
|
||||||
}).filter(el => !el.classList.contains("corner"));
|
});
|
||||||
|
}
|
||||||
const uidToParts = new Map();
|
if (selectingState) {
|
||||||
for (const shape of shapesInSelection) {
|
// we were making a selection
|
||||||
const uid = shape.dataset.uid;
|
const normalizedSS = normalizeRect(selectingState);
|
||||||
if (uid) {
|
const shapes = Array.from(refSVG.current?.querySelectorAll("rect, line, circle, text") || []) as SVGGraphicsElement[];
|
||||||
const parts: Set<string> = uidToParts.get(uid) || new Set();
|
const shapesInSelection = shapes.filter(el => {
|
||||||
for (const part of shape.dataset.parts?.split(' ') || []) {
|
const bbox = getBBoxInSvgCoords(el, refSVG.current!);
|
||||||
parts.add(part);
|
return isEntirelyWithin(bbox, normalizedSS);
|
||||||
}
|
}).filter(el => !el.classList.contains("corner"));
|
||||||
uidToParts.set(uid, parts);
|
const uidToParts = new Map();
|
||||||
|
for (const shape of shapesInSelection) {
|
||||||
|
const uid = shape.dataset.uid;
|
||||||
|
if (uid) {
|
||||||
|
const parts: Set<string> = uidToParts.get(uid) || new Set();
|
||||||
|
for (const part of shape.dataset.parts?.split(' ') || []) {
|
||||||
|
parts.add(part);
|
||||||
}
|
}
|
||||||
|
uidToParts.set(uid, parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelection(() => [...uidToParts.entries()].map(([uid,parts]) => ({
|
|
||||||
kind: "rountangle",
|
|
||||||
uid,
|
|
||||||
parts: [...parts],
|
|
||||||
})));
|
|
||||||
}
|
}
|
||||||
return null; // no longer selecting
|
setSelection(() => [...uidToParts.entries()].map(([uid,parts]) => ({
|
||||||
});
|
kind: "rountangle",
|
||||||
|
uid,
|
||||||
|
parts: [...parts],
|
||||||
|
})));
|
||||||
|
setSelectingState(null); // no longer making a selection
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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"
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue