can draw history states

This commit is contained in:
Joeri Exelmans 2025-10-17 13:31:02 +02:00
parent e8fda9bdf0
commit 28071eb1f3
8 changed files with 166 additions and 63 deletions

View file

@ -28,31 +28,47 @@ export function ArrowSVG(props: { arrow: Arrow; selected: string[]; errors: stri
y={(start.y + end.y) / 2} y={(start.y + end.y) / 2}
textAnchor="middle" textAnchor="middle"
data-uid={uid} data-uid={uid}
data-parts="start end">{props.errors.join(' ')}</text>} data-parts="start end">{props.errors.join(', ')}</text>}
<path <path
className="pathHelper helper" className="helper"
d={`M ${start.x} ${start.y} d={`M ${start.x} ${start.y}
${arcOrLine} ${arcOrLine}
${end.x} ${end.y}`} ${end.x} ${end.y}`}
data-uid={uid} data-uid={uid}
data-parts="start end" /> data-parts="start end" />
{/* selection helper circles */}
<circle <circle
className={"circleHelper helper" className="helper"
+ (props.selected.includes("start") ? " selected" : "")}
cx={start.x} cx={start.x}
cy={start.y} cy={start.y}
r={CORNER_HELPER_RADIUS} r={CORNER_HELPER_RADIUS}
data-uid={uid} data-uid={uid}
data-parts="start" /> data-parts="start" />
<circle <circle
className={"circleHelper helper" className="helper"
+ (props.selected.includes("end") ? " selected" : "")}
cx={end.x} cx={end.x}
cy={end.y} cy={end.y}
r={CORNER_HELPER_RADIUS} r={CORNER_HELPER_RADIUS}
data-uid={uid} data-uid={uid}
data-parts="end" /> data-parts="end" />
{/* selection indicator circles */}
{props.selected.includes("start") && <circle
className="selected"
cx={start.x}
cy={start.y}
r={CORNER_HELPER_RADIUS}
data-uid={uid}
data-parts="start" />}
{props.selected.includes("end") && <circle
className="selected"
cx={end.x}
cy={end.y}
r={CORNER_HELPER_RADIUS}
data-uid={uid}
data-parts="end" />}
</g>; </g>;
} }

View file

@ -32,12 +32,10 @@ export function DiamondSVG(props: { diamond: Diamond; selected: string[]; highli
return <g transform={`translate(${props.diamond.topLeft.x} ${props.diamond.topLeft.y})`}> return <g transform={`translate(${props.diamond.topLeft.x} ${props.diamond.topLeft.y})`}>
<DiamondShape size={minSize} extraAttrs={extraAttrs}/> <DiamondShape size={minSize} extraAttrs={extraAttrs}/>
<RectHelper uid={props.diamond.uid} size={minSize} highlight={props.highlight} selected={props.selected} />
<text x={minSize.x/2} y={minSize.y/2} <text x={minSize.x/2} y={minSize.y/2}
className="uid" className="uid"
textAnchor="middle" textAnchor="middle">{props.diamond.uid}</text>
data-uid={props.diamond.uid}>{props.diamond.uid}</text>
<RectHelper uid={props.diamond.uid} size={minSize} highlight={props.highlight} selected={props.selected} />
</g>; </g>;
} }

View file

@ -1,3 +1,33 @@
export function ShallowHistorySVG() { import { Vec2D } from "./geometry";
import { HISTORY_RADIUS } from "./parameters";
export function HistorySVG(props: {uid: string, topLeft: Vec2D, kind: "shallow"|"deep", selected: boolean}) {
const text = props.kind === "shallow" ? "H" : "H*";
return <>
<circle
className={props.selected ? "selected":""}
cx={props.topLeft.x+HISTORY_RADIUS}
cy={props.topLeft.y+HISTORY_RADIUS}
r={HISTORY_RADIUS}
fill="white"
stroke="black"
strokeWidth={2}
data-uid={props.uid}
data-parts="history"
/>
<text
x={props.topLeft.x+HISTORY_RADIUS}
y={props.topLeft.y+HISTORY_RADIUS+4}
textAnchor="middle"
fontWeight={500}
>{text}</text>
<circle
className="helper"
cx={props.topLeft.x+HISTORY_RADIUS}
cy={props.topLeft.y+HISTORY_RADIUS}
r={HISTORY_RADIUS}
data-uid={props.uid}
data-parts="history"
/>
</>;
} }

