diff --git a/src/Handlers/EventHandlers/arrowBindingHandler.js b/src/Handlers/EventHandlers/arrowBindingHandler.js index d049a84..407883d 100644 --- a/src/Handlers/EventHandlers/arrowBindingHandler.js +++ b/src/Handlers/EventHandlers/arrowBindingHandler.js @@ -21,19 +21,61 @@ function ArrowBindingHandler() { useEffect(() => { if (!canvas) return undefined; - // A shape with bound arrows moved/resized -> re-route those arrows live. We + // Shape(s) with bound arrows moved/resized -> re-route those arrows live. We // react to object:moving/scaling (not object:modified) so the final // positions are in place when fabric-history snapshots on drop — undo - // restores shape + arrows together with no extra history plumbing. + // restores shape + arrows together with no extra history plumbing. A + // multi-select drag reports the target as an activeSelection, so fan out to + // every selected shape (else arrows between selected shapes are left behind). const onShapeChange = (opt) => { const t = opt && opt.target; - if (!t || isArrow(t) || t.type === "activeSelection" || !t.id) return; - const arrows = boundArrows(canvas, t.id); - if (!arrows.length) return; + if (!t || isArrow(t)) return; + const shapes = t.type === "activeSelection" ? t.getObjects() : [t]; + const arrows = new Set(); + shapes.forEach((s) => { + if (s && s.id) boundArrows(canvas, s.id).forEach((a) => arrows.add(a)); + }); + if (!arrows.size) return; arrows.forEach((a) => rerouteArrow(canvas, a)); canvas.requestRenderAll(); }; + // While a bound arrow is itself being dragged, keep each bound end glued to + // its shape's border LIVE (every frame), so the arrowhead glides on the + // border instead of following the cursor into the shape and then snapping + // back on release — that snap was the "padding between cursor and arrowhead". + // refit=false: mid-drag we must NOT reset the group's bounds, or we'd fight + // fabric's own translate (bounds are re-fitted on drop, below). + const onArrowMoving = (opt) => { + const t = opt && opt.target; + if (!t || !isArrow(t) || (!t.startBinding && !t.endBinding)) return; + rerouteArrow(canvas, t, false); + canvas.requestRenderAll(); + }; + + // On drop, re-evaluate each bound end against where it landed: still over its + // shape -> stay glued; dragged onto a different shape -> rebind; dragged into + // empty space -> unbind (so a bound arrow can be pulled off, not just pinned). + // Then a final reroute re-fits the bounds. + // + // An endpoint drag ALSO ends in object:modified (action "arrowEndpoint") but + // owns its own (un)binding via arrow:endpoint:up — skip it, or we'd fight it. + const onArrowMoved = (opt) => { + const t = opt && opt.target; + if (!t || !isArrow(t) || (!t.startBinding && !t.endBinding)) return; + if (opt.action === "arrowEndpoint") return; + ["start", "end"].forEach((end) => { + const boundId = end === "start" ? t.startBinding : t.endBinding; + if (!boundId) return; + const p = arrowEndScene(t, end); + const shape = shapeUnderPoint(canvas, p, [t]); + if (shape) bindEnd(t, end, shape, p); + else unbindEnd(t, end); + }); + if (t.startBinding || t.endBinding) rerouteArrow(canvas, t); + canvas.requestRenderAll(); + }; + // Dragging ONE endpoint: highlight only the shape THAT endpoint is over // (the other end isn't changing, so highlighting it too just adds clutter). const onEndpointMoving = (opt) => { @@ -61,13 +103,17 @@ function ArrowBindingHandler() { const onMouseUp = () => clearBindHighlight(canvas); canvas.on("object:moving", onShapeChange); + canvas.on("object:moving", onArrowMoving); canvas.on("object:scaling", onShapeChange); + canvas.on("object:modified", onArrowMoved); canvas.on("arrow:endpoint:moving", onEndpointMoving); canvas.on("arrow:endpoint:up", onEndpointUp); canvas.on("mouse:up", onMouseUp); return () => { canvas.off("object:moving", onShapeChange); + canvas.off("object:moving", onArrowMoving); canvas.off("object:scaling", onShapeChange); + canvas.off("object:modified", onArrowMoved); canvas.off("arrow:endpoint:moving", onEndpointMoving); canvas.off("arrow:endpoint:up", onEndpointUp); canvas.off("mouse:up", onMouseUp); diff --git a/src/Handlers/EventHandlers/arrowResizeHandler.js b/src/Handlers/EventHandlers/arrowResizeHandler.js index 67463e9..1047e51 100644 --- a/src/Handlers/EventHandlers/arrowResizeHandler.js +++ b/src/Handlers/EventHandlers/arrowResizeHandler.js @@ -1,7 +1,7 @@ import { useEffect } from "react"; import { fabric } from "fabric"; import { useCanvasContext } from "../../hooks"; -import { buildArrowGroup } from "../ToolsHandler/tools/arrow"; +import { rebuildArrow } from "../ToolsHandler/tools/arrow"; import { isArrow, counterScaleArrowDecorations } from "../../utils/shapeLabel"; // Arrow detection (line + 1-2 heads, optional text label) is centralised in @@ -25,66 +25,11 @@ function ArrowResizeHandler() { // Rebuild one scaled arrow group at scale 1 between its real endpoints, // preserving its head config (single vs double). Returns the fresh arrow // (already swapped onto the canvas). + // rebuildArrow reads the arrow's real (scaled) endpoints and rebuilds a + // fresh, unscaled arrow between them — preserving heads, straight/elbow type, + // label and bindings, and handling the line-or-polyline connector. const rebuildScaledArrow = (group) => { - const line = group._objects.find((c) => c.type === "line"); - const heads = group._objects.filter((c) => c.type === "path"); - const textChild = group._objects.find( - (c) => c.type === "textbox" || c.type === "i-text" || c.type === "text", - ); - const matrix = group.calcTransformMatrix(); - // Derive both endpoints from the line's real endpoints — NOT the group's - // bounding box: a head inflates the bbox, so an opposite-corner tail sits - // off the line and tilts a horizontal arrow on drop. calcLinePoints() is - // relative to the line's centre; add its left/top (relative to the group - // centre) then map to absolute. - const lp = line.calcLinePoints(); - const end1 = fabric.util.transformPoint( - new fabric.Point(line.left + lp.x1, line.top + lp.y1), - matrix, - ); - const end2 = fabric.util.transformPoint( - new fabric.Point(line.left + lp.x2, line.top + lp.y2), - matrix, - ); - - let tail; - let tip; - if (heads.length === 2) { - // Double-headed: symmetric, so either endpoint can be the "end". - tail = end1; - tip = end2; - } else { - // Single-headed: the tip is the endpoint nearest the head. - const headAbs = fabric.util.transformPoint( - new fabric.Point(heads[0].left, heads[0].top), - matrix, - ); - const d1 = Math.hypot(end1.x - headAbs.x, end1.y - headAbs.y); - const d2 = Math.hypot(end2.x - headAbs.x, end2.y - headAbs.y); - tip = d1 < d2 ? end1 : end2; - tail = d1 < d2 ? end2 : end1; - } - - const arrow = buildArrowGroup( - { x: tail.x, y: tail.y }, - { x: tip.x, y: tip.y }, - { - stroke: line.stroke, - strokeWidth: line.strokeWidth, - strokeDashArray: line.strokeDashArray, - heads: heads.length === 2 ? "both" : "end", - // Preserve a label across the rebuild, re-centred on the new midpoint. - label: textChild - ? { - text: textChild.text, - fontFamily: textChild.fontFamily, - fontSize: textChild.fontSize, - fill: textChild.fill, - backgroundColor: textChild.backgroundColor, - } - : null, - }, - ); + const arrow = rebuildArrow(group); canvas.remove(group); canvas.add(arrow); arrow.setCoords(); diff --git a/src/Handlers/ToolsHandler/toolStyle.js b/src/Handlers/ToolsHandler/toolStyle.js index 2792d02..6e2decb 100644 --- a/src/Handlers/ToolsHandler/toolStyle.js +++ b/src/Handlers/ToolsHandler/toolStyle.js @@ -13,6 +13,7 @@ export const DEFAULT_STYLE = { fontFamily: "Comic Sans MS", // excalidraw-style hand-drawn default fontSize: 20, // "M" — Excalidraw's default; readable without overflowing arrowHeads: "end", // "end" (single head) | "both" (double-headed) + arrowType: "straight", // "straight" | "elbow" (orthogonal, right-angle route) }; // Arrowhead configurations offered for the arrow tool. @@ -21,6 +22,12 @@ export const ARROW_HEAD_OPTIONS = [ { id: "both", label: "Double-headed" }, ]; +// Arrow routing offered for the arrow tool: straight line vs elbow (orthogonal). +export const ARROW_TYPE_OPTIONS = [ + { id: "straight", label: "Straight" }, + { id: "elbow", label: "Elbow" }, +]; + export const FONT_FAMILIES = [ "Arial", "Helvetica", diff --git a/src/Handlers/ToolsHandler/tools/arrow.js b/src/Handlers/ToolsHandler/tools/arrow.js index 58e7342..04cbc00 100644 --- a/src/Handlers/ToolsHandler/tools/arrow.js +++ b/src/Handlers/ToolsHandler/tools/arrow.js @@ -1,7 +1,14 @@ import { fabric } from "fabric"; import { Tool } from "../toolGeneric"; import { resolveToolStyle } from "../toolStyle"; -import { attachEndpointControls } from "../../../utils/arrowEndpoints"; +import { + attachEndpointControls, + elbowRoute, + setArrowEndpoints, + sceneEndpoints, + headCenterFor, +} from "../../../utils/arrowEndpoints"; +import { getArrowParts, isElbowArrow } from "../../../utils/shapeLabel"; import { bindArrowOnDraw, shapeUnderPoint } from "../../../utils/binding"; import { showBindHighlight, @@ -36,7 +43,8 @@ const makeHead = (at, angle, color) => // (a second head at start). Exported so the resize handler and the properties // panel can rebuild an arrow without scaling — and distorting — the head(s). export const buildArrowGroup = (start, end, style) => { - const line = new fabric.Line([start.x, start.y, end.x, end.y], { + const elbow = style.arrowType === "elbow"; + const connStyle = { stroke: style.stroke, strokeWidth: style.strokeWidth, strokeDashArray: style.strokeDashArray || null, @@ -46,13 +54,27 @@ export const buildArrowGroup = (start, end, style) => { hasControls: false, hasBorders: false, selectable: false, - }); + }; + // The connector: a straight line, or an orthogonally-routed polyline (elbow). + const line = elbow + ? new fabric.Polyline(elbowRoute(start, end), { ...connStyle, fill: "" }) + : new fabric.Line([start.x, start.y, end.x, end.y], connStyle); + // Head aims along the last route segment (= start->end for a straight arrow). + // Its centre is backed off so the tip vertex sits exactly on the endpoint. + const route = elbow ? elbowRoute(start, end) : [start, end]; + const prev = route[route.length - 2]; const children = [ line, - makeHead(end, angleBetween(start, end), style.stroke), + makeHead(headCenterFor(end, prev), angleBetween(prev, end), style.stroke), ]; if (style.heads === "both") { - children.push(makeHead(start, angleBetween(end, start), style.stroke)); + children.push( + makeHead( + headCenterFor(start, route[1]), + angleBetween(route[1], start), + style.stroke, + ), + ); } // Optional centred text label, anchored to the line's midpoint. Carried // through rebuilds (resize) so a labelled arrow keeps its label; a non- @@ -90,6 +112,11 @@ export const buildArrowGroup = (start, end, style) => { // transparent shapes keep convenient click-anywhere bbox selection. perPixelTargetFind: true, }); + // Re-lay the connector in group-local space (and re-fit bounds) through the + // single layout path, so its points/endpoints are consistent with the rest of + // the arrow model — an elbow polyline needs it (built in absolute coords), and + // a straight line needs the head-tip shortening applyEndpointsLocal applies. + setArrowEndpoints(group, { x: start.x, y: start.y }, { x: end.x, y: end.y }); // Excalidraw-style editing: drag either endpoint to re-aim/extend the arrow, // instead of scaling a bounding box. Replaces the default controls with two // endpoint handles (tail + tip). @@ -97,6 +124,47 @@ export const buildArrowGroup = (start, end, style) => { return group; }; +// Binding fields carried across a rebuild so a rebuilt arrow stays bound. +const BINDING_FIELDS = [ + "id", + "startBinding", + "endBinding", + "startAnchor", + "endAnchor", +]; + +// Rebuild an existing arrow between its current endpoints, preserving its style, +// heads, elbow/straight type, label and bindings — with `overrides` applied +// (e.g. { arrowType: "elbow" } or { heads: "both" }). The single rebuild path +// for the properties panel and the resize handler, so all of them handle elbow +// arrows and keep bindings. Does NOT touch the canvas — the caller swaps it in. +export const rebuildArrow = (group, overrides = {}) => { + const { tail, tip } = sceneEndpoints(group); + const { line, heads, text } = getArrowParts(group); + const arrow = buildArrowGroup(tail, tip, { + stroke: line.stroke, + strokeWidth: line.strokeWidth, + strokeDashArray: line.strokeDashArray, + heads: heads.length === 2 ? "both" : "end", + arrowType: isElbowArrow(group) ? "elbow" : "straight", + label: text + ? { + text: text.text, + fontFamily: text.fontFamily, + fontSize: text.fontSize, + fill: text.fill, + backgroundColor: text.backgroundColor, + } + : null, + ...overrides, + }); + BINDING_FIELDS.forEach((f) => { + if (group[f] !== undefined) arrow[f] = group[f]; + }); + arrow.setCoords(); + return arrow; +}; + export class Arrow extends Tool { constructor() { super(); @@ -117,6 +185,7 @@ export class Arrow extends Tool { this.origY = this.pointer.y; this.style = resolveToolStyle(canvas); this.heads = canvas.currentArrowHeads === "both" ? "both" : "end"; + this.arrowType = canvas.currentArrowType === "elbow" ? "elbow" : "straight"; // Live preview: a plain line + head that follow the cursor; on mouse-up // they're replaced by a proper arrow group (buildArrowGroup). @@ -156,13 +225,27 @@ export class Arrow extends Tool { } this.pointer = canvas.getPointer(event.e); const origin = { x: this.origX, y: this.origY }; - this.line.set({ x2: this.pointer.x, y2: this.pointer.y }); + // Back the head off so its tip (not centre) tracks the cursor, and stop the + // line at the head centre so it's hidden under the head (not poking past the + // tip) — matching the final arrow, so the preview doesn't shift on drop. + const headCen = headCenterFor(this.pointer, origin); + const startCen = this.arrowHeadStart + ? headCenterFor(origin, this.pointer) + : origin; + this.line.set({ + x1: startCen.x, + y1: startCen.y, + x2: headCen.x, + y2: headCen.y, + }); this.line.setCoords(); - this.arrowHead.left = this.pointer.x; - this.arrowHead.top = this.pointer.y; + this.arrowHead.left = headCen.x; + this.arrowHead.top = headCen.y; this.arrowHead.angle = angleBetween(origin, this.pointer); this.arrowHead.setCoords(); if (this.arrowHeadStart) { + this.arrowHeadStart.left = startCen.x; + this.arrowHeadStart.top = startCen.y; this.arrowHeadStart.angle = angleBetween(this.pointer, origin); this.arrowHeadStart.setCoords(); } @@ -195,7 +278,11 @@ export class Arrow extends Tool { const arrow = buildArrowGroup( { x: this.origX, y: this.origY }, { x: this.pointer.x, y: this.pointer.y }, - { ...(this.style || resolveToolStyle(canvas)), heads: this.heads }, + { + ...(this.style || resolveToolStyle(canvas)), + heads: this.heads, + arrowType: this.arrowType, + }, ); arrow.setCoords(); canvas.add(arrow); diff --git a/src/components/CanvasEditor/PropertiesPanel/PropertiesPanel.jsx b/src/components/CanvasEditor/PropertiesPanel/PropertiesPanel.jsx index c4b4e3f..109006c 100644 --- a/src/components/CanvasEditor/PropertiesPanel/PropertiesPanel.jsx +++ b/src/components/CanvasEditor/PropertiesPanel/PropertiesPanel.jsx @@ -1,5 +1,4 @@ import React, { useEffect, useRef } from "react"; -import { fabric } from "fabric"; import { Tooltip } from "@mui/material"; import { ChevronsUp, @@ -19,14 +18,16 @@ import { FONT_FAMILIES, FONT_SIZES, ARROW_HEAD_OPTIONS, + ARROW_TYPE_OPTIONS, toFabricStyle, toTextStyle, } from "../../../Handlers/ToolsHandler/toolStyle"; -import { buildArrowGroup } from "../../../Handlers/ToolsHandler/tools/arrow"; +import { rebuildArrow } from "../../../Handlers/ToolsHandler/tools/arrow"; import { isLabeledShape, getLabelParts, isArrow, + isElbowArrow, } from "../../../utils/shapeLabel"; import { dimColor, isDarkTheme } from "../../../utils/themeColor"; import "./PropertiesPanel.scss"; @@ -89,8 +90,10 @@ const PropertiesPanel = () => { if (!canvas) return; canvas.currentStyle = toFabricStyle(style); canvas.currentTextStyle = toTextStyle(style); - // Arrowheads aren't a fabric prop — expose it separately for the arrow tool. + // Arrowheads / arrow-type aren't fabric props — expose them separately for + // the arrow tool to read at draw time. canvas.currentArrowHeads = style.arrowHeads; + canvas.currentArrowType = style.arrowType; if (canvas.freeDrawingBrush) { canvas.freeDrawingBrush.color = style.stroke; } @@ -204,61 +207,37 @@ const PropertiesPanel = () => { applyToActive(next); }; - // Arrowheads can't be set with obj.set() — the head is a child object — so a - // selected arrow is rebuilt with the new config. Also updates the default for - // the next drawn arrow. - const setArrowHeads = (mode) => { - const next = { ...styleRef.current, arrowHeads: mode }; - styleRef.current = next; - setStyle(next); + // An arrow's heads/type live in its child structure, so they can't be set with + // obj.set() — the selected arrow is rebuilt (preserving endpoints, label and + // bindings) via rebuildArrow. Records one history entry + persistence save. + const rebuildActiveArrow = (overrides) => { if (!canvas || !isArrowObject(activeObject)) return; - const group = activeObject; - const line = group._objects.find((c) => c.type === "line"); - const textChild = group._objects.find((c) => isText(c)); - const matrix = group.calcTransformMatrix(); - const lp = line.calcLinePoints(); - const p1 = fabric.util.transformPoint( - new fabric.Point(line.left + lp.x1, line.top + lp.y1), - matrix, - ); - const p2 = fabric.util.transformPoint( - new fabric.Point(line.left + lp.x2, line.top + lp.y2), - matrix, - ); - const arrow = buildArrowGroup( - { x: p1.x, y: p1.y }, - { x: p2.x, y: p2.y }, - { - stroke: line.stroke, - strokeWidth: line.strokeWidth, - strokeDashArray: line.strokeDashArray, - heads: mode, - // Preserve a label when toggling the head style. - label: textChild - ? { - text: textChild.text, - fontFamily: textChild.fontFamily, - fontSize: textChild.fontSize, - fill: textChild.fill, - backgroundColor: textChild.backgroundColor, - } - : null, - }, - ); - + const arrow = rebuildArrow(group, overrides); const supportsHistory = typeof canvas._historySaveAction === "function"; if (supportsHistory) canvas.historyProcessing = true; canvas.remove(group); canvas.add(arrow); - arrow.setCoords(); canvas.setActiveObject(arrow); if (supportsHistory) canvas.historyProcessing = false; canvas.requestRenderAll(); - // One history entry (and a persistence save) for the head change. canvas.fire("object:modified", { target: arrow }); }; + const setArrowHeads = (mode) => { + const next = { ...styleRef.current, arrowHeads: mode }; + styleRef.current = next; + setStyle(next); + rebuildActiveArrow({ heads: mode }); + }; + + const setArrowType = (type) => { + const next = { ...styleRef.current, arrowType: type }; + styleRef.current = next; + setStyle(next); + rebuildActiveArrow({ arrowType: type }); + }; + // Reorder the selection's z-index. sendToBack/sendBackwards process in // reverse so a multi-selection keeps its internal stacking order. const reorder = (method) => { @@ -302,6 +281,13 @@ const PropertiesPanel = () => { const arrowContext = activeTool === TOOL_CONSTANTS.ARROW || selectedArrowHeads !== null; const activeHeads = selectedArrowHeads || style.arrowHeads; + // Straight vs elbow — reflects the selected arrow's own routing, else default. + const selectedArrowType = isArrowObject(activeObject) + ? isElbowArrow(activeObject) + ? "elbow" + : "straight" + : null; + const activeArrowType = selectedArrowType || style.arrowType; // An icon (imported logo) has no editable colour/width/style — it keeps its // own artwork — so the panel shows only its position/layer controls. @@ -506,6 +492,28 @@ const PropertiesPanel = () => { )} + {arrowContext && ( +
+ Arrow type +
+ {ARROW_TYPE_OPTIONS.map((opt) => ( + + ))} +
+
+ )} + {activeObject && (
Layer diff --git a/src/utils/arrowEndpoints.js b/src/utils/arrowEndpoints.js index 1276693..9d1c67a 100644 --- a/src/utils/arrowEndpoints.js +++ b/src/utils/arrowEndpoints.js @@ -24,6 +24,75 @@ import { getArrowParts } from "./shapeLabel"; const angleDeg = (a, b) => (Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI; +// The arrowhead PATH ("M 0 0 L 20 10 L 0 20 Z") has its tip vertex half its +// length ahead of the path's centre. Placing the centre AT the endpoint makes the +// visible tip overshoot by that much — and, when the end is bound to a shape, +// poke inside it. HEAD_TIP_INSET is that half-length; headCenterFor returns where +// the head's centre must sit (backed off along the incoming segment prev->tip) so +// the tip lands exactly on `tip`. +export const HEAD_TIP_INSET = 10; +export const headCenterFor = (tip, prev) => { + const dx = tip.x - prev.x; + const dy = tip.y - prev.y; + const len = Math.hypot(dx, dy); + if (!len) return { x: tip.x, y: tip.y }; + return { + x: tip.x - (dx / len) * HEAD_TIP_INSET, + y: tip.y - (dy / len) * HEAD_TIP_INSET, + }; +}; + +// The scene/group-local point of a head's visible tip vertex, from its centre +// (left/top) and angle. The logical arrow endpoint for a headed end IS this tip +// (the connector line stops short at the head centre — see applyEndpointsLocal — +// so it never poked past the head's narrowing point). +export const headTipOf = (head) => { + const a = (head.angle * Math.PI) / 180; + return { + x: head.left + HEAD_TIP_INSET * Math.cos(a), + y: head.top + HEAD_TIP_INSET * Math.sin(a), + }; +}; + +// Orthogonal (elbow) route between two points: a right-angled path. Routes along +// the dominant axis first, bending at the midpoint (a clean Z). Collapses to a +// straight segment when the points share a row/column. +export const elbowRoute = (s, e) => { + const dx = e.x - s.x; + const dy = e.y - s.y; + if (Math.abs(dx) < 1 || Math.abs(dy) < 1) + return [ + { x: s.x, y: s.y }, + { x: e.x, y: e.y }, + ]; + if (Math.abs(dx) >= Math.abs(dy)) { + const mx = s.x + dx / 2; + return [ + { x: s.x, y: s.y }, + { x: mx, y: s.y }, + { x: mx, y: e.y }, + { x: e.x, y: e.y }, + ]; + } + const my = s.y + dy / 2; + return [ + { x: s.x, y: s.y }, + { x: s.x, y: my }, + { x: e.x, y: my }, + { x: e.x, y: e.y }, + ]; +}; + +// Position an elbow polyline so its points render at their exact group-local +// coords (fabric otherwise offsets a polyline by its pathOffset). Set the route, +// recompute dimensions, then pin left/top to the new pathOffset. +export const layoutElbowPolyline = (poly, route) => { + poly.set({ points: route.map((p) => ({ x: p.x, y: p.y })) }); + poly._setPositionDimensions({}); + poly.set({ left: poly.pathOffset.x, top: poly.pathOffset.y }); + poly.setCoords(); +}; + const IDENTITY = [1, 0, 0, 1, 0, 0]; // The group's full object->screen matrix. Guards the case where the group // isn't on a canvas yet (buildArrowGroup calls setCoords() before canvas.add, @@ -35,13 +104,36 @@ const screenMatrix = (group) => ); // Endpoint positions in group-local coordinates (relative to the group centre). +// For an end that carries a head, the logical endpoint is the head's TIP vertex +// (the visible point), NOT the connector's terminal — the connector is drawn +// short of the tip so it doesn't poke through. heads[0] = tip end (e2), +// heads[1] = tail end (e1) on a double-headed arrow. const localEndpoints = (group) => { - const { line } = getArrowParts(group); - const lp = line.calcLinePoints(); - return { - e1: { x: line.left + lp.x1, y: line.top + lp.y1 }, // tail (line start) - e2: { x: line.left + lp.x2, y: line.top + lp.y2 }, // tip (line end) - }; + const { line, heads } = getArrowParts(group); + let e1; + let e2; + if (line.type === "polyline") { + // Elbow: points already live in group-local coords (see layoutElbowPolyline). + const pts = line.points; + e1 = { x: pts[0].x, y: pts[0].y }; + e2 = { x: pts[pts.length - 1].x, y: pts[pts.length - 1].y }; + } else { + const lp = line.calcLinePoints(); + e1 = { x: line.left + lp.x1, y: line.top + lp.y1 }; // tail (line start) + e2 = { x: line.left + lp.x2, y: line.top + lp.y2 }; // tip (line end) + } + if (heads[0]) e2 = headTipOf(heads[0]); + if (heads[1]) e1 = headTipOf(heads[1]); + return { e1, e2 }; +}; + +// Arrow endpoints in absolute (scene) coords — handles line or elbow polyline. +export const sceneEndpoints = (group) => { + const { e1, e2 } = localEndpoints(group); + const m = group.calcTransformMatrix(); + const toScene = (p) => + fabric.util.transformPoint(new fabric.Point(p.x, p.y), m); + return { tail: toScene(e1), tip: toScene(e2) }; }; // The single source of truth for arrow layout: rewrite the line, head(s) and @@ -50,22 +142,55 @@ const localEndpoints = (group) => { // keep rendering). All endpoint mutations funnel through here. const applyEndpointsLocal = (group, start, end) => { const { line, heads, text } = getArrowParts(group); + const elbow = line.type === "polyline"; - line.set({ x1: start.x, y1: start.y, x2: end.x, y2: end.y }); - line._setWidthHeight(); // re-derives the line's centre + bbox from the points - line.setCoords(); + // The route the head/label follow: a straight [start,end] or the elbow path. + const route = elbow ? elbowRoute(start, end) : [start, end]; - // heads[0] sits at the tip (line end); a second head (double-ended) at tail. + // heads[0] sits at the tip, aimed along the LAST segment; a second head + // (double-ended) sits at the tail, aimed along the FIRST segment (reversed). + // The head's CENTRE is backed off so its tip vertex lands exactly on the + // endpoint. The CONNECTOR then stops at that centre (not the tip), so the line + // is fully hidden under the head and never pokes past its narrowing point. + const tipPrev = route[route.length - 2]; + const headEnd = heads[0] ? headCenterFor(end, tipPrev) : null; + const headStart = heads[1] ? headCenterFor(start, route[1]) : null; if (heads[0]) { - heads[0].set({ left: end.x, top: end.y, angle: angleDeg(start, end) }); + heads[0].set({ + left: headEnd.x, + top: headEnd.y, + angle: angleDeg(tipPrev, end), + }); heads[0].setCoords(); } if (heads[1]) { - heads[1].set({ left: start.x, top: start.y, angle: angleDeg(end, start) }); + heads[1].set({ + left: headStart.x, + top: headStart.y, + angle: angleDeg(route[1], start), + }); heads[1].setCoords(); } + + // Connector route: same shape, but each headed end pulled in to the head centre. + const cStart = headStart || start; + const cEnd = headEnd || end; + if (elbow) { + const croute = route.map((p) => ({ x: p.x, y: p.y })); + croute[0] = cStart; + croute[croute.length - 1] = cEnd; + layoutElbowPolyline(line, croute); + } else { + line.set({ x1: cStart.x, y1: cStart.y, x2: cEnd.x, y2: cEnd.y }); + line._setWidthHeight(); // re-derives the line's centre + bbox from the points + line.setCoords(); + } if (text) { - text.set({ left: (start.x + end.x) / 2, top: (start.y + end.y) / 2 }); + // Straight: segment midpoint. Elbow: the middle vertex of the route. + const mid = elbow + ? route[Math.floor(route.length / 2)] + : { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 }; + text.set({ left: mid.x, top: mid.y }); text.setCoords(); } group.dirty = true; @@ -83,15 +208,20 @@ export const reshapeArrow = (group, key, local) => { }; // Re-route an arrow to new endpoints given in absolute (scene) coords, and -// re-fit its bounds. The programmatic entry point (e.g. a future bound shape -// moving) — callers just say "put the ends here" and the arrow model handles -// the rest. -export const setArrowEndpoints = (group, tailScene, tipScene) => { +// (by default) re-fit its bounds. The programmatic entry point (e.g. a bound +// shape moving) — callers just say "put the ends here". +// +// Pass refit=false while the arrow ITSELF is being dragged: refitArrowBounds +// resets the group's left/top, which fabric then clobbers with its own +// translate each frame — leaving the geometry fighting the drag. Skipping the +// refit re-positions only the children (keeping a bound end glued to its border +// as the group translates); the bounds are re-fitted once on drop. +export const setArrowEndpoints = (group, tailScene, tipScene, refit = true) => { const inv = fabric.util.invertTransform(group.calcTransformMatrix()); const toLocal = (p) => fabric.util.transformPoint(new fabric.Point(p.x, p.y), inv); applyEndpointsLocal(group, toLocal(tailScene), toLocal(tipScene)); - refitArrowBounds(group); + if (refit) refitArrowBounds(group); }; // Re-fit the group's bounding box to its (mutated) children while preserving diff --git a/src/utils/arrowEndpoints.test.js b/src/utils/arrowEndpoints.test.js index f3bddf5..e6da97d 100644 --- a/src/utils/arrowEndpoints.test.js +++ b/src/utils/arrowEndpoints.test.js @@ -3,10 +3,79 @@ import { reshapeArrow, refitArrowBounds, setArrowEndpoints, + elbowRoute, + headCenterFor, + headTipOf, } from "./arrowEndpoints"; -import { getArrowParts, isArrow } from "./shapeLabel"; +import { getArrowParts, isArrow, isElbowArrow } from "./shapeLabel"; import { buildArrowGroup } from "../Handlers/ToolsHandler/tools/arrow"; +describe("elbowRoute (orthogonal routing)", () => { + test("mostly-horizontal points bend at the mid-x (H then V then H)", () => { + const r = elbowRoute({ x: 0, y: 0 }, { x: 200, y: 60 }); + expect(r).toEqual([ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 60 }, + { x: 200, y: 60 }, + ]); + // every segment is axis-aligned (a right angle at each bend) + for (let i = 1; i < r.length; i += 1) + expect(r[i].x === r[i - 1].x || r[i].y === r[i - 1].y).toBe(true); + }); + + test("mostly-vertical points bend at the mid-y", () => { + const r = elbowRoute({ x: 0, y: 0 }, { x: 60, y: 200 }); + expect(r[1]).toEqual({ x: 0, y: 100 }); + expect(r[2]).toEqual({ x: 60, y: 100 }); + }); + + test("aligned points collapse to a straight two-point segment", () => { + expect(elbowRoute({ x: 0, y: 0 }, { x: 200, y: 0 })).toHaveLength(2); + }); +}); + +describe("headCenterFor (arrowhead sits ON the endpoint, not past it)", () => { + test("backs the centre off the tip by the head half-length, along the segment", () => { + // horizontal segment (0,0)->(100,0): centre pulled 10 left of the tip + expect(headCenterFor({ x: 100, y: 0 }, { x: 0, y: 0 })).toEqual({ + x: 90, + y: 0, + }); + }); + + test("normalises diagonal segments (offset magnitude stays the head length)", () => { + const c = headCenterFor({ x: 30, y: 40 }, { x: 0, y: 0 }); // 3-4-5 => len 50 + expect(Math.round(c.x)).toBe(24); // 30 - (30/50)*10 + expect(Math.round(c.y)).toBe(32); // 40 - (40/50)*10 + }); + + test("degenerate (tip == prev) returns the tip unchanged (no divide-by-zero)", () => { + expect(headCenterFor({ x: 5, y: 5 }, { x: 5, y: 5 })).toEqual({ + x: 5, + y: 5, + }); + }); +}); + +describe("isElbowArrow", () => { + test("true for an elbow arrow, false for a straight one", () => { + const straight = buildArrowGroup( + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { stroke: "#000", strokeWidth: 2, arrowType: "straight" }, + ); + const elbow = buildArrowGroup( + { x: 0, y: 0 }, + { x: 200, y: 120 }, + { stroke: "#000", strokeWidth: 2, arrowType: "elbow" }, + ); + expect(isElbowArrow(straight)).toBe(false); + expect(isElbowArrow(elbow)).toBe(true); + expect(isArrow(elbow)).toBe(true); // an elbow is still an arrow + }); +}); + const arrow = (label) => buildArrowGroup( { x: 0, y: 0 }, @@ -14,16 +83,39 @@ const arrow = (label) => { stroke: "#000", strokeWidth: 2, ...(label ? { label } : {}) }, ); -// Endpoint positions in group-local coords (relative to the group centre). +// Endpoint positions in group-local coords (relative to the group centre). The +// logical endpoint of a headed end is the head's TIP (the connector line stops +// short at the head centre), mirroring the module's own localEndpoints. const localEnds = (a) => { - const { line } = getArrowParts(a); + const { line, heads } = getArrowParts(a); const lp = line.calcLinePoints(); - return { - e1: { x: line.left + lp.x1, y: line.top + lp.y1 }, - e2: { x: line.left + lp.x2, y: line.top + lp.y2 }, - }; + const e1 = heads[1] + ? headTipOf(heads[1]) + : { x: line.left + lp.x1, y: line.top + lp.y1 }; + const e2 = heads[0] + ? headTipOf(heads[0]) + : { x: line.left + lp.x2, y: line.top + lp.y2 }; + return { e1, e2 }; }; +describe("connector stops short of the head tip (no line poking past it)", () => { + test("the line's terminal is HEAD_TIP_INSET behind the logical tip", () => { + const a = arrow(); + reshapeArrow(a, "e2", { x: 100, y: 0 }); + const { line, heads } = getArrowParts(a); + const lp = line.calcLinePoints(); + const lineEnd = { x: line.left + lp.x2, y: line.top + lp.y2 }; + const tip = headTipOf(heads[0]); + // logical tip is the head's point; the drawn line ends ~10 units before it + expect(Math.round(tip.x)).toBe(100); + expect(Math.round(Math.hypot(tip.x - lineEnd.x, tip.y - lineEnd.y))).toBe( + 10, + ); + // and the line never reaches (let alone passes) the tip + expect(lineEnd.x).toBeLessThan(tip.x); + }); +}); + describe("reshapeArrow", () => { test("moves the dragged endpoint, keeps the other, follows head + label", () => { const a = arrow({ text: "x", fontSize: 16 }); @@ -40,9 +132,11 @@ describe("reshapeArrow", () => { expect(Math.round(after.e1.y)).toBe(Math.round(before.e1.y)); const { heads, text } = getArrowParts(a); - // single head sits at the tip - expect(Math.round(heads[0].left)).toBe(Math.round(target.x)); - expect(Math.round(heads[0].top)).toBe(Math.round(target.y)); + // single head is backed off the tip so its VISIBLE point lands on the tip + // (its centre is HEAD_TIP_INSET back along the tail->tip segment). + const cen = headCenterFor(target, after.e1); + expect(Math.round(heads[0].left)).toBe(Math.round(cen.x)); + expect(Math.round(heads[0].top)).toBe(Math.round(cen.y)); // label re-centres on the new midpoint expect(Math.round(text.left)).toBe(Math.round((after.e1.x + target.x) / 2)); expect(Math.round(text.top)).toBe(Math.round((after.e1.y + target.y) / 2)); @@ -72,11 +166,9 @@ describe("refitArrowBounds", () => { c.add(a); const P = fabric.Point; const abs = (k) => { - const { line } = getArrowParts(a); - const lp = line.calcLinePoints(); - const off = k === "e1" ? { x: lp.x1, y: lp.y1 } : { x: lp.x2, y: lp.y2 }; + const p = localEnds(a)[k]; return fabric.util.transformPoint( - new P(line.left + off.x, line.top + off.y), + new P(p.x, p.y), a.calcTransformMatrix(), ); }; @@ -107,11 +199,9 @@ describe("setArrowEndpoints (programmatic re-route)", () => { const P = fabric.Point; const abs = (k) => { - const { line } = getArrowParts(a); - const lp = line.calcLinePoints(); - const off = k === "e1" ? { x: lp.x1, y: lp.y1 } : { x: lp.x2, y: lp.y2 }; + const p = localEnds(a)[k]; return fabric.util.transformPoint( - new P(line.left + off.x, line.top + off.y), + new P(p.x, p.y), a.calcTransformMatrix(), ); }; @@ -123,4 +213,25 @@ describe("setArrowEndpoints (programmatic re-route)", () => { expect(Math.round(tip.y)).toBe(180); expect(isArrow(a)).toBe(true); }); + + test("refit=false still positions the endpoints (live drag path)", () => { + const c = new fabric.Canvas(document.createElement("canvas")); + const a = arrow(); + c.add(a); + + setArrowEndpoints(a, { x: 60, y: 60 }, { x: 260, y: 200 }, false); + + const P = fabric.Point; + const abs = (k) => { + const p = localEnds(a)[k]; + return fabric.util.transformPoint( + new P(p.x, p.y), + a.calcTransformMatrix(), + ); + }; + const tip = abs("e2"); + expect(Math.round(tip.x)).toBe(260); + expect(Math.round(tip.y)).toBe(200); + expect(isArrow(a)).toBe(true); + }); }); diff --git a/src/utils/binding.js b/src/utils/binding.js index 846d34d..98abae3 100644 --- a/src/utils/binding.js +++ b/src/utils/binding.js @@ -1,6 +1,6 @@ import { fabric } from "fabric"; -import { isArrow, getArrowParts } from "./shapeLabel"; -import { setArrowEndpoints } from "./arrowEndpoints"; +import { isArrow } from "./shapeLabel"; +import { setArrowEndpoints, sceneEndpoints } from "./arrowEndpoints"; // Arrow <-> shape binding (eraser.io style). An arrow endpoint can be "bound" to // a shape by that shape's stable id; when the shape moves or resizes we re-route @@ -31,16 +31,47 @@ const NON_BINDABLE = new Set(["line", "i-text", "text", "textbox"]); export const isBindable = (o) => !!o && !o.__nonBindable && !isArrow(o) && !NON_BINDABLE.has(o.type); -// How far outside a shape an arrow endpoint can land and still bind. People draw -// arrows TO a box's edge (or just past it), not deep inside — without this slack -// the endpoint misses the bbox and nothing binds. The bound end then snaps to -// the border anyway (rerouteArrow), so a little generosity here is free. -export const BIND_MARGIN = 24; +// How far outside a shape an arrow endpoint can land and still bind — a small +// "on the border" tolerance so dropping right at the edge binds, without +// engaging (and highlighting) while the endpoint is still well away from it. +export const BIND_MARGIN = 8; // --- geometry ------------------------------------------------------------- // The shape's absolute (scene) axis-aligned bounding box. Good enough for v1 // across rect/ellipse/diamond/polygon and labelled groups. -const sceneBBox = (shape) => shape.getBoundingRect(true, true); +// +// While a shape is part of an activeSelection (a multi-select drag), fabric v5's +// getBoundingRect(true) returns coords RELATIVE to the selection — the shape's +// own transform matrix doesn't include the parent group. Transform that bbox by +// the group's matrix to recover absolute scene coords, so an arrow bound to a +// shape that is being dragged inside a selection re-routes to the right place. +const sceneBBox = (shape) => { + const b = shape.getBoundingRect(true, true); + if (!shape.group) return b; + const m = shape.group.calcTransformMatrix(); + const tl = fabric.util.transformPoint(new fabric.Point(b.left, b.top), m); + const br = fabric.util.transformPoint( + new fabric.Point(b.left + b.width, b.top + b.height), + m, + ); + return { + left: Math.min(tl.x, br.x), + top: Math.min(tl.y, br.y), + width: Math.abs(br.x - tl.x), + height: Math.abs(br.y - tl.y), + }; +}; + +// The shape's centre in ABSOLUTE scene coords. Derived from the (absolute) bbox +// rather than getCenterPoint() on purpose: for a shape that is momentarily part +// of an activeSelection, getCenterPoint() returns coords relative to the +// selection, which would mis-place a re-routed arrow while the group is dragged. +// The bbox centre equals the geometric centre for our shapes (bbox is symmetric +// about it, even when rotated). +const sceneCenter = (shape) => { + const b = sceneBBox(shape); + return { x: b.left + b.width / 2, y: b.top + b.height / 2 }; +}; const bboxContainsPoint = (shape, p, margin = 0) => { const b = sceneBBox(shape); @@ -52,15 +83,59 @@ const bboxContainsPoint = (shape, p, margin = 0) => { ); }; +// A polygon's vertices in ABSOLUTE scene coords (handles a shape momentarily +// inside an activeSelection by folding in the group's matrix — see sceneBBox). +const scenePolygonPoints = (poly) => { + const own = poly.calcTransformMatrix(); + const m = poly.group + ? fabric.util.multiplyTransformMatrices( + poly.group.calcTransformMatrix(), + own, + ) + : own; + const off = poly.pathOffset || { x: 0, y: 0 }; + return poly.points.map((p) => + fabric.util.transformPoint(new fabric.Point(p.x - off.x, p.y - off.y), m), + ); +}; + +// Where the ray from `c` toward `t` first crosses the polygon outline (nearest +// forward edge intersection), or null if it somehow misses. Lets an arrow touch +// a diamond/hexagon's actual slanted edge instead of its bounding-box corner. +const rayPolygonBorder = (verts, c, t) => { + const dx = t.x - c.x; + const dy = t.y - c.y; + let best = null; + for (let i = 0; i < verts.length; i += 1) { + const a = verts[i]; + const b = verts[(i + 1) % verts.length]; + const ex = b.x - a.x; + const ey = b.y - a.y; + const den = ex * dy - dx * ey; + if (Math.abs(den) < 1e-9) continue; // ray parallel to this edge + const rt = (ex * (a.y - c.y) - ey * (a.x - c.x)) / den; // dist along the ray + const u = (dx * (a.y - c.y) - dy * (a.x - c.x)) / den; // pos along the edge + if (rt > 1e-6 && u >= -1e-6 && u <= 1 + 1e-6 && (!best || rt < best.rt)) { + best = { rt, x: c.x + dx * rt, y: c.y + dy * rt }; + } + } + return best ? { x: best.x, y: best.y } : null; +}; + // Point on the shape's border along the ray from its centre toward `toward`, so // the arrow touches the actual outline (not the middle). Ellipses/circles use a -// true ray-ellipse intersection; everything else clips the ray to the bounding -// box (exact for rectangles; a close approximation for diamonds/polygons/icons). +// true ray-ellipse intersection; polygons (diamond/hexagon) use a ray-polygon +// intersection against their real edges; everything else clips the ray to the +// bounding box (exact for rectangles; a close approximation for icons/groups). export const borderPoint = (shape, toward) => { - const c = shape.getCenterPoint(); + const c = sceneCenter(shape); const dx = toward.x - c.x; const dy = toward.y - c.y; if (dx === 0 && dy === 0) return { x: c.x, y: c.y }; + if (shape.type === "polygon" && Array.isArray(shape.points)) { + const hit = rayPolygonBorder(scenePolygonPoints(shape), c, toward); + if (hit) return hit; + } const b = sceneBBox(shape); const hw = b.width / 2; const hh = b.height / 2; @@ -84,7 +159,7 @@ export const borderPoint = (shape, toward) => { const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); const anchorOf = (shape, scenePoint) => { - const c = shape.getCenterPoint(); + const c = sceneCenter(shape); const b = sceneBBox(shape); return { fx: clamp((scenePoint.x - c.x) / (b.width / 2 || 1), -1, 1), @@ -94,7 +169,7 @@ const anchorOf = (shape, scenePoint) => { // The scene point an anchor currently resolves to (centre + fraction*half). const anchorTarget = (shape, anchor) => { - const c = shape.getCenterPoint(); + const c = sceneCenter(shape); const b = sceneBBox(shape); return { x: c.x + anchor.fx * (b.width / 2), @@ -114,18 +189,9 @@ export const boundArrows = (canvas, shapeId) => isArrow(o) && (o.startBinding === shapeId || o.endBinding === shapeId), ); -// Current arrow endpoints in scene coords (tail = line start, tip = line end). -const arrowEndpointsScene = (arrow) => { - const { line } = getArrowParts(arrow); - const lp = line.calcLinePoints(); - const m = arrow.calcTransformMatrix(); - const toScene = (x, y) => - fabric.util.transformPoint( - new fabric.Point(line.left + x, line.top + y), - m, - ); - return { tail: toScene(lp.x1, lp.y1), tip: toScene(lp.x2, lp.y2) }; -}; +// Current arrow endpoints in scene coords (delegates to the arrow-layout helper, +// which handles both a straight line and an elbow polyline connector). +const arrowEndpointsScene = (arrow) => sceneEndpoints(arrow); // Scene position of one arrow end ("start" = tail, "end" = tip). export const arrowEndScene = (arrow, end) => { @@ -137,8 +203,9 @@ export const arrowEndScene = (arrow, end) => { // Recompute one arrow's endpoints from its bindings. A bound end anchors to its // shape's border (facing the other end); an unbound end keeps its position. A // binding whose shape no longer exists is treated as unbound. Returns true if a -// bound end was re-routed. -export const rerouteArrow = (canvas, arrow) => { +// bound end was re-routed. Pass refit=false while the arrow itself is being +// dragged (see setArrowEndpoints) — the bounds are re-fitted once on drop. +export const rerouteArrow = (canvas, arrow, refit = true) => { const startShape = shapeById(canvas, arrow.startBinding); const endShape = shapeById(canvas, arrow.endBinding); if (!startShape && !endShape) return false; @@ -153,21 +220,21 @@ export const rerouteArrow = (canvas, arrow) => { ? meaningful(arrow.startAnchor) ? anchorTarget(startShape, arrow.startAnchor) : endShape - ? endShape.getCenterPoint() + ? sceneCenter(endShape) : ends.tip : null; const endAim = endShape ? meaningful(arrow.endAnchor) ? anchorTarget(endShape, arrow.endAnchor) : startShape - ? startShape.getCenterPoint() + ? sceneCenter(startShape) : ends.tail : null; const tail = startShape ? borderPoint(startShape, startAim) : ends.tail; const tip = endShape ? borderPoint(endShape, endAim) : ends.tip; - setArrowEndpoints(arrow, tail, tip); + setArrowEndpoints(arrow, tail, tip, refit); return true; }; diff --git a/src/utils/binding.test.js b/src/utils/binding.test.js index b29f614..a83572b 100644 --- a/src/utils/binding.test.js +++ b/src/utils/binding.test.js @@ -13,7 +13,6 @@ import { enableBindingPersistence, BIND_MARGIN, } from "./binding"; -import { getArrowParts } from "./shapeLabel"; import { buildArrowGroup } from "../Handlers/ToolsHandler/tools/arrow"; const makeCanvas = () => new fabric.Canvas(document.createElement("canvas")); @@ -31,18 +30,12 @@ const rect = (o = {}) => const arrow = (start, end) => buildArrowGroup(start, end, { stroke: "#111", strokeWidth: 2, heads: "end" }); -// Tail/tip of an arrow in scene coords (mirrors the module's internal helper). -const ends = (a) => { - const { line } = getArrowParts(a); - const lp = line.calcLinePoints(); - const m = a.calcTransformMatrix(); - const p = (x, y) => - fabric.util.transformPoint( - new fabric.Point(line.left + x, line.top + y), - m, - ); - return { tail: p(lp.x1, lp.y1), tip: p(lp.x2, lp.y2) }; -}; +// Tail/tip of an arrow in scene coords — the module's own logical endpoints +// (the head TIP for a headed end, not the shortened connector terminal). +const ends = (a) => ({ + tail: arrowEndScene(a, "start"), + tip: arrowEndScene(a, "end"), +}); describe("ensureId", () => { test("assigns a stable id and is idempotent", () => { @@ -94,6 +87,29 @@ describe("borderPoint", () => { const on = ((p.x - 100) / 50) ** 2 + ((p.y - 100) / 30) ** 2; expect(on).toBeCloseTo(1, 2); }); + + test("a diamond (polygon) lands on its slanted edge, not the bbox corner", () => { + // a diamond with vertices 50 out along each axis (centre at the local origin) + const d = new fabric.Polygon( + [ + { x: 0, y: -50 }, + { x: 50, y: 0 }, + { x: 0, y: 50 }, + { x: -50, y: 0 }, + ], + { left: 50, top: 50, strokeWidth: 0 }, + ); + const c = new fabric.Canvas(document.createElement("canvas")); + c.add(d); + const bb = d.getBoundingRect(true, true); + const cx = bb.left + bb.width / 2; + const cy = bb.top + bb.height / 2; + // aim toward the bottom-right: the bbox clip would give the corner (taxi 100), + // the real edge is the line |dx|+|dy| = 50 from the centre. + const p = borderPoint(d, { x: cx + 300, y: cy + 300 }); + expect(Math.round(Math.abs(p.x - cx) + Math.abs(p.y - cy))).toBe(50); + expect(p.x).toBeLessThan(bb.left + bb.width - 1); // not the bbox corner + }); }); describe("shapeUnderPoint", () => { diff --git a/src/utils/bindingHighlight.js b/src/utils/bindingHighlight.js index 404ef39..d5f742d 100644 --- a/src/utils/bindingHighlight.js +++ b/src/utils/bindingHighlight.js @@ -6,29 +6,44 @@ import { fabric } from "fabric"; // (excludeFromExport) and flagged __nonBindable so they can't target themselves. // Kept on canvas.__bindHL; cleared on drop. -// A thin accent outline that hugs the shape's border (eraser.io-style hover -// affordance) — no fill, just the border "lighting up". +// An additional border layer drawn ON the target's exact outline (no padding), +// so the shape's own border "lights up" (Excalidraw-style). Shape-aware: an +// ellipse target gets an ellipse outline; a rounded rect matches its radius. const STROKE = "#6965db"; // clean violet-indigo accent -const PAD = 2; -const makeRect = (shape) => { - const b = shape.getBoundingRect(true, true); +const overlayProps = { + fill: "transparent", + stroke: STROKE, + strokeWidth: 2, + selectable: false, + evented: false, + excludeFromExport: true, + __nonBindable: true, + originX: "left", + originY: "top", +}; + +const makeOverlay = (shape) => { + const b = shape.getBoundingRect(true, true); // exact scene bbox (incl. stroke) + if (shape.type === "ellipse" || shape.type === "circle") { + return new fabric.Ellipse({ + ...overlayProps, + left: b.left, + top: b.top, + rx: b.width / 2, + ry: b.height / 2, + }); + } + // Match the shape's rounded corners when it has them (rect); else hug tight. + const rx = shape.rx ? shape.rx * Math.abs(shape.scaleX || 1) : 6; return new fabric.Rect({ - left: b.left - PAD, - top: b.top - PAD, - width: b.width + PAD * 2, - height: b.height + PAD * 2, - rx: 12, - ry: 12, - fill: "transparent", - stroke: STROKE, - strokeWidth: 2, - selectable: false, - evented: false, - excludeFromExport: true, - __nonBindable: true, - originX: "left", - originY: "top", + ...overlayProps, + left: b.left, + top: b.top, + width: b.width, + height: b.height, + rx, + ry: rx, }); }; @@ -45,7 +60,7 @@ export const showBindHighlight = (canvas, shapes) => { clearBindHighlight(canvas); if (!list.length) return; const rects = list.map((s) => { - const r = makeRect(s); + const r = makeOverlay(s); canvas.add(r); canvas.bringToFront(r); return r; diff --git a/src/utils/shapeLabel.js b/src/utils/shapeLabel.js index ec99694..be3f7ee 100644 --- a/src/utils/shapeLabel.js +++ b/src/utils/shapeLabel.js @@ -24,13 +24,17 @@ export const getLabelParts = (group) => ({ text: group._objects.find((c) => TEXT_TYPES.has(c.type)), }); -// An arrow is a group of one line + one or two heads (paths), and now an +// The connector child is a straight `line` OR (for an elbow arrow) an +// orthogonally-routed `polyline`. Detected structurally either way. +const isConnector = (c) => c.type === "line" || c.type === "polyline"; + +// An arrow is a group of one connector + one or two heads (paths), and an // OPTIONAL text label. Detected structurally (single source of truth, imported // by the resize handler and properties panel) so an arrow stays an arrow whether // or not it carries a label, and survives undo/reload without custom props. export const isArrow = (o) => { if (!o || o.type !== "group" || !o._objects) return false; - const lines = o._objects.filter((c) => c.type === "line"); + const lines = o._objects.filter(isConnector); const heads = o._objects.filter((c) => c.type === "path"); const texts = o._objects.filter((c) => TEXT_TYPES.has(c.type)); return ( @@ -43,11 +47,15 @@ export const isArrow = (o) => { }; export const getArrowParts = (group) => ({ - line: group._objects.find((c) => c.type === "line"), + line: group._objects.find(isConnector), heads: group._objects.filter((c) => c.type === "path"), text: group._objects.find((c) => TEXT_TYPES.has(c.type)) || null, }); +// True when the arrow's connector is an elbow (orthogonal polyline). +export const isElbowArrow = (o) => + isArrow(o) && getArrowParts(o).line.type === "polyline"; + // The board's current background colour, used to mask the arrow line behind a // label (excalidraw-style) so the line doesn't strike through the text. Falls // back to white when unavailable (e.g. jsdom in tests).