Skip to content
Merged
78 changes: 78 additions & 0 deletions src/Handlers/EventHandlers/arrowBindingHandler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { useEffect } from "react";
import { useCanvasContext } from "../../hooks";
import { isArrow } from "../../utils/shapeLabel";
import {
boundArrows,
rerouteArrow,
shapeUnderPoint,
arrowEndScene,
bindEnd,
unbindEnd,
} from "../../utils/binding";
import {
showBindHighlight,
clearBindHighlight,
} from "../../utils/bindingHighlight";

// Keeps bound arrows glued to their shapes and drives endpoint (re)binding.
function ArrowBindingHandler() {
const { canvas } = useCanvasContext();

useEffect(() => {
if (!canvas) return undefined;

// A shape 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.
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;
arrows.forEach((a) => rerouteArrow(canvas, a));
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) => {
const { arrow, end } = opt || {};
if (!arrow) return;
const p = arrowEndScene(arrow, end);
showBindHighlight(canvas, shapeUnderPoint(canvas, p, [arrow]));
};

// Dropping an arrow endpoint: bind it if over a shape, else unbind it.
const onEndpointUp = (opt) => {
const { arrow, end } = opt || {};
if (!arrow) return;
clearBindHighlight(canvas);
const p = arrowEndScene(arrow, end);
const target = shapeUnderPoint(canvas, p, [arrow]);
if (target) bindEnd(arrow, end, target, p);
else unbindEnd(arrow, end);
rerouteArrow(canvas, arrow);
canvas.requestRenderAll();
};

// Catch-all: any mouse release clears a stray highlight, so it can never
// get stuck (e.g. if an endpoint drag ends without its control's mouse-up).
const onMouseUp = () => clearBindHighlight(canvas);

canvas.on("object:moving", onShapeChange);
canvas.on("object:scaling", onShapeChange);
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:scaling", onShapeChange);
canvas.off("arrow:endpoint:moving", onEndpointMoving);
canvas.off("arrow:endpoint:up", onEndpointUp);
canvas.off("mouse:up", onMouseUp);
};
}, [canvas]);
}

export default ArrowBindingHandler;
1 change: 1 addition & 0 deletions src/Handlers/EventHandlers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ export { default as DarkColorHandler } from "./darkColorHandler";
export { default as ZoomHandler } from "./zoomHandler";
export { default as PersistenceHandler } from "./persistenceHandler";
export { default as ArrowResizeHandler } from "./arrowResizeHandler";
export { default as ArrowBindingHandler } from "./arrowBindingHandler";
4 changes: 2 additions & 2 deletions src/Handlers/ToolsHandler/toolStyle.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ export const FONT_SIZES = [

export const STROKE_WIDTHS = [
{ label: "Thin", value: 2 },
{ label: "Medium", value: 4 },
{ label: "Bold", value: 8 },
{ label: "Medium", value: 3 },
{ label: "Bold", value: 6 },
];

export const STROKE_STYLES = ["solid", "dashed", "dotted"];
Expand Down
24 changes: 24 additions & 0 deletions src/Handlers/ToolsHandler/tools/arrow.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import { fabric } from "fabric";
import { Tool } from "../toolGeneric";
import { resolveToolStyle } from "../toolStyle";
import { attachEndpointControls } from "../../../utils/arrowEndpoints";
import { bindArrowOnDraw, shapeUnderPoint } from "../../../utils/binding";
import {
showBindHighlight,
clearBindHighlight,
} from "../../../utils/bindingHighlight";

const ARROW_HEAD_PATH = "M 0 0 L 20 10 L 0 20 Z";

Expand Down Expand Up @@ -161,9 +166,20 @@ export class Arrow extends Tool {
this.arrowHeadStart.angle = angleBetween(this.pointer, origin);
this.arrowHeadStart.setCoords();
}
// Live binding affordance: highlight BOTH the source (start) shape and the
// shape the moving endpoint is over, excluding the transient preview objects.
const preview = [this.line, this.arrowHead, this.arrowHeadStart];
const src = shapeUnderPoint(
canvas,
{ x: this.origX, y: this.origY },
preview,
);
const dst = shapeUnderPoint(canvas, this.pointer, preview);
showBindHighlight(canvas, [src, dst]);
}

done(canvas) {
clearBindHighlight(canvas);
// Use the arrow's actual length, not just its horizontal extent — a
// vertical/steep arrow has a small x-delta and was being discarded as if
// it were a stray click, so it never appeared.
Expand All @@ -183,5 +199,13 @@ export class Arrow extends Tool {
);
arrow.setCoords();
canvas.add(arrow);
// Bind either end that was dropped on a shape, and snap it to the border so
// the arrow stays glued when that shape later moves (eraser.io style).
bindArrowOnDraw(
canvas,
arrow,
{ x: this.origX, y: this.origY },
{ x: this.pointer.x, y: this.pointer.y },
);
}
}
7 changes: 7 additions & 0 deletions src/components/Canvas/Canvas.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { useEffect, useRef } from "react";
import { fabric } from "fabric";
import "fabric-history";
import { useCanvasContext } from "../../hooks/";
import { enableBindingPersistence } from "../../utils/binding";
import {
MouseHandler,
KeyBoardHandler,
Expand All @@ -12,9 +13,14 @@ import {
ZoomHandler,
PersistenceHandler,
ArrowResizeHandler,
ArrowBindingHandler,
DarkColorHandler,
} from "../../Handlers/EventHandlers";

// Persist arrow<->shape binding fields (id/startBinding/endBinding) on every
// object so bindings survive reload and undo/redo. Once, at module load.
enableBindingPersistence();

function Canvas() {
MouseHandler();
KeyBoardHandler();
Expand All @@ -27,6 +33,7 @@ function Canvas() {
ZoomHandler();
PersistenceHandler();
ArrowResizeHandler();
ArrowBindingHandler();
DarkColorHandler();
const canvasRef = useRef(null);
const { setCanvas } = useCanvasContext();
Expand Down
18 changes: 17 additions & 1 deletion src/utils/arrowEndpoints.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,12 +118,21 @@ const positionHandler = (key) =>
);
};

// e1 = tail = the arrow's "start" end; e2 = tip = the "end" end.
const endOf = (key) => (key === "e1" ? "start" : "end");

const actionHandler = (key) =>
function (eventData, transform, x, y) {
const group = transform.target;
const inv = fabric.util.invertTransform(screenMatrix(group));
const local = fabric.util.transformPoint(new fabric.Point(x, y), inv);
reshapeArrow(group, key, local);
// Let binding react live (highlight the shape under the dragged endpoint).
if (group.canvas)
group.canvas.fire("arrow:endpoint:moving", {
arrow: group,
end: endOf(key),
});
return true;
};

Expand All @@ -148,7 +157,14 @@ const makeControl = (key) =>
cursorStyle: "pointer",
render: renderHandle,
mouseUpHandler: (eventData, transform) => {
refitArrowBounds(transform.target);
const group = transform.target;
refitArrowBounds(group);
// Commit binding: bind if dropped on a shape, unbind if in empty space.
if (group.canvas)
group.canvas.fire("arrow:endpoint:up", {
arrow: group,
end: endOf(key),
});
return true;
},
});
Expand Down
Loading
Loading