View file

@ -23,29 +23,30 @@ export function RectHelper(props: { uid: string, size: Vec2D, selected: string[]
<line className="helper" {...ps} data-uid={props.uid} data-parts={side}/> <line className="helper" {...ps} data-uid={props.uid} data-parts={side}/>
</>)} </>)}
{/* The corner-helpers have the DOM class 'corner' added to them, because we ignore them when the user is making a selection. Only if the user clicks directly on them, do we select their respective parts. */}
<circle <circle
className="helper" className="helper corner"
cx={CORNER_HELPER_OFFSET} cx={CORNER_HELPER_OFFSET}
cy={CORNER_HELPER_OFFSET} cy={CORNER_HELPER_OFFSET}
r={CORNER_HELPER_RADIUS} r={CORNER_HELPER_RADIUS}
data-uid={props.uid} data-uid={props.uid}
data-parts="top left" /> data-parts="top left" />
<circle <circle
className="helper" className="helper corner"
cx={props.size.x - CORNER_HELPER_OFFSET} cx={props.size.x - CORNER_HELPER_OFFSET}
cy={CORNER_HELPER_OFFSET} cy={CORNER_HELPER_OFFSET}
r={CORNER_HELPER_RADIUS} r={CORNER_HELPER_RADIUS}
data-uid={props.uid} data-uid={props.uid}
data-parts="top right" /> data-parts="top right" />
<circle <circle
className="helper" className="helper corner"
cx={props.size.x - CORNER_HELPER_OFFSET} cx={props.size.x - CORNER_HELPER_OFFSET}
cy={props.size.y - CORNER_HELPER_OFFSET} cy={props.size.y - CORNER_HELPER_OFFSET}
r={CORNER_HELPER_RADIUS} r={CORNER_HELPER_RADIUS}
data-uid={props.uid} data-uid={props.uid}
data-parts="bottom right" /> data-parts="bottom right" />
<circle <circle
className="helper" className="helper corner"
cx={CORNER_HELPER_OFFSET} cx={CORNER_HELPER_OFFSET}
cy={props.size.y - CORNER_HELPER_OFFSET} cy={props.size.y - CORNER_HELPER_OFFSET}
r={CORNER_HELPER_RADIUS} r={CORNER_HELPER_RADIUS}

View file

@ -28,6 +28,8 @@ export function RountangleSVG(props: { rountangle: Rountangle; selected: string[
{...extraAttrs} {...extraAttrs}
/> />
<text x={10} y={20} className="uid">{props.rountangle.uid}</text>
{(props.errors.length > 0) && {(props.errors.length > 0) &&
<text className="error" x={10} y={40} data-uid={uid} data-parts="left top right bottom">{props.errors.join(' ')}</text>} <text className="error" x={10} y={40} data-uid={uid} data-parts="left top right bottom">{props.errors.join(' ')}</text>}
@ -35,9 +37,6 @@ export function RountangleSVG(props: { rountangle: Rountangle; selected: string[
selected={props.selected} selected={props.selected}
highlight={props.highlight} /> highlight={props.highlight} />
<text x={10} y={20}
className="uid"
data-uid={props.rountangle.uid}>{props.rountangle.uid}</text>
</g>; </g>;
} }

View file

@ -16,6 +16,10 @@
background-color: rgb(255, 140, 0, 0.2); background-color: rgb(255, 140, 0, 0.2);
} }
.svgCanvas text {
user-select: none;
}
/* rectangle drawn while a selection is being made */ /* rectangle drawn while a selection is being made */
.selecting { .selecting {
fill: blue; fill: blue;
@ -32,7 +36,7 @@
} }
.rountangle.selected { .rountangle.selected {
fill: rgba(0, 0, 255, 0.2); /* fill: rgba(0, 0, 255, 0.2); */
} }
.rountangle.error { .rountangle.error {
stroke: rgb(230,0,0); stroke: rgb(230,0,0);
@ -127,7 +131,6 @@ text.helper:hover {
} }
.draggableText, .draggableText.highlight { .draggableText, .draggableText.highlight {
user-select: none;
/* text-shadow: 2px 0 #fff, -2px 0 #fff, 0 2px #fff, 0 -2px #fff, 1px 1px #fff, -1px -1px #fff, 1px -1px #fff, -1px 1px #fff; */ /* text-shadow: 2px 0 #fff, -2px 0 #fff, 0 2px #fff, 0 -2px #fff, 1px 1px #fff, -1px -1px #fff, 1px -1px #fff, -1px 1px #fff; */
/* -webkit-text-stroke: 4px white; */ /* -webkit-text-stroke: 4px white; */
paint-order: stroke; paint-order: stroke;
@ -154,7 +157,7 @@ text.helper:hover {
.arrow.error { .arrow.error {
stroke: rgb(230,0,0); stroke: rgb(230,0,0);
} }
.draggableText.error, tspan.error { text.error, tspan.error {
fill: rgb(230,0,0); fill: rgb(230,0,0);
font-weight: 600; font-weight: 600;
} }

View file

@ -14,6 +14,7 @@ import { ArrowSVG } from "./ArrowSVG";
import { RountangleSVG } from "./RountangleSVG"; import { RountangleSVG } from "./RountangleSVG";
import { TextSVG } from "./TextSVG"; import { TextSVG } from "./TextSVG";
import { DiamondSVG } from "./DiamondSVG"; import { DiamondSVG } from "./DiamondSVG";
import { HistorySVG } from "./HistorySVG";
type DraggingState = { type DraggingState = {
@ -36,7 +37,11 @@ type TextSelectable = {
parts: ["text"]; parts: ["text"];
uid: string; uid: string;
} }
type Selectable = RountangleSelectable | ArrowSelectable | TextSelectable; type HistorySelectable = {
parts: ["history"];
uid: string;
}
type Selectable = RountangleSelectable | ArrowSelectable | TextSelectable | HistorySelectable;
type Selection = Selectable[]; type Selection = Selectable[];
type HistoryState = { type HistoryState = {
@ -52,7 +57,7 @@ export const sides: [RountanglePart, (r:Rect2D)=>Line2D][] = [
["bottom", getBottomSide], ["bottom", getBottomSide],
]; ];
export type InsertMode = "and"|"or"|"pseudo"|"transition"|"text"; export type InsertMode = "and"|"or"|"pseudo"|"shallow"|"deep"|"transition"|"text";
type VisualEditorProps = { type VisualEditorProps = {
setAST: Dispatch<SetStateAction<Statechart>>, setAST: Dispatch<SetStateAction<Statechart>>,
@ -65,8 +70,6 @@ type VisualEditorProps = {
export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditorProps) { export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditorProps) {
const [historyState, setHistoryState] = useState<HistoryState>({current: emptyState, history: [], future: []}); const [historyState, setHistoryState] = useState<HistoryState>({current: emptyState, history: [], future: []});
const [clipboard, setClipboard] = useState<Set<string>>(new Set());
const state = historyState.current; const state = historyState.current;
const setState = (s: SetStateAction<VisualEditorState>) => { const setState = (s: SetStateAction<VisualEditorState>) => {
setHistoryState(historyState => { setHistoryState(historyState => {
@ -170,7 +173,7 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor
} }
} }
const onMouseDown = (e: MouseEvent) => { const onMouseDown = (e: {button: number, target: any, pageX: number, pageY: number}) => {
const currentPointer = getCurrentPointer(e); const currentPointer = getCurrentPointer(e);
if (e.button === 2) { if (e.button === 2) {
@ -204,6 +207,18 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor
nextID: state.nextID+1, nextID: state.nextID+1,
}; };
} }
else if (mode === "shallow" || mode === "deep") {
setSelection([{uid: newID, parts: ["history"]}]);
return {
...state,
history: [...state.history, {
uid: newID,
kind: mode,
topLeft: currentPointer,
}],
nextID: state.nextID+1,
}
}
else if (mode === "transition") { else if (mode === "transition") {
setSelection([{uid: newID, parts: ["end"]}]); setSelection([{uid: newID, parts: ["end"]}]);
return { return {
@ -238,11 +253,10 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor
if (e.button === 0) { if (e.button === 0) {
// 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 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.
// @ts-ignore
const uid = e.target?.dataset.uid; const uid = e.target?.dataset.uid;
// @ts-ignore const parts: string[] = e.target?.dataset.parts?.split(' ').filter((p:string) => p!=="") || [];
const parts: string[] = e.target?.dataset.parts?.split(' ') || []; if (uid && parts.length > 0) {
if (uid) { console.log('start drag');
checkPoint(); checkPoint();
// if the mouse button is pressed outside of the current selection, we reset the selection to whatever shape the mouse is on // if the mouse button is pressed outside of the current selection, we reset the selection to whatever shape the mouse is on
@ -254,7 +268,18 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor
} }
} }
if (!allPartsInSelection) { if (!allPartsInSelection) {
setSelection([{uid, parts}] as Selection); if (e.target.classList.contains("helper")) {
setSelection([{uid, parts}] as Selection);
}
else {
setDragging(null);
setSelectingState({
topLeft: currentPointer,
size: {x: 0, y: 0},
});
setSelection([]);
return;
}
} }
// start dragging // start dragging
@ -291,6 +316,26 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor
}; };
}) })
.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
diamonds: state.diamonds.map(d => {
const parts = selection.find(selected => selected.uid === d.uid)?.parts || [];
if (parts.length === 0) {
return d;
}
return {
...d,
...transformRect(d, parts, pointerDelta),
}
}),
history: state.history.map(h => {
const parts = selection.find(selected => selected.uid === h.uid)?.parts || [];
if (parts.length === 0) {
return h;
}
return {
...h,
topLeft: addV2D(h.topLeft, pointerDelta),
}
}),
arrows: state.arrows.map(a => { arrows: state.arrows.map(a => {
const parts = selection.find(selected => selected.uid === a.uid)?.parts || []; const parts = selection.find(selected => selected.uid === a.uid)?.parts || [];
if (parts.length === 0) { if (parts.length === 0) {
@ -311,16 +356,6 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor
topLeft: addV2D(t.topLeft, pointerDelta), topLeft: addV2D(t.topLeft, pointerDelta),
} }
}), }),
diamonds: state.diamonds.map(d => {
const parts = selection.find(selected => selected.uid === d.uid)?.parts || [];
if (parts.length === 0) {
return d;
}
return {
...d,
...transformRect(d, parts, pointerDelta),
}
})
})); }));
setDragging({lastMousePos: currentPointer}); setDragging({lastMousePos: currentPointer});
} }
@ -335,7 +370,7 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor
} }
}; };
const onMouseUp = (e: {pageX: number, pageY: number}) => { const onMouseUp = (e: {target: any, pageX: number, pageY: number}) => {
if (dragging) { if (dragging) {
setDragging(null); setDragging(null);
// do not persist sizes smaller than 40x40 // do not persist sizes smaller than 40x40
@ -354,29 +389,43 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor
}); });
} }
if (selectingState) { if (selectingState) {
// we were making a selection if (selectingState.size.x === 0 && selectingState.size.y === 0) {
const normalizedSS = normalizeRect(selectingState); const uid = e.target?.dataset.uid;
const shapes = Array.from(refSVG.current?.querySelectorAll("rect, line, circle, text") || []) as SVGGraphicsElement[];
const shapesInSelection = shapes.filter(el => {
const bbox = getBBoxInSvgCoords(el, refSVG.current!);
return isEntirelyWithin(bbox, normalizedSS);
}).filter(el => !el.classList.contains("corner"));
const uidToParts = new Map();
for (const shape of shapesInSelection) {
const uid = shape.dataset.uid;
if (uid) { if (uid) {
const parts: Set<string> = uidToParts.get(uid) || new Set(); const parts = e.target?.dataset.parts.split(' ').filter((p: string) => p!=="");
for (const part of shape.dataset.parts?.split(' ') || []) { if (uid) {
parts.add(part); setSelection(() => [{
uid,
parts,
}]);
} }
uidToParts.set(uid, parts);
} }
} }
setSelection(() => [...uidToParts.entries()].map(([uid,parts]) => ({ else {
uid, // we were making a selection
parts: [...parts], const normalizedSS = normalizeRect(selectingState);
}))); const shapes = Array.from(refSVG.current?.querySelectorAll("rect, line, circle, text") || []) as SVGGraphicsElement[];
const shapesInSelection = shapes.filter(el => {
const bbox = getBBoxInSvgCoords(el, refSVG.current!);
return isEntirelyWithin(bbox, normalizedSS);
}).filter(el => !el.classList.contains("corner"));
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]) => ({
uid,
parts: [...parts],
})));
}
} }
setSelectingState(null); // no longer making a selection setSelectingState(null); // no longer making a selection
}; };
@ -385,9 +434,10 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor
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)),
diamonds: state.diamonds.filter(d => !selection.some(ds => ds.uid === d.uid)),
history: state.history.filter(h => !selection.some(hs => hs.uid === h.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)),
diamonds: state.diamonds.filter(d => !selection.some(ds => ds.uid === d.uid)),
})); }));
setSelection([]); setSelection([]);
} }
@ -462,7 +512,7 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor
window.removeEventListener("mousemove", onMouseMove); window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp); window.removeEventListener("mouseup", onMouseUp);
}; };
}, [selectingState, dragging, clipboard]); }, [selectingState, dragging]);
// detect what is 'connected' // detect what is 'connected'
const arrow2SideMap = new Map<string,[{ uid: string; part: RountanglePart; } | undefined, { uid: string; part: RountanglePart; } | undefined]>(); const arrow2SideMap = new Map<string,[{ uid: string; part: RountanglePart; } | undefined, { uid: string; part: RountanglePart; } | undefined]>();
@ -712,6 +762,10 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor
active={active.has(diamond.uid)}/> active={active.has(diamond.uid)}/>
</>)} </>)}
{state.history.map(history => <>
<HistorySVG {...history} selected={selection.find(h => h.uid === history.uid)} />
</>)}
{state.arrows.map(arrow => { {state.arrows.map(arrow => {
const sides = arrow2SideMap.get(arrow.uid); const sides = arrow2SideMap.get(arrow.uid);
let arc = "no" as ArcDirection; let arc = "no" as ArcDirection;

View file

@ -8,3 +8,5 @@ export const MIN_ROUNTANGLE_SIZE = { x: ROUNTANGLE_RADIUS*2, y: ROUNTANGLE_RADIU
// those hoverable green transparent circles in the corners of rountangles: // those hoverable green transparent circles in the corners of rountangles:
export const CORNER_HELPER_OFFSET = 4; export const CORNER_HELPER_OFFSET = 4;
export const CORNER_HELPER_RADIUS = 16; export const CORNER_HELPER_RADIUS = 16;
export const HISTORY_RADIUS = 20;