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).