This commit is contained in:
Joeri Exelmans 2025-10-05 10:48:26 +02:00
parent 145e61c607
commit 924019e81c
3 changed files with 122 additions and 53 deletions

View file

@ -23,7 +23,7 @@ svg > text {
.rountangle:hover { .rountangle:hover {
/* fill: lightgrey; */ /* fill: lightgrey; */
/* stroke-width: 4px; */ /* stroke-width: 4px; */
cursor: grab; /* cursor: grab; */
} }
.rountangle.dragging { .rountangle.dragging {
@ -38,6 +38,10 @@ svg > text {
stroke-width: 4px; */ stroke-width: 4px; */
} }
.selected:hover {
cursor: grab;
}
.lineHelper { .lineHelper {
stroke: rgba(0, 0, 0, 0); stroke: rgba(0, 0, 0, 0);
stroke-width: 16px; stroke-width: 16px;
@ -87,3 +91,12 @@ line.selected, circle.selected {
stroke: blue; stroke: blue;
stroke-width: 4px; stroke-width: 4px;
} }
text.selected, text.selected:hover {
fill: blue;
/* font-weight: bold; */
}
text:hover {
fill: rgba(0, 200, 0, 1);
/* cursor: grab; */
}

View file

@ -55,16 +55,20 @@ type RountanglePart = "left" | "top" | "right" | "bottom";
type ArrowPart = "start" | "end"; type ArrowPart = "start" | "end";
type RountangleSelectable = { type RountangleSelectable = {
kind: "rountangle"; // kind: "rountangle";
parts: RountanglePart[]; parts: RountanglePart[];
uid: string; uid: string;
} }
type ArrowSelectable = { type ArrowSelectable = {
kind: "arrow"; // kind: "arrow";
parts: ArrowPart[]; parts: ArrowPart[];
uid: string; uid: string;
} }
type Selectable = RountangleSelectable | ArrowSelectable; type TextSelectable = {
parts: "text";
uid: string;
}
type Selectable = RountangleSelectable | ArrowSelectable | TextSelectable;
type Selection = Selectable[]; type Selection = Selectable[];
const minStateSize = {x: 40, y: 40}; const minStateSize = {x: 40, y: 40};
@ -103,17 +107,15 @@ export function VisualEditor() {
const onMouseDown: MouseEventHandler<SVGSVGElement> = (e) => { const onMouseDown: MouseEventHandler<SVGSVGElement> = (e) => {
console.log(e); const currentPointer = {x: e.pageX, y: e.pageY};
const currentPointer = {x: e.clientX, y: e.clientY};
if (e.button === 1) { if (e.button === 1) {
// 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();
if (mode === "state") {
// insert rountangle
setSelection([{uid: newID, parts: ["bottom", "right"]}]); setSelection([{uid: newID, parts: ["bottom", "right"]}]);
setDragging({
lastMousePos: currentPointer,
});
return { return {
...state, ...state,
rountangles: [...state.rountangles, { rountangles: [...state.rountangles, {
@ -124,6 +126,34 @@ export function VisualEditor() {
}], }],
nextID: state.nextID+1, nextID: state.nextID+1,
}; };
}
else if (mode === "transition") {
setSelection([{uid: newID, parts: ["end"]}]);
return {
...state,
arrows: [...state.arrows, {
uid: newID,
start: currentPointer,
end: currentPointer,
}],
nextID: state.nextID+1,
}
}
else if (mode === "text") {
setSelection([{uid: newID, parts: ["text"]}]);
return {
...state,
texts: [...state.texts, {
uid: newID,
text: "Double-click to edit text",
topLeft: currentPointer,
}],
nextID: state.nextID+1,
}
}
});
setDragging({
lastMousePos: currentPointer,
}); });
return; return;
} }
@ -160,7 +190,7 @@ export function VisualEditor() {
}; };
const onMouseMove = (e: MouseEvent) => { const onMouseMove = (e: MouseEvent) => {
const currentPointer = {x: e.clientX, y: e.clientY}; const currentPointer = {x: e.pageX, y: e.pageY};
if (dragging) { if (dragging) {
setDragging(prevDragState => { setDragging(prevDragState => {
const pointerDelta = subtractV2D(currentPointer, dragging.lastMousePos); const pointerDelta = subtractV2D(currentPointer, dragging.lastMousePos);
@ -177,7 +207,8 @@ export function VisualEditor() {
kind: r.kind, kind: r.kind,
...transformRect(r, parts, halfPointerDelta), ...transformRect(r, parts, halfPointerDelta),
}; };
}), })
.toSorted((a,b) => area(b) - area(a)), // sort: smaller rountangles are drawn on top
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) {
@ -187,6 +218,17 @@ export function VisualEditor() {
uid: a.uid, uid: a.uid,
...transformLine(a, parts, halfPointerDelta), ...transformLine(a, parts, halfPointerDelta),
} }
}),
texts: state.texts.map(t => {
const parts = selection.find(selected => selected.uid === t.uid)?.parts || [];
if (parts.length === 0) {
return t;
}
return {
uid: t.uid,
text: t.text,
topLeft: addV2D(t.topLeft, halfPointerDelta),
}
}) })
})); }));
return {lastMousePos: currentPointer}; return {lastMousePos: currentPointer};
@ -201,13 +243,6 @@ export function VisualEditor() {
}; };
}); });
} }
// sort: smaller rountangles are drawn on top
setState(state => ({
...state,
rountangles: state.rountangles.toSorted((a,b) => area(b) - area(a)),
}));
}; };
const onMouseUp = (e: MouseEvent) => { const onMouseUp = (e: MouseEvent) => {
@ -217,7 +252,7 @@ export function VisualEditor() {
// we were making a selection // we were making a selection
const normalizedSS = normalizeRect(ss); const normalizedSS = normalizeRect(ss);
const shapes = Array.from(refSVG.current?.querySelectorAll("rect, line, circle") || []) 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!);
@ -244,12 +279,6 @@ export function VisualEditor() {
} }
return null; // no longer selecting return null; // no longer selecting
}); });
// sort: smaller rountangles are drawn on top
setState(state => ({
...state,
rountangles: state.rountangles.toSorted((a,b) => area(b) - area(a)),
}));
}; };
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
@ -259,17 +288,18 @@ export function 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)),
arrows: state.arrows.filter(a => !selection.some(as => as.uid === a.uid)),
texts: state.texts.filter(t => !selection.some(ts => ts.uid === t.uid)),
})); }));
return []; return [];
}); });
} }
if (e.key === "o") { if (e.key === "o") {
console.log('turn selected states into OR-states...');
// selected states become OR-states // selected states become OR-states
setSelection(selection => { setSelection(selection => {
setState(state => ({ setState(state => ({
...state, ...state,
rountangles: state.rountangles.map(r => selection.includes(r.uid) ? ({...r, kind: "or"}) : r), rountangles: state.rountangles.map(r => selection.some(rs => rs.uid === r.uid) ? ({...r, kind: "or"}) : r),
})); }));
return selection; return selection;
}) })
@ -279,7 +309,7 @@ export function VisualEditor() {
setSelection(selection => { setSelection(selection => {
setState(state => ({ setState(state => ({
...state, ...state,
rountangles: state.rountangles.map(r => selection.includes(r.uid) ? ({...r, kind: "and"}) : r), rountangles: state.rountangles.map(r => selection.some(rs => rs.uid === r.uid) ? ({...r, kind: "and"}) : r),
})); }));
return selection; return selection;
}); });
@ -310,7 +340,7 @@ export function VisualEditor() {
}; };
}, [selectingState, dragging]); }, [selectingState, dragging]);
return <svg width="100%" height="100%" return <svg width="4000px" height="4000px"
className="svgCanvas" className="svgCanvas"
onMouseDown={onMouseDown} onMouseDown={onMouseDown}
onContextMenu={e => e.preventDefault()} onContextMenu={e => e.preventDefault()}
@ -332,18 +362,48 @@ export function VisualEditor() {
{state.rountangles.map(rountangle => <RountangleSVG {state.rountangles.map(rountangle => <RountangleSVG
key={rountangle.uid} key={rountangle.uid}
rountangle={rountangle} rountangle={rountangle}
dragging={(dragging!==null) && selection.includes(rountangle.uid)}
selected={selection.find(r => r.uid === rountangle.uid)?.parts || []} selected={selection.find(r => r.uid === rountangle.uid)?.parts || []}
/>)} />)}
{state.arrows.map(arrow => <ArrowSVG {state.arrows.map(arrow => <ArrowSVG
key={arrow.uid} key={arrow.uid}
arrow={arrow} arrow={arrow}
dragging={(dragging!==null) && selection.includes(arrow.uid)}
selected={selection.find(a => a.uid === arrow.uid)?.parts || []} selected={selection.find(a => a.uid === arrow.uid)?.parts || []}
/> />
)} )}
{state.texts.map(txt => <text
key={txt.uid}
className={selection.find(s => s.uid === txt.uid)?.parts?.length ? "selected":""}
x={txt.topLeft.x}
width={200}
height={40}
y={txt.topLeft.y}
data-uid={txt.uid}
data-parts="text"
onDoubleClick={() => {
const newText = prompt("", txt.text);
if (newText) {
setState(state => ({
...state,
texts: state.texts.map(t => {
if (t.uid === txt.uid) {
return {
...txt,
text: newText,
}
}
else {
return t;
}
}),
}));
}
}}
>
{txt.text}
</text>)}
{selectingState && <Selecting {...selectingState} />} {selectingState && <Selecting {...selectingState} />}
{showHelp && <> {showHelp && <>
@ -375,12 +435,11 @@ export function VisualEditor() {
const cornerOffset = 4; const cornerOffset = 4;
const cornerRadius = 16; const cornerRadius = 16;
export function RountangleSVG(props: {rountangle: Rountangle, dragging: boolean, selected: string[]}) { export function RountangleSVG(props: {rountangle: Rountangle, selected: string[]}) {
const {topLeft, size, uid} = props.rountangle; const {topLeft, size, uid} = props.rountangle;
return <g transform={`translate(${topLeft.x} ${topLeft.y})`}> return <g transform={`translate(${topLeft.x} ${topLeft.y})`}>
<rect <rect
className={"rountangle" className={"rountangle"
+(props.dragging?" dragging":"")
+(props.selected.length===4?" selected":"") +(props.selected.length===4?" selected":"")
+((props.rountangle.kind==="or")?" or":"")} +((props.rountangle.kind==="or")?" or":"")}
rx={20} ry={20} rx={20} ry={20}
@ -474,14 +533,11 @@ export function RountangleSVG(props: {rountangle: Rountangle, dragging: boolean,
</g>; </g>;
} }
export function ArrowSVG(props: {arrow: Arrow, dragging: boolean, selected: string[]}) { export function ArrowSVG(props: {arrow: Arrow, selected: string[]}) {
const {start, end, uid} = props.arrow; const {start, end, uid} = props.arrow;
return <g> return <g>
<line <line
className={"arrow" className={"arrow"}
+(props.dragging?" dragging":"")
// +(props.selected.length===2?" selected":"")
}
markerEnd='url(#arrowEnd)' markerEnd='url(#arrowEnd)'
x1={start.x} x1={start.x}
y1={start.y} y1={start.y}

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: 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: 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)),
}, },
}; };
} }