From 28071eb1f38ca71cd30b462a3cd14d84be140030 Mon Sep 17 00:00:00 2001 From: Joeri Exelmans Date: Fri, 17 Oct 2025 13:31:02 +0200 Subject: [PATCH] can draw history states --- src/VisualEditor/ArrowSVG.tsx | 28 ++++-- src/VisualEditor/DiamondSVG.tsx | 6 +- src/VisualEditor/HistorySVG.tsx | 32 ++++++- src/VisualEditor/RectHelpers.tsx | 9 +- src/VisualEditor/RountangleSVG.tsx | 5 +- src/VisualEditor/VisualEditor.css | 9 +- src/VisualEditor/VisualEditor.tsx | 138 ++++++++++++++++++++--------- src/VisualEditor/parameters.ts | 2 + 8 files changed, 166 insertions(+), 63 deletions(-) diff --git a/src/VisualEditor/ArrowSVG.tsx b/src/VisualEditor/ArrowSVG.tsx index 98f2faa..771c630 100644 --- a/src/VisualEditor/ArrowSVG.tsx +++ b/src/VisualEditor/ArrowSVG.tsx @@ -28,31 +28,47 @@ export function ArrowSVG(props: { arrow: Arrow; selected: string[]; errors: stri y={(start.y + end.y) / 2} textAnchor="middle" data-uid={uid} - data-parts="start end">{props.errors.join(' ')}} + data-parts="start end">{props.errors.join(', ')}} + {/* selection helper circles */} + + {/* selection indicator circles */} + {props.selected.includes("start") && } + {props.selected.includes("end") && } + ; } diff --git a/src/VisualEditor/DiamondSVG.tsx b/src/VisualEditor/DiamondSVG.tsx index 108f4dd..608c1f8 100644 --- a/src/VisualEditor/DiamondSVG.tsx +++ b/src/VisualEditor/DiamondSVG.tsx @@ -32,12 +32,10 @@ export function DiamondSVG(props: { diamond: Diamond; selected: string[]; highli return - - {props.diamond.uid} + textAnchor="middle">{props.diamond.uid} + ; } diff --git a/src/VisualEditor/HistorySVG.tsx b/src/VisualEditor/HistorySVG.tsx index 4c5587e..2e031d9 100644 --- a/src/VisualEditor/HistorySVG.tsx +++ b/src/VisualEditor/HistorySVG.tsx @@ -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 <> + + {text} + + ; } \ No newline at end of file diff --git a/src/VisualEditor/RectHelpers.tsx b/src/VisualEditor/RectHelpers.tsx index 8aeafa3..9bec926 100644 --- a/src/VisualEditor/RectHelpers.tsx +++ b/src/VisualEditor/RectHelpers.tsx @@ -23,29 +23,30 @@ export function RectHelper(props: { uid: string, size: Vec2D, selected: string[] )} + {/* 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. */} + {props.rountangle.uid} + {(props.errors.length > 0) && {props.errors.join(' ')}} @@ -35,9 +37,6 @@ export function RountangleSVG(props: { rountangle: Rountangle; selected: string[ selected={props.selected} highlight={props.highlight} /> - {props.rountangle.uid} ; } diff --git a/src/VisualEditor/VisualEditor.css b/src/VisualEditor/VisualEditor.css index 58a23e8..19dbe11 100644 --- a/src/VisualEditor/VisualEditor.css +++ b/src/VisualEditor/VisualEditor.css @@ -16,6 +16,10 @@ background-color: rgb(255, 140, 0, 0.2); } +.svgCanvas text { + user-select: none; +} + /* rectangle drawn while a selection is being made */ .selecting { fill: blue; @@ -32,7 +36,7 @@ } .rountangle.selected { - fill: rgba(0, 0, 255, 0.2); + /* fill: rgba(0, 0, 255, 0.2); */ } .rountangle.error { stroke: rgb(230,0,0); @@ -127,7 +131,6 @@ text.helper:hover { } .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; */ /* -webkit-text-stroke: 4px white; */ paint-order: stroke; @@ -154,7 +157,7 @@ text.helper:hover { .arrow.error { stroke: rgb(230,0,0); } -.draggableText.error, tspan.error { +text.error, tspan.error { fill: rgb(230,0,0); font-weight: 600; } diff --git a/src/VisualEditor/VisualEditor.tsx b/src/VisualEditor/VisualEditor.tsx index 8ec51d0..4e3ce6d 100644 --- a/src/VisualEditor/VisualEditor.tsx +++ b/src/VisualEditor/VisualEditor.tsx @@ -14,6 +14,7 @@ import { ArrowSVG } from "./ArrowSVG"; import { RountangleSVG } from "./RountangleSVG"; import { TextSVG } from "./TextSVG"; import { DiamondSVG } from "./DiamondSVG"; +import { HistorySVG } from "./HistorySVG"; type DraggingState = { @@ -36,7 +37,11 @@ type TextSelectable = { parts: ["text"]; uid: string; } -type Selectable = RountangleSelectable | ArrowSelectable | TextSelectable; +type HistorySelectable = { + parts: ["history"]; + uid: string; +} +type Selectable = RountangleSelectable | ArrowSelectable | TextSelectable | HistorySelectable; type Selection = Selectable[]; type HistoryState = { @@ -52,7 +57,7 @@ export const sides: [RountanglePart, (r:Rect2D)=>Line2D][] = [ ["bottom", getBottomSide], ]; -export type InsertMode = "and"|"or"|"pseudo"|"transition"|"text"; +export type InsertMode = "and"|"or"|"pseudo"|"shallow"|"deep"|"transition"|"text"; type VisualEditorProps = { setAST: Dispatch>, @@ -65,8 +70,6 @@ type VisualEditorProps = { export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditorProps) { const [historyState, setHistoryState] = useState({current: emptyState, history: [], future: []}); - const [clipboard, setClipboard] = useState>(new Set()); - const state = historyState.current; const setState = (s: SetStateAction) => { 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); if (e.button === 2) { @@ -204,6 +207,18 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor 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") { setSelection([{uid: newID, parts: ["end"]}]); return { @@ -238,11 +253,10 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor 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. - // @ts-ignore const uid = e.target?.dataset.uid; - // @ts-ignore - const parts: string[] = e.target?.dataset.parts?.split(' ') || []; - if (uid) { + const parts: string[] = e.target?.dataset.parts?.split(' ').filter((p:string) => p!=="") || []; + if (uid && parts.length > 0) { + console.log('start drag'); checkPoint(); // 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) { - 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 @@ -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 + 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 => { const parts = selection.find(selected => selected.uid === a.uid)?.parts || []; if (parts.length === 0) { @@ -311,16 +356,6 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor 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}); } @@ -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) { setDragging(null); // do not persist sizes smaller than 40x40 @@ -354,29 +389,43 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor }); } if (selectingState) { - // we were making a selection - 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 (selectingState.size.x === 0 && selectingState.size.y === 0) { + const uid = e.target?.dataset.uid; if (uid) { - const parts: Set = uidToParts.get(uid) || new Set(); - for (const part of shape.dataset.parts?.split(' ') || []) { - parts.add(part); + const parts = e.target?.dataset.parts.split(' ').filter((p: string) => p!==""); + if (uid) { + setSelection(() => [{ + uid, + parts, + }]); } - uidToParts.set(uid, parts); } } - setSelection(() => [...uidToParts.entries()].map(([uid,parts]) => ({ - uid, - parts: [...parts], - }))); + else { + // we were making a selection + 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 = 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 }; @@ -385,9 +434,10 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor setState(state => ({ ...state, 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)), 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([]); } @@ -462,7 +512,7 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor window.removeEventListener("mousemove", onMouseMove); window.removeEventListener("mouseup", onMouseUp); }; - }, [selectingState, dragging, clipboard]); + }, [selectingState, dragging]); // detect what is 'connected' const arrow2SideMap = new Map(); @@ -712,6 +762,10 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor active={active.has(diamond.uid)}/> )} + {state.history.map(history => <> + h.uid === history.uid)} /> + )} + {state.arrows.map(arrow => { const sides = arrow2SideMap.get(arrow.uid); let arc = "no" as ArcDirection; diff --git a/src/VisualEditor/parameters.ts b/src/VisualEditor/parameters.ts index e369fc0..6284f00 100644 --- a/src/VisualEditor/parameters.ts +++ b/src/VisualEditor/parameters.ts @@ -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: export const CORNER_HELPER_OFFSET = 4; export const CORNER_HELPER_RADIUS = 16; + +export const HISTORY_RADIUS = 20; \ No newline at end of file