diff --git a/src/VisualEditor/VisualEditor.css b/src/VisualEditor/VisualEditor.css index 11c6c77..2ad61ca 100644 --- a/src/VisualEditor/VisualEditor.css +++ b/src/VisualEditor/VisualEditor.css @@ -2,8 +2,23 @@ cursor: crosshair; } -text { +text, text.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; + stroke: white; + stroke-width: 4px; + stroke-linecap: butt; + stroke-linejoin: miter; + stroke-opacity: 1; + fill-opacity:1; + /* font-weight: 800; */ +} + +text.highlight { + fill: green; + font-weight: 600; } .selecting { @@ -70,6 +85,10 @@ text { stroke: black; stroke-width: 2px; } +.arrow.selected { + stroke: blue; + stroke-width: 3px; +} /* .arrow.selected { stroke: blue; @@ -100,7 +119,7 @@ text.selected, text.selected:hover { /* font-weight: bold; */ } text:hover { - fill: rgba(0, 200, 0, 1); + fill: darkcyan; /* cursor: grab; */ } diff --git a/src/VisualEditor/VisualEditor.tsx b/src/VisualEditor/VisualEditor.tsx index cd5e546..39ed002 100644 --- a/src/VisualEditor/VisualEditor.tsx +++ b/src/VisualEditor/VisualEditor.tsx @@ -4,7 +4,7 @@ import { Line2D, Rect2D, Vec2D, addV2D, area, getBottomSide, getLeftSide, getRig import "./VisualEditor.css"; import { getBBoxInSvgCoords } from "./svg_helper"; -import { VisualEditorState, Rountangle, emptyState, Arrow, ArrowPart, RountanglePart, findNearestRountangleSide } from "./editor_types"; +import { VisualEditorState, Rountangle, emptyState, Arrow, ArrowPart, RountanglePart, findNearestRountangleSide, findNearestArrow, Text } from "./editor_types"; import { parseStatechart } from "./parser"; import { CORNER_HELPER_OFFSET, CORNER_HELPER_RADIUS, MIN_ROUNTANGLE_SIZE, ROUNTANGLE_RADIUS } from "./parameters"; @@ -124,7 +124,7 @@ export function VisualEditor() { // 2) performance: only save when the user does nothing const timeout = setTimeout(() => { window.localStorage.setItem("state", JSON.stringify(state)); - console.log('saved to localStorage'); + // console.log('saved to localStorage'); const [statechart, errors] = parseStatechart(state); console.log('statechart: ', statechart, 'errors:', errors); @@ -396,7 +396,11 @@ export function VisualEditor() { }; }, [selectingState, dragging]); + // whenever an arrow is selected, highlight the rountangle sides it connects to + // just for visual feedback let sidesToHighlight: {[key: string]: RountanglePart[]} = {}; + let arrowsToHighlight: {[key: string]: Arrow} = {}; + let textsToHighlight: {[key: string]: Text} = {}; for (const selected of selection) { for (const arrow of state.arrows) { if (arrow.uid === selected.uid) { @@ -408,7 +412,20 @@ export function VisualEditor() { if (rSideEnd) { sidesToHighlight[rSideEnd.uid] = [...(sidesToHighlight[rSideEnd.uid] || []), rSideEnd.part]; } - + for (const text of state.texts) { + const belongsToArrow = findNearestArrow(text.topLeft, state.arrows); + if (belongsToArrow === arrow) { + textsToHighlight[text.uid] = text; + } + } + } + } + for (const text of state.texts) { + if (text.uid === selected.uid) { + const belongsToArrow = findNearestArrow(text.topLeft, state.arrows); + if (belongsToArrow) { + arrowsToHighlight[belongsToArrow.uid] = belongsToArrow; + } } } } @@ -449,16 +466,19 @@ export function VisualEditor() { arrow={arrow} selected={selection.find(a => a.uid === arrow.uid)?.parts || []} errors={errors.filter(([uid,msg])=>uid===arrow.uid).map(err=>err[1])} + highlight={arrowsToHighlight.hasOwnProperty(arrow.uid)} /> )} {state.texts.map(txt => s.uid === txt.uid)?.parts?.length ? "selected":""} + className={ + (selection.find(s => s.uid === txt.uid)?.parts?.length ? "selected":"") + +(textsToHighlight.hasOwnProperty(txt.uid)?" highlight":"") + } x={txt.topLeft.x} - width={200} - height={40} y={txt.topLeft.y} + textAnchor="middle" data-uid={txt.uid} data-parts="text" onDoubleClick={() => { @@ -543,7 +563,8 @@ export function RountangleSVG(props: {rountangle: Rountangle, selected: string[] data-parts="left top right bottom" /> - {(props.errors.length>0) && {props.errors.join(' ')}} + {(props.errors.length>0) && + {props.errors.join(' ')}} ; } -export function ArrowSVG(props: {arrow: Arrow, selected: string[], errors: string[]}) { +export function ArrowSVG(props: {arrow: Arrow, selected: string[], errors: string[], highlight: boolean}) { const {start, end, uid} = props.arrow; return 0?" error":"")} + className={"arrow" + +(props.selected.length===2?" selected":"") + +(props.errors.length>0?" error":"") + +(props.highlight?" highlight":"") + } markerEnd='url(#arrowEnd)' x1={start.x} y1={start.y} diff --git a/src/VisualEditor/editor_types.ts b/src/VisualEditor/editor_types.ts index 2c73b62..90b4d9a 100644 --- a/src/VisualEditor/editor_types.ts +++ b/src/VisualEditor/editor_types.ts @@ -1,5 +1,5 @@ import { Rect2D, Vec2D, Line2D, euclideanDistance, intersectLines, isWithin, lineBBox } from "./geometry"; -import { ARROW_SNAP_THRESHOLD } from "./parameters"; +import { ARROW_SNAP_THRESHOLD, TEXT_SNAP_THRESHOLD } from "./parameters"; import { sides } from "./VisualEditor"; export type Rountangle = { @@ -7,7 +7,7 @@ export type Rountangle = { kind: "and" | "or"; } & Rect2D; -type Text = { +export type Text = { uid: string; topLeft: Vec2D; text: string; @@ -44,6 +44,7 @@ export const onOffStateMachine = { nextID: 3, }; +// used to find which rountangle an arrow connects to (src/tgt) export function findNearestRountangleSide(arrow: Line2D, arrowPart: "start" | "end", candidates: Rountangle[]): {uid: string, part: RountanglePart} | undefined { let best = Infinity; let bestSide: undefined | {uid: string, part: RountanglePart}; @@ -63,3 +64,45 @@ export function findNearestRountangleSide(arrow: Line2D, arrowPart: "start" | "e } return bestSide; } + +export function point2LineDistance(point: Vec2D, {start, end}: Line2D): number { + const A = point.x - start.x; + const B = point.y - start.y; + const C = end.x - start.x; + const D = end.y - start.y; + + const dot = A * C + B * D; + const lenSq = C * C + D * D; + let t = lenSq ? dot / lenSq : -1; + + if (t < 0) t = 0; + else if (t > 1) t = 1; + + const closestX = start.x + t * C; + const closestY = start.y + t * D; + + const dx = point.x - closestX; + const dy = point.y - closestY; + + const distance = Math.hypot(dx, dy); + + return distance; +} + +// used to find which arrow a text label belongs to (if any) +// author: ChatGPT +export function findNearestArrow(point: Vec2D, candidates: Arrow[]): Arrow | undefined { + let best; + let bestDistance = Infinity + + for (const arrow of candidates) { + const distance = point2LineDistance(point, arrow); + + if (distance < TEXT_SNAP_THRESHOLD && distance < bestDistance) { + bestDistance = distance; + best = arrow; + } + } + + return best; +} diff --git a/src/VisualEditor/geometry.ts b/src/VisualEditor/geometry.ts index 3380ba0..81f3684 100644 --- a/src/VisualEditor/geometry.ts +++ b/src/VisualEditor/geometry.ts @@ -133,7 +133,8 @@ export function intersectLines(a: Line2D, b: Line2D): Vec2D | null { export function euclideanDistance(a: Vec2D, b: Vec2D): number { const diffX = a.x - b.x; const diffY = a.y - b.y; - return Math.sqrt(diffX*diffX + diffY*diffY); + return Math.hypot(diffX, diffY); + // return Math.sqrt(diffX*diffX + diffY*diffY); } export function getLeftSide(rect: Rect2D): Line2D { diff --git a/src/VisualEditor/parameters.ts b/src/VisualEditor/parameters.ts index 0ecdcec..7b47a33 100644 --- a/src/VisualEditor/parameters.ts +++ b/src/VisualEditor/parameters.ts @@ -1,5 +1,6 @@ export const ARROW_SNAP_THRESHOLD = 20; +export const TEXT_SNAP_THRESHOLD = 20; export const ROUNTANGLE_RADIUS = 20; export const MIN_ROUNTANGLE_SIZE = { x: ROUNTANGLE_RADIUS*2, y: ROUNTANGLE_RADIUS*2 };