Skip to content
56 changes: 51 additions & 5 deletions src/Handlers/EventHandlers/arrowBindingHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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);
Expand Down
65 changes: 5 additions & 60 deletions src/Handlers/EventHandlers/arrowResizeHandler.js
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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();
Expand Down
7 changes: 7 additions & 0 deletions src/Handlers/ToolsHandler/toolStyle.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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",
Expand Down
105 changes: 96 additions & 9 deletions src/Handlers/ToolsHandler/tools/arrow.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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-
Expand Down Expand Up @@ -90,13 +112,59 @@ 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).
attachEndpointControls(group);
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();
Expand All @@ -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).
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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);
Expand Down
Loading