From c5ac155a363bc1b3055abf56660071ae2378e402 Mon Sep 17 00:00:00 2001 From: Amark19 Date: Mon, 17 Aug 2026 16:38:02 +0530 Subject: [PATCH 1/8] feat: arrow <-> shape binding (arrows follow boxes when moved) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Draw an arrow so an end lands on a shape and that end binds to the shape; moving or resizing the shape re-routes the arrow so its bound end stays glued to the shape's border, facing the other end (eraser.io style). - utils/binding.js: stable persisted ids (ensureId), border-anchor geometry (borderPoint clips the centre->target ray to the shape's bbox edge), bind-on-draw (bindArrowOnDraw), and rerouteArrow — all funnelling through the existing setArrowEndpoints layout seam. enableBindingPersistence() serialises id/startBinding/endBinding on toObject so bindings survive reload and undo/redo. - arrowBindingHandler: on object:moving/scaling of a bound shape, re-route its arrows live (single-shape for v1; drop snapshot stays consistent). - arrow tool done(): bind whichever end was dropped on a shape. - binding.test.js (11 tests) + wired into Canvas. Verified in-browser: draw A->B, move/resize either box, arrow follows and stays anchored to the border; bindings persist across reload. Co-Authored-By: Claude Opus 4.8 --- .../EventHandlers/arrowBindingHandler.js | 37 ++++ src/Handlers/EventHandlers/index.js | 1 + src/Handlers/ToolsHandler/tools/arrow.js | 9 + src/components/Canvas/Canvas.jsx | 7 + src/utils/binding.js | 155 ++++++++++++++++ src/utils/binding.test.js | 170 ++++++++++++++++++ 6 files changed, 379 insertions(+) create mode 100644 src/Handlers/EventHandlers/arrowBindingHandler.js create mode 100644 src/utils/binding.js create mode 100644 src/utils/binding.test.js diff --git a/src/Handlers/EventHandlers/arrowBindingHandler.js b/src/Handlers/EventHandlers/arrowBindingHandler.js new file mode 100644 index 0000000..7146271 --- /dev/null +++ b/src/Handlers/EventHandlers/arrowBindingHandler.js @@ -0,0 +1,37 @@ +import { useEffect } from "react"; +import { useCanvasContext } from "../../hooks"; +import { isArrow } from "../../utils/shapeLabel"; +import { boundArrows, rerouteArrow } from "../../utils/binding"; + +// Keep bound arrows glued to their shapes: when a shape with bound arrows is +// dragged or resized, re-route those arrows live to the shape's border. We react +// to object:moving / object:scaling (not object:modified) so the re-routing is +// live during the gesture AND the final positions are already in place when +// fabric-history snapshots on drop — so undo restores shape + arrows together +// without extra history plumbing. +function ArrowBindingHandler() { + const { canvas } = useCanvasContext(); + + useEffect(() => { + if (!canvas) return undefined; + + const onShapeChange = (opt) => { + const t = opt && opt.target; + // Single bound shape only for v1 — multi-select re-route is a follow-up. + 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(); + }; + + canvas.on("object:moving", onShapeChange); + canvas.on("object:scaling", onShapeChange); + return () => { + canvas.off("object:moving", onShapeChange); + canvas.off("object:scaling", onShapeChange); + }; + }, [canvas]); +} + +export default ArrowBindingHandler; diff --git a/src/Handlers/EventHandlers/index.js b/src/Handlers/EventHandlers/index.js index a88dd2f..573db58 100644 --- a/src/Handlers/EventHandlers/index.js +++ b/src/Handlers/EventHandlers/index.js @@ -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"; diff --git a/src/Handlers/ToolsHandler/tools/arrow.js b/src/Handlers/ToolsHandler/tools/arrow.js index 688c593..c2cb0de 100644 --- a/src/Handlers/ToolsHandler/tools/arrow.js +++ b/src/Handlers/ToolsHandler/tools/arrow.js @@ -2,6 +2,7 @@ import { fabric } from "fabric"; import { Tool } from "../toolGeneric"; import { resolveToolStyle } from "../toolStyle"; import { attachEndpointControls } from "../../../utils/arrowEndpoints"; +import { bindArrowOnDraw } from "../../../utils/binding"; const ARROW_HEAD_PATH = "M 0 0 L 20 10 L 0 20 Z"; @@ -183,5 +184,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 }, + ); } } diff --git a/src/components/Canvas/Canvas.jsx b/src/components/Canvas/Canvas.jsx index b60bd4f..000abf8 100644 --- a/src/components/Canvas/Canvas.jsx +++ b/src/components/Canvas/Canvas.jsx @@ -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, @@ -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(); @@ -27,6 +33,7 @@ function Canvas() { ZoomHandler(); PersistenceHandler(); ArrowResizeHandler(); + ArrowBindingHandler(); DarkColorHandler(); const canvasRef = useRef(null); const { setCanvas } = useCanvasContext(); diff --git a/src/utils/binding.js b/src/utils/binding.js new file mode 100644 index 0000000..1734c1d --- /dev/null +++ b/src/utils/binding.js @@ -0,0 +1,155 @@ +import { fabric } from "fabric"; +import { isArrow, getArrowParts } from "./shapeLabel"; +import { setArrowEndpoints } 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 +// the arrow so the bound end stays glued to the shape's border, facing the other +// end. The re-route funnels through setArrowEndpoints (utils/arrowEndpoints), the +// single arrow-layout entry point. +// +// Bindings live as three persisted fields (see enableBindingPersistence): +// shape.id — stable identity for a binding target +// arrow.startBinding / .endBinding — the bound shape's id (or undefined) + +// --- stable ids ----------------------------------------------------------- +let counter = 0; +const genId = () => + typeof crypto !== "undefined" && crypto.randomUUID + ? crypto.randomUUID() + : `wb-${Math.random().toString(36).slice(2)}-${(counter += 1)}`; + +export const ensureId = (obj) => { + if (!obj.id) obj.id = genId(); + return obj.id; +}; + +// A shape that can be a binding target: anything that isn't an arrow, a bare +// line, or loose text. Rects/ellipses/diamonds/polygons, labelled shapes and +// imported icons (groups) all qualify. +const NON_BINDABLE = new Set(["line", "i-text", "text", "textbox"]); +export const isBindable = (o) => + !!o && !isArrow(o) && !NON_BINDABLE.has(o.type); + +// --- 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); + +const bboxContainsPoint = (shape, p) => { + const b = sceneBBox(shape); + return ( + p.x >= b.left && + p.x <= b.left + b.width && + p.y >= b.top && + p.y <= b.top + b.height + ); +}; + +// Point on the shape's border along the ray from its centre toward `toward`. +// Clips the direction vector to the bounding box so the arrow touches the edge +// rather than burying its head in the shape's middle. +export const borderPoint = (shape, toward) => { + const c = shape.getCenterPoint(); + const dx = toward.x - c.x; + const dy = toward.y - c.y; + if (dx === 0 && dy === 0) return { x: c.x, y: c.y }; + const b = sceneBBox(shape); + const hw = b.width / 2; + const hh = b.height / 2; + const t = Math.min( + dx === 0 ? Infinity : hw / Math.abs(dx), + dy === 0 ? Infinity : hh / Math.abs(dy), + ); + return { x: c.x + dx * t, y: c.y + dy * t }; +}; + +// --- lookups -------------------------------------------------------------- +const shapeById = (canvas, id) => + id ? canvas.getObjects().find((o) => o.id === id) || null : null; + +export const boundArrows = (canvas, shapeId) => + canvas + .getObjects() + .filter( + (o) => + 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) }; +}; + +// --- re-route ------------------------------------------------------------- +// 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) => { + const startShape = shapeById(canvas, arrow.startBinding); + const endShape = shapeById(canvas, arrow.endBinding); + if (!startShape && !endShape) return false; + + const ends = arrowEndpointsScene(arrow); + const startAim = endShape ? endShape.getCenterPoint() : ends.tip; + const endAim = startShape ? startShape.getCenterPoint() : ends.tail; + + const tail = startShape ? borderPoint(startShape, startAim) : ends.tail; + const tip = endShape ? borderPoint(endShape, endAim) : ends.tip; + + setArrowEndpoints(arrow, tail, tip); + return true; +}; + +// --- bind on draw --------------------------------------------------------- +// Topmost bindable shape whose bbox contains `point` (scene coords), skipping +// the arrow being drawn. +export const shapeUnderPoint = (canvas, point, exclude) => { + const objs = canvas.getObjects(); + for (let i = objs.length - 1; i >= 0; i -= 1) { + const o = objs[i]; + if (o === exclude || !isBindable(o)) continue; + if (bboxContainsPoint(o, point)) return o; + } + return null; +}; + +// After an arrow is drawn, bind whichever end landed inside a shape and snap the +// bound end(s) to the border. tailScene/tipScene are the draw start/end points. +export const bindArrowOnDraw = (canvas, arrow, tailScene, tipScene) => { + const startShape = shapeUnderPoint(canvas, tailScene, arrow); + const endShape = shapeUnderPoint(canvas, tipScene, arrow); + if (startShape) arrow.startBinding = ensureId(startShape); + if (endShape) arrow.endBinding = ensureId(endShape); + if (startShape || endShape) { + ensureId(arrow); + rerouteArrow(canvas, arrow); + } + return { startShape, endShape }; +}; + +// --- persistence ---------------------------------------------------------- +// Serialise the binding fields on every object so bindings survive reload and +// undo/redo (both go through toObject/loadFromJSON). Idempotent. +export const enableBindingPersistence = () => { + if (fabric.__bindingPersist) return; + fabric.__bindingPersist = true; + const base = fabric.Object.prototype.toObject; + fabric.Object.prototype.toObject = function (propertiesToInclude) { + return base.call(this, [ + "id", + "startBinding", + "endBinding", + ...(propertiesToInclude || []), + ]); + }; +}; diff --git a/src/utils/binding.test.js b/src/utils/binding.test.js new file mode 100644 index 0000000..6ed6e89 --- /dev/null +++ b/src/utils/binding.test.js @@ -0,0 +1,170 @@ +import { fabric } from "fabric"; +import { + ensureId, + isBindable, + borderPoint, + boundArrows, + shapeUnderPoint, + rerouteArrow, + bindArrowOnDraw, + enableBindingPersistence, +} from "./binding"; +import { getArrowParts } from "./shapeLabel"; +import { buildArrowGroup } from "../Handlers/ToolsHandler/tools/arrow"; + +const makeCanvas = () => new fabric.Canvas(document.createElement("canvas")); +// strokeWidth 0 so the bounding box is exactly width x height (getBoundingRect +// otherwise adds the stroke, which would shift the border-point math by 0.5px). +const rect = (o = {}) => + new fabric.Rect({ + width: 100, + height: 60, + left: 0, + top: 0, + strokeWidth: 0, + ...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) }; +}; + +describe("ensureId", () => { + test("assigns a stable id and is idempotent", () => { + const r = rect(); + const id = ensureId(r); + expect(id).toBeTruthy(); + expect(ensureId(r)).toBe(id); + }); +}); + +describe("isBindable", () => { + test("shapes and groups are bindable; arrows, lines and text are not", () => { + expect(isBindable(rect())).toBe(true); + expect(isBindable(new fabric.Ellipse({ rx: 20, ry: 10 }))).toBe(true); + expect(isBindable(arrow({ x: 0, y: 0 }, { x: 50, y: 0 }))).toBe(false); + expect(isBindable(new fabric.Line([0, 0, 10, 0]))).toBe(false); + expect(isBindable(new fabric.IText("x"))).toBe(false); + }); +}); + +describe("borderPoint", () => { + test("clips the centre->toward ray to the shape's bounding box edge", () => { + const r = rect({ left: 50, top: 70 }); // centre (100,100), hw 50, hh 30 + // toward the right -> right edge centre (150, 100) + expect(borderPoint(r, { x: 400, y: 100 })).toEqual({ x: 150, y: 100 }); + // straight up -> top edge centre (100, 70) + expect(borderPoint(r, { x: 100, y: -100 })).toEqual({ x: 100, y: 70 }); + }); +}); + +describe("shapeUnderPoint", () => { + test("returns the topmost bindable shape containing the point, else null", () => { + const c = makeCanvas(); + const r = rect({ left: 50, top: 50 }); // covers (50,50)-(150,110) + c.add(r); + expect(shapeUnderPoint(c, { x: 100, y: 80 })).toBe(r); + expect(shapeUnderPoint(c, { x: 500, y: 500 })).toBe(null); + }); + + test("skips the excluded object (the arrow being drawn)", () => { + const c = makeCanvas(); + const r = rect({ left: 50, top: 50 }); + c.add(r); + expect(shapeUnderPoint(c, { x: 100, y: 80 }, r)).toBe(null); + }); +}); + +describe("boundArrows", () => { + test("finds arrows bound to a shape by id on either end", () => { + const c = makeCanvas(); + const r = rect(); + ensureId(r); + const a1 = arrow({ x: 0, y: 0 }, { x: 50, y: 0 }); + a1.startBinding = r.id; + const a2 = arrow({ x: 0, y: 0 }, { x: 50, y: 0 }); + a2.endBinding = r.id; + const a3 = arrow({ x: 0, y: 0 }, { x: 50, y: 0 }); // unbound + c.add(r, a1, a2, a3); + expect(boundArrows(c, r.id).sort()).toEqual([a1, a2].sort()); + }); +}); + +describe("rerouteArrow", () => { + test("snaps a bound end to the shape's border facing the other end", () => { + const c = makeCanvas(); + const r = rect({ left: 50, top: 270 }); // centre (100,300), right edge x=150 + ensureId(r); + const a = arrow({ x: 500, y: 300 }, { x: 700, y: 300 }); + a.startBinding = r.id; + c.add(r, a); + + expect(rerouteArrow(c, a)).toBe(true); + const { tail, tip } = ends(a); + // tail snapped to the rect's right edge; tip (unbound) stays put + expect(Math.round(tail.x)).toBe(150); + expect(Math.round(tail.y)).toBe(300); + expect(Math.round(tip.x)).toBe(700); + }); + + test("returns false when neither end is bound", () => { + const c = makeCanvas(); + const a = arrow({ x: 0, y: 0 }, { x: 50, y: 0 }); + c.add(a); + expect(rerouteArrow(c, a)).toBe(false); + }); +}); + +describe("bindArrowOnDraw", () => { + test("binds the ends dropped on shapes and snaps them to the border", () => { + const c = makeCanvas(); + const a = rect({ left: 50, top: 270 }); // centre (100,300) + const b = rect({ left: 650, top: 270 }); // centre (700,300) + c.add(a, b); + const ar = arrow({ x: 100, y: 300 }, { x: 700, y: 300 }); + c.add(ar); + + const res = bindArrowOnDraw(c, ar, { x: 100, y: 300 }, { x: 700, y: 300 }); + expect(res.startShape).toBe(a); + expect(res.endShape).toBe(b); + expect(ar.startBinding).toBe(a.id); + expect(ar.endBinding).toBe(b.id); + const { tail, tip } = ends(ar); + expect(Math.round(tail.x)).toBe(150); // a's right edge + expect(Math.round(tip.x)).toBe(650); // b's left edge + }); + + test("leaves an arrow drawn in empty space unbound", () => { + const c = makeCanvas(); + const ar = arrow({ x: 10, y: 10 }, { x: 200, y: 10 }); + c.add(ar); + bindArrowOnDraw(c, ar, { x: 10, y: 10 }, { x: 200, y: 10 }); + expect(ar.startBinding).toBeUndefined(); + expect(ar.endBinding).toBeUndefined(); + }); +}); + +describe("enableBindingPersistence", () => { + test("serialises id / startBinding / endBinding on toObject", () => { + enableBindingPersistence(); + const r = rect(); + r.id = "shape-1"; + const a = arrow({ x: 0, y: 0 }, { x: 50, y: 0 }); + a.id = "arrow-1"; + a.startBinding = "shape-1"; + expect(r.toObject().id).toBe("shape-1"); + expect(a.toObject().startBinding).toBe("shape-1"); + expect(a.toObject().id).toBe("arrow-1"); + }); +}); From 7794c2af60497d7442a0319600ddc701da97f325 Mon Sep 17 00:00:00 2001 From: Amark19 Date: Mon, 17 Aug 2026 19:56:50 +0530 Subject: [PATCH 2/8] fix: bind arrows drawn to a box edge (tolerance margin) Binding only fired when an arrow endpoint landed strictly inside a shape's bbox, but people draw arrows TO the edge (or just past it), so nothing bound and moving a box did nothing. Add a BIND_MARGIN (24px) slack to the draw-time hit test; the bound end still snaps to the border via rerouteArrow, so the extra reach is free. Co-Authored-By: Claude Opus 4.8 --- src/utils/binding.js | 18 ++++++++++++------ src/utils/binding.test.js | 11 +++++++++++ 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/utils/binding.js b/src/utils/binding.js index 1734c1d..de8ef04 100644 --- a/src/utils/binding.js +++ b/src/utils/binding.js @@ -31,18 +31,24 @@ const NON_BINDABLE = new Set(["line", "i-text", "text", "textbox"]); export const isBindable = (o) => !!o && !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; + // --- 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); -const bboxContainsPoint = (shape, p) => { +const bboxContainsPoint = (shape, p, margin = 0) => { const b = sceneBBox(shape); return ( - p.x >= b.left && - p.x <= b.left + b.width && - p.y >= b.top && - p.y <= b.top + b.height + p.x >= b.left - margin && + p.x <= b.left + b.width + margin && + p.y >= b.top - margin && + p.y <= b.top + b.height + margin ); }; @@ -118,7 +124,7 @@ export const shapeUnderPoint = (canvas, point, exclude) => { for (let i = objs.length - 1; i >= 0; i -= 1) { const o = objs[i]; if (o === exclude || !isBindable(o)) continue; - if (bboxContainsPoint(o, point)) return o; + if (bboxContainsPoint(o, point, BIND_MARGIN)) return o; } return null; }; diff --git a/src/utils/binding.test.js b/src/utils/binding.test.js index 6ed6e89..7e618ce 100644 --- a/src/utils/binding.test.js +++ b/src/utils/binding.test.js @@ -8,6 +8,7 @@ import { rerouteArrow, bindArrowOnDraw, enableBindingPersistence, + BIND_MARGIN, } from "./binding"; import { getArrowParts } from "./shapeLabel"; import { buildArrowGroup } from "../Handlers/ToolsHandler/tools/arrow"; @@ -84,6 +85,16 @@ describe("shapeUnderPoint", () => { c.add(r); expect(shapeUnderPoint(c, { x: 100, y: 80 }, r)).toBe(null); }); + + test("binds to a point just outside the edge (within BIND_MARGIN)", () => { + const c = makeCanvas(); + const r = rect({ left: 50, top: 50 }); // right edge x=150 + c.add(r); + // people draw arrows TO the edge; a point a few px past it still binds + expect(shapeUnderPoint(c, { x: 150 + BIND_MARGIN - 4, y: 80 })).toBe(r); + // but well beyond the margin does not + expect(shapeUnderPoint(c, { x: 150 + BIND_MARGIN + 20, y: 80 })).toBe(null); + }); }); describe("boundArrows", () => { From fca5867f7809fd7e3385065a36f31a980642a29e Mon Sep 17 00:00:00 2001 From: Amark19 Date: Mon, 17 Aug 2026 20:38:35 +0530 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20live=20bind=20highlight=20=E2=80=94?= =?UTF-8?q?=20show=20the=20target=20box=20while=20drawing=20an=20arrow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There was no signal that an arrow would bind. Now, while drawing, the shape the endpoint is over gets an indigo outline (Excalidraw-style), so binding is visible before release; it clears on drop. - bindingHighlight.js: transient, non-interactive, excludeFromExport overlay outlining the target; kept on canvas.__bindHighlight, flagged __nonBindable so it can't be its own target. - isBindable skips __nonBindable; shapeUnderPoint accepts an array exclude so the arrow tool can ignore its own preview line/head while highlighting. - arrow tool: highlight on draw, clear on done. Co-Authored-By: Claude Opus 4.8 --- src/Handlers/ToolsHandler/tools/arrow.js | 16 ++++++- src/utils/binding.js | 8 ++-- src/utils/binding.test.js | 13 ++++++ src/utils/bindingHighlight.js | 57 ++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 src/utils/bindingHighlight.js diff --git a/src/Handlers/ToolsHandler/tools/arrow.js b/src/Handlers/ToolsHandler/tools/arrow.js index c2cb0de..1e80a59 100644 --- a/src/Handlers/ToolsHandler/tools/arrow.js +++ b/src/Handlers/ToolsHandler/tools/arrow.js @@ -2,7 +2,11 @@ import { fabric } from "fabric"; import { Tool } from "../toolGeneric"; import { resolveToolStyle } from "../toolStyle"; import { attachEndpointControls } from "../../../utils/arrowEndpoints"; -import { bindArrowOnDraw } from "../../../utils/binding"; +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"; @@ -162,9 +166,19 @@ export class Arrow extends Tool { this.arrowHeadStart.angle = angleBetween(this.pointer, origin); this.arrowHeadStart.setCoords(); } + // Live binding affordance: highlight the shape the moving endpoint is over, + // excluding the transient preview objects so they don't count as targets. + const target = shapeUnderPoint(canvas, this.pointer, [ + this.line, + this.arrowHead, + this.arrowHeadStart, + ]); + if (target) showBindHighlight(canvas, target); + else clearBindHighlight(canvas); } 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. diff --git a/src/utils/binding.js b/src/utils/binding.js index de8ef04..2bf79d4 100644 --- a/src/utils/binding.js +++ b/src/utils/binding.js @@ -29,7 +29,7 @@ export const ensureId = (obj) => { // imported icons (groups) all qualify. const NON_BINDABLE = new Set(["line", "i-text", "text", "textbox"]); export const isBindable = (o) => - !!o && !isArrow(o) && !NON_BINDABLE.has(o.type); + !!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 @@ -118,12 +118,14 @@ export const rerouteArrow = (canvas, arrow) => { // --- bind on draw --------------------------------------------------------- // Topmost bindable shape whose bbox contains `point` (scene coords), skipping -// the arrow being drawn. +// `exclude` (a single object or an array — e.g. the arrow being drawn plus its +// transient preview line/head, which must not count as binding targets). export const shapeUnderPoint = (canvas, point, exclude) => { + const skip = Array.isArray(exclude) ? exclude : exclude ? [exclude] : []; const objs = canvas.getObjects(); for (let i = objs.length - 1; i >= 0; i -= 1) { const o = objs[i]; - if (o === exclude || !isBindable(o)) continue; + if (skip.includes(o) || !isBindable(o)) continue; if (bboxContainsPoint(o, point, BIND_MARGIN)) return o; } return null; diff --git a/src/utils/binding.test.js b/src/utils/binding.test.js index 7e618ce..96bc871 100644 --- a/src/utils/binding.test.js +++ b/src/utils/binding.test.js @@ -58,6 +58,10 @@ describe("isBindable", () => { expect(isBindable(new fabric.Line([0, 0, 10, 0]))).toBe(false); expect(isBindable(new fabric.IText("x"))).toBe(false); }); + + test("an object flagged __nonBindable (e.g. the bind highlight) is skipped", () => { + expect(isBindable(rect({ __nonBindable: true }))).toBe(false); + }); }); describe("borderPoint", () => { @@ -86,6 +90,15 @@ describe("shapeUnderPoint", () => { expect(shapeUnderPoint(c, { x: 100, y: 80 }, r)).toBe(null); }); + test("exclude may be an array (arrow + its preview objects)", () => { + const c = makeCanvas(); + const r = rect({ left: 50, top: 50 }); + const line = new fabric.Line([0, 0, 10, 0]); + c.add(r, line); + expect(shapeUnderPoint(c, { x: 100, y: 80 }, [line, r])).toBe(null); + expect(shapeUnderPoint(c, { x: 100, y: 80 }, [line])).toBe(r); + }); + test("binds to a point just outside the edge (within BIND_MARGIN)", () => { const c = makeCanvas(); const r = rect({ left: 50, top: 50 }); // right edge x=150 diff --git a/src/utils/bindingHighlight.js b/src/utils/bindingHighlight.js new file mode 100644 index 0000000..4b2c45f --- /dev/null +++ b/src/utils/bindingHighlight.js @@ -0,0 +1,57 @@ +import { fabric } from "fabric"; + +// Live "this will bind" affordance: while an arrow is being drawn, the shape its +// endpoint is over gets a highlighted outline (like Excalidraw), so you can see +// the connection will be made before releasing. The highlight is a transient, +// non-interactive overlay — never selectable, never persisted (excludeFromExport) +// — kept on canvas.__bindHighlight and cleared on drop. + +const HL_COLOR = "#4f46e5"; // indigo accent (matches the endpoint handles) +const PAD = 5; + +export const showBindHighlight = (canvas, shape) => { + if (!shape) return clearBindHighlight(canvas); + const b = shape.getBoundingRect(true, true); + const props = { + left: b.left - PAD, + top: b.top - PAD, + width: b.width + PAD * 2, + height: b.height + PAD * 2, + }; + let hl = canvas.__bindHighlight; + if (!hl) { + hl = new fabric.Rect({ + ...props, + rx: 16, + ry: 16, + fill: "transparent", + stroke: HL_COLOR, + strokeWidth: 3, + selectable: false, + evented: false, + excludeFromExport: true, + __nonBindable: true, + originX: "left", + originY: "top", + }); + canvas.__bindHighlight = hl; + canvas.add(hl); + } else { + hl.set(props); + if (canvas.getObjects().indexOf(hl) < 0) canvas.add(hl); + } + canvas.bringToFront(hl); + hl.setCoords(); + canvas.requestRenderAll(); + return hl; +}; + +export const clearBindHighlight = (canvas) => { + const hl = canvas && canvas.__bindHighlight; + if (hl) { + canvas.remove(hl); + canvas.__bindHighlight = null; + canvas.requestRenderAll(); + } + return null; +}; From b00f26354fbce8f82a61737ded3783e5fac7b708 Mon Sep 17 00:00:00 2001 From: Amark19 Date: Mon, 17 Aug 2026 20:57:37 +0530 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20binding=20polish=20=E2=80=94=20endp?= =?UTF-8?q?oint=20rebind/unbind,=20anchor=20points,=20softer=20highlight?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses binding UX feedback: - Endpoint drag now (re)binds: drop an arrow endpoint on a shape to bind it, drop it in empty space to unbind — no more stale binding snapping back. arrowEndpoints fires arrow:endpoint:moving/up; arrowBindingHandler commits it. - Attach at ANY reachable border point: each bound end stores a normalized anchor (fraction of the shape's half-extents) and re-routes to that side/ corner, so an arrow keeps its attach point as the shape moves — instead of always snapping to the single centre-facing point. Near-centre anchors fall back to centre-facing so they still land on a clean edge. - Highlight is now subtle (soft indigo tint + 1.5px border) and shows on BOTH the source and destination shapes while drawing/dragging an endpoint. startAnchor/endAnchor persist alongside the bindings. Tests: 72 pass. Co-Authored-By: Claude Opus 4.8 --- .../EventHandlers/arrowBindingHandler.js | 59 ++++++++++-- src/Handlers/ToolsHandler/tools/arrow.js | 19 ++-- src/utils/arrowEndpoints.js | 18 +++- src/utils/binding.js | 85 +++++++++++++++-- src/utils/binding.test.js | 40 ++++++++ src/utils/bindingHighlight.js | 92 ++++++++++--------- 6 files changed, 243 insertions(+), 70 deletions(-) diff --git a/src/Handlers/EventHandlers/arrowBindingHandler.js b/src/Handlers/EventHandlers/arrowBindingHandler.js index 7146271..ee59130 100644 --- a/src/Handlers/EventHandlers/arrowBindingHandler.js +++ b/src/Handlers/EventHandlers/arrowBindingHandler.js @@ -1,23 +1,32 @@ import { useEffect } from "react"; import { useCanvasContext } from "../../hooks"; import { isArrow } from "../../utils/shapeLabel"; -import { boundArrows, rerouteArrow } from "../../utils/binding"; - -// Keep bound arrows glued to their shapes: when a shape with bound arrows is -// dragged or resized, re-route those arrows live to the shape's border. We react -// to object:moving / object:scaling (not object:modified) so the re-routing is -// live during the gesture AND the final positions are already in place when -// fabric-history snapshots on drop — so undo restores shape + arrows together -// without extra history plumbing. +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; - // Single bound shape only for v1 — multi-select re-route is a follow-up. if (!t || isArrow(t) || t.type === "activeSelection" || !t.id) return; const arrows = boundArrows(canvas, t.id); if (!arrows.length) return; @@ -25,11 +34,43 @@ function ArrowBindingHandler() { canvas.requestRenderAll(); }; + // The other (already-bound) end's shape, so we can highlight the source too. + const otherShape = (arrow, end) => { + const id = end === "start" ? arrow.endBinding : arrow.startBinding; + return id ? canvas.getObjects().find((o) => o.id === id) : null; + }; + + // Dragging an arrow endpoint: highlight the shape it's over (+ the source). + const onEndpointMoving = (opt) => { + const { arrow, end } = opt || {}; + if (!arrow) return; + const p = arrowEndScene(arrow, end); + const target = shapeUnderPoint(canvas, p, [arrow]); + showBindHighlight(canvas, [target, otherShape(arrow, end)]); + }; + + // 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(); + }; + canvas.on("object:moving", onShapeChange); canvas.on("object:scaling", onShapeChange); + canvas.on("arrow:endpoint:moving", onEndpointMoving); + canvas.on("arrow:endpoint:up", onEndpointUp); return () => { canvas.off("object:moving", onShapeChange); canvas.off("object:scaling", onShapeChange); + canvas.off("arrow:endpoint:moving", onEndpointMoving); + canvas.off("arrow:endpoint:up", onEndpointUp); }; }, [canvas]); } diff --git a/src/Handlers/ToolsHandler/tools/arrow.js b/src/Handlers/ToolsHandler/tools/arrow.js index 1e80a59..58e7342 100644 --- a/src/Handlers/ToolsHandler/tools/arrow.js +++ b/src/Handlers/ToolsHandler/tools/arrow.js @@ -166,15 +166,16 @@ export class Arrow extends Tool { this.arrowHeadStart.angle = angleBetween(this.pointer, origin); this.arrowHeadStart.setCoords(); } - // Live binding affordance: highlight the shape the moving endpoint is over, - // excluding the transient preview objects so they don't count as targets. - const target = shapeUnderPoint(canvas, this.pointer, [ - this.line, - this.arrowHead, - this.arrowHeadStart, - ]); - if (target) showBindHighlight(canvas, target); - else clearBindHighlight(canvas); + // 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) { diff --git a/src/utils/arrowEndpoints.js b/src/utils/arrowEndpoints.js index 2b3d902..1276693 100644 --- a/src/utils/arrowEndpoints.js +++ b/src/utils/arrowEndpoints.js @@ -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; }; @@ -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; }, }); diff --git a/src/utils/binding.js b/src/utils/binding.js index 2bf79d4..fd4aa92 100644 --- a/src/utils/binding.js +++ b/src/utils/binding.js @@ -70,6 +70,32 @@ export const borderPoint = (shape, toward) => { return { x: c.x + dx * t, y: c.y + dy * t }; }; +// --- anchor (which point on the shape the arrow attaches to) -------------- +// Store the attach point as a fraction of the shape's half-extents from its +// centre ({fx,fy} in [-1,1]). This is resolution-independent, so the arrow +// keeps attaching to the SAME side/corner as the shape moves or resizes — +// letting an arrow connect at any reachable border point, not just the centre. +const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); + +const anchorOf = (shape, scenePoint) => { + const c = shape.getCenterPoint(); + const b = sceneBBox(shape); + return { + fx: clamp((scenePoint.x - c.x) / (b.width / 2 || 1), -1, 1), + fy: clamp((scenePoint.y - c.y) / (b.height / 2 || 1), -1, 1), + }; +}; + +// The scene point an anchor currently resolves to (centre + fraction*half). +const anchorTarget = (shape, anchor) => { + const c = shape.getCenterPoint(); + const b = sceneBBox(shape); + return { + x: c.x + anchor.fx * (b.width / 2), + y: c.y + anchor.fy * (b.height / 2), + }; +}; + // --- lookups -------------------------------------------------------------- const shapeById = (canvas, id) => id ? canvas.getObjects().find((o) => o.id === id) || null : null; @@ -95,6 +121,12 @@ const arrowEndpointsScene = (arrow) => { return { tail: toScene(lp.x1, lp.y1), tip: toScene(lp.x2, lp.y2) }; }; +// Scene position of one arrow end ("start" = tail, "end" = tip). +export const arrowEndScene = (arrow, end) => { + const e = arrowEndpointsScene(arrow); + return end === "start" ? e.tail : e.tip; +}; + // --- re-route ------------------------------------------------------------- // 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 @@ -106,8 +138,25 @@ export const rerouteArrow = (canvas, arrow) => { if (!startShape && !endShape) return false; const ends = arrowEndpointsScene(arrow); - const startAim = endShape ? endShape.getCenterPoint() : ends.tip; - const endAim = startShape ? startShape.getCenterPoint() : ends.tail; + // Aim each bound end at its stored anchor point (so it keeps its attach + // side/corner). A near-centre anchor is ambiguous, so fall back to facing the + // other end — which snaps to a clean edge instead of burying it in the middle. + const meaningful = (a) => + a && (Math.abs(a.fx) > 0.05 || Math.abs(a.fy) > 0.05); + const startAim = startShape + ? meaningful(arrow.startAnchor) + ? anchorTarget(startShape, arrow.startAnchor) + : endShape + ? endShape.getCenterPoint() + : ends.tip + : null; + const endAim = endShape + ? meaningful(arrow.endAnchor) + ? anchorTarget(endShape, arrow.endAnchor) + : startShape + ? startShape.getCenterPoint() + : ends.tail + : null; const tail = startShape ? borderPoint(startShape, startAim) : ends.tail; const tip = endShape ? borderPoint(endShape, endAim) : ends.tip; @@ -131,17 +180,31 @@ export const shapeUnderPoint = (canvas, point, exclude) => { return null; }; -// After an arrow is drawn, bind whichever end landed inside a shape and snap the -// bound end(s) to the border. tailScene/tipScene are the draw start/end points. +// Field names for an end ("start" = tail/e1, "end" = tip/e2). +const bindingField = (end) => (end === "start" ? "startBinding" : "endBinding"); +const anchorField = (end) => (end === "start" ? "startAnchor" : "endAnchor"); + +// Bind one end of an arrow to a shape, anchoring at where the endpoint landed. +export const bindEnd = (arrow, end, shape, scenePoint) => { + arrow[bindingField(end)] = ensureId(shape); + arrow[anchorField(end)] = anchorOf(shape, scenePoint); + ensureId(arrow); +}; + +// Unbind one end (e.g. its endpoint was dragged into empty space). +export const unbindEnd = (arrow, end) => { + arrow[bindingField(end)] = undefined; + arrow[anchorField(end)] = undefined; +}; + +// After an arrow is drawn, bind whichever end landed on a shape (anchored at the +// drawn point) and snap it to the border. tailScene/tipScene are the draw ends. export const bindArrowOnDraw = (canvas, arrow, tailScene, tipScene) => { const startShape = shapeUnderPoint(canvas, tailScene, arrow); const endShape = shapeUnderPoint(canvas, tipScene, arrow); - if (startShape) arrow.startBinding = ensureId(startShape); - if (endShape) arrow.endBinding = ensureId(endShape); - if (startShape || endShape) { - ensureId(arrow); - rerouteArrow(canvas, arrow); - } + if (startShape) bindEnd(arrow, "start", startShape, tailScene); + if (endShape) bindEnd(arrow, "end", endShape, tipScene); + if (startShape || endShape) rerouteArrow(canvas, arrow); return { startShape, endShape }; }; @@ -157,6 +220,8 @@ export const enableBindingPersistence = () => { "id", "startBinding", "endBinding", + "startAnchor", + "endAnchor", ...(propertiesToInclude || []), ]); }; diff --git a/src/utils/binding.test.js b/src/utils/binding.test.js index 96bc871..9d1b329 100644 --- a/src/utils/binding.test.js +++ b/src/utils/binding.test.js @@ -7,6 +7,9 @@ import { shapeUnderPoint, rerouteArrow, bindArrowOnDraw, + bindEnd, + unbindEnd, + arrowEndScene, enableBindingPersistence, BIND_MARGIN, } from "./binding"; @@ -148,6 +151,43 @@ describe("rerouteArrow", () => { c.add(a); expect(rerouteArrow(c, a)).toBe(false); }); + + test("anchored end attaches at the anchored side (e.g. the top edge)", () => { + const c = makeCanvas(); + const r = rect({ left: 50, top: 270 }); // centre (100,300), top edge y=270 + ensureId(r); + const a = arrow({ x: 100, y: 100 }, { x: 500, y: 100 }); + // bind the tail anchored to the TOP of the rect (a point above its centre) + bindEnd(a, "start", r, { x: 100, y: 270 }); + c.add(r, a); + rerouteArrow(c, a); + const { tail } = ends(a); + expect(Math.round(tail.x)).toBe(100); // horizontally centred + expect(Math.round(tail.y)).toBe(270); // on the TOP edge, not the side + }); +}); + +describe("bindEnd / unbindEnd", () => { + test("bindEnd sets the binding + anchor; unbindEnd clears them", () => { + const c = makeCanvas(); + const r = rect({ left: 50, top: 270 }); + const a = arrow({ x: 500, y: 300 }, { x: 700, y: 300 }); + c.add(r, a); + bindEnd(a, "start", r, { x: 120, y: 300 }); + expect(a.startBinding).toBe(r.id); + expect(a.startAnchor).toBeTruthy(); + unbindEnd(a, "start"); + expect(a.startBinding).toBeUndefined(); + expect(a.startAnchor).toBeUndefined(); + }); +}); + +describe("arrowEndScene", () => { + test("returns tail for 'start' and tip for 'end'", () => { + const a = arrow({ x: 100, y: 200 }, { x: 400, y: 200 }); + expect(Math.round(arrowEndScene(a, "start").x)).toBe(100); + expect(Math.round(arrowEndScene(a, "end").x)).toBe(400); + }); }); describe("bindArrowOnDraw", () => { diff --git a/src/utils/bindingHighlight.js b/src/utils/bindingHighlight.js index 4b2c45f..9e1e6e3 100644 --- a/src/utils/bindingHighlight.js +++ b/src/utils/bindingHighlight.js @@ -1,57 +1,67 @@ import { fabric } from "fabric"; -// Live "this will bind" affordance: while an arrow is being drawn, the shape its -// endpoint is over gets a highlighted outline (like Excalidraw), so you can see -// the connection will be made before releasing. The highlight is a transient, -// non-interactive overlay — never selectable, never persisted (excludeFromExport) -// — kept on canvas.__bindHighlight and cleared on drop. +// Live "this will bind" affordance: while an arrow is drawn or an endpoint is +// dragged, the shape(s) it will connect to get a soft highlight (like +// Excalidraw). Highlights are transient, non-interactive, never persisted +// (excludeFromExport) and flagged __nonBindable so they can't target themselves. +// Kept on canvas.__bindHL; cleared on drop. -const HL_COLOR = "#4f46e5"; // indigo accent (matches the endpoint handles) -const PAD = 5; +const STROKE = "#6366f1"; // soft indigo +const FILL = "rgba(99,102,241,0.09)"; // faint tint, not a heavy border +const PAD = 4; -export const showBindHighlight = (canvas, shape) => { - if (!shape) return clearBindHighlight(canvas); +const makeRect = (shape) => { const b = shape.getBoundingRect(true, true); - const props = { + return new fabric.Rect({ left: b.left - PAD, top: b.top - PAD, width: b.width + PAD * 2, height: b.height + PAD * 2, - }; - let hl = canvas.__bindHighlight; - if (!hl) { - hl = new fabric.Rect({ - ...props, - rx: 16, - ry: 16, - fill: "transparent", - stroke: HL_COLOR, - strokeWidth: 3, - selectable: false, - evented: false, - excludeFromExport: true, - __nonBindable: true, - originX: "left", - originY: "top", - }); - canvas.__bindHighlight = hl; - canvas.add(hl); - } else { - hl.set(props); - if (canvas.getObjects().indexOf(hl) < 0) canvas.add(hl); - } - canvas.bringToFront(hl); - hl.setCoords(); + rx: 14, + ry: 14, + fill: FILL, + stroke: STROKE, + strokeWidth: 1.5, + selectable: false, + evented: false, + excludeFromExport: true, + __nonBindable: true, + originX: "left", + originY: "top", + }); +}; + +const sameSet = (a, b) => + a.length === b.length && a.every((s, i) => s === b[i]); + +// Highlight exactly `shapes` (a shape, an array, or falsy to clear). Skips the +// rebuild when the set is unchanged so a drag doesn't churn objects each frame. +export const showBindHighlight = (canvas, shapes) => { + const list = [ + ...new Set((Array.isArray(shapes) ? shapes : [shapes]).filter(Boolean)), + ]; + if (canvas.__bindHLShapes && sameSet(canvas.__bindHLShapes, list)) return; + clearBindHighlight(canvas); + if (!list.length) return; + const rects = list.map((s) => { + const r = makeRect(s); + canvas.add(r); + canvas.bringToFront(r); + return r; + }); + canvas.__bindHL = rects; + canvas.__bindHLShapes = list; canvas.requestRenderAll(); - return hl; }; export const clearBindHighlight = (canvas) => { - const hl = canvas && canvas.__bindHighlight; - if (hl) { - canvas.remove(hl); - canvas.__bindHighlight = null; + const arr = canvas && canvas.__bindHL; + if (arr && arr.length) { + arr.forEach((r) => canvas.remove(r)); canvas.requestRenderAll(); } - return null; + if (canvas) { + canvas.__bindHL = []; + canvas.__bindHLShapes = []; + } }; From bae3a03e05e97ce15d8c31fb8612490ad3bf9c82 Mon Sep 17 00:00:00 2001 From: Amark19 Date: Mon, 17 Aug 2026 22:27:12 +0530 Subject: [PATCH 5/8] style: thinner eraser-style bind highlight (border-only, hugs the shape) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bind highlight was a heavy filled box. Make it a thin (2px) violet-indigo outline with no fill that hugs the shape's border — a clean hover affordance like eraser.io, on both source and destination. Co-Authored-By: Claude Opus 4.8 --- src/utils/bindingHighlight.js | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/utils/bindingHighlight.js b/src/utils/bindingHighlight.js index 9e1e6e3..404ef39 100644 --- a/src/utils/bindingHighlight.js +++ b/src/utils/bindingHighlight.js @@ -6,9 +6,10 @@ import { fabric } from "fabric"; // (excludeFromExport) and flagged __nonBindable so they can't target themselves. // Kept on canvas.__bindHL; cleared on drop. -const STROKE = "#6366f1"; // soft indigo -const FILL = "rgba(99,102,241,0.09)"; // faint tint, not a heavy border -const PAD = 4; +// A thin accent outline that hugs the shape's border (eraser.io-style hover +// affordance) — no fill, just the border "lighting up". +const STROKE = "#6965db"; // clean violet-indigo accent +const PAD = 2; const makeRect = (shape) => { const b = shape.getBoundingRect(true, true); @@ -17,11 +18,11 @@ const makeRect = (shape) => { top: b.top - PAD, width: b.width + PAD * 2, height: b.height + PAD * 2, - rx: 14, - ry: 14, - fill: FILL, + rx: 12, + ry: 12, + fill: "transparent", stroke: STROKE, - strokeWidth: 1.5, + strokeWidth: 2, selectable: false, evented: false, excludeFromExport: true, From 37d008e465fdd8304fafea19936a2531b56f2b16 Mon Sep 17 00:00:00 2001 From: Amark19 Date: Mon, 17 Aug 2026 22:44:05 +0530 Subject: [PATCH 6/8] fix: bind highlight can't get stuck; endpoint drag highlights only its target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Clear the bind highlight on any canvas mouse:up (catch-all), so a stray highlight can never persist past a release, whatever ended the gesture. - Dragging one arrow endpoint now highlights only the shape THAT endpoint is over — not the unchanged other/source end, which just added clutter. Co-Authored-By: Claude Opus 4.8 --- .../EventHandlers/arrowBindingHandler.js | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Handlers/EventHandlers/arrowBindingHandler.js b/src/Handlers/EventHandlers/arrowBindingHandler.js index ee59130..d049a84 100644 --- a/src/Handlers/EventHandlers/arrowBindingHandler.js +++ b/src/Handlers/EventHandlers/arrowBindingHandler.js @@ -34,19 +34,13 @@ function ArrowBindingHandler() { canvas.requestRenderAll(); }; - // The other (already-bound) end's shape, so we can highlight the source too. - const otherShape = (arrow, end) => { - const id = end === "start" ? arrow.endBinding : arrow.startBinding; - return id ? canvas.getObjects().find((o) => o.id === id) : null; - }; - - // Dragging an arrow endpoint: highlight the shape it's over (+ the source). + // 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); - const target = shapeUnderPoint(canvas, p, [arrow]); - showBindHighlight(canvas, [target, otherShape(arrow, end)]); + showBindHighlight(canvas, shapeUnderPoint(canvas, p, [arrow])); }; // Dropping an arrow endpoint: bind it if over a shape, else unbind it. @@ -62,15 +56,21 @@ function ArrowBindingHandler() { 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]); } From 30482c52dc076e23e2482709e128ebdd30ae0640 Mon Sep 17 00:00:00 2001 From: Amark19 Date: Mon, 17 Aug 2026 22:49:22 +0530 Subject: [PATCH 7/8] fix: default stroke width is now a preset (Width control auto-selects) The default strokeWidth (3) wasn't one of the Width presets (2/4/8), so no Width button highlighted for a default shape. Set presets to Thin/Medium/Bold = 2/3/6 so the default (3) = Medium and highlights; existing default shapes (also 3) now highlight too. Also snap any legacy stored width to the nearest preset (mirrors the font-size snap), so the control is never blank. Co-Authored-By: Claude Opus 4.8 --- src/Handlers/ToolsHandler/toolStyle.js | 4 +-- src/utils/stylePrefs.js | 35 ++++++++++++++++++-------- src/utils/stylePrefs.test.js | 13 ++++++++++ 3 files changed, 39 insertions(+), 13 deletions(-) diff --git a/src/Handlers/ToolsHandler/toolStyle.js b/src/Handlers/ToolsHandler/toolStyle.js index acbe2c8..2792d02 100644 --- a/src/Handlers/ToolsHandler/toolStyle.js +++ b/src/Handlers/ToolsHandler/toolStyle.js @@ -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"]; diff --git a/src/utils/stylePrefs.js b/src/utils/stylePrefs.js index 2014b5b..3eb623d 100644 --- a/src/utils/stylePrefs.js +++ b/src/utils/stylePrefs.js @@ -3,20 +3,32 @@ // resetting to defaults. localStorage (not IndexedDB) — it's a tiny, plain // object, kept separate from the (larger) scene record. -import { DEFAULT_STYLE, FONT_SIZES } from "../Handlers/ToolsHandler/toolStyle"; +import { + DEFAULT_STYLE, + FONT_SIZES, + STROKE_WIDTHS, +} from "../Handlers/ToolsHandler/toolStyle"; import { getInitialTheme, THEME_DARK, LIGHT_INK, DARK_INK } from "./theme"; -// Snap an arbitrary size onto the nearest current preset. A legacy stored size -// (from an older S/M/L scale, e.g. 12) is no longer one of the presets, which -// left the Size control with nothing highlighted; snapping keeps it in sync so -// a preset is always active and new text uses a real preset value. -const snapToPreset = (size) => { - const presets = FONT_SIZES.map((s) => s.value); - if (presets.includes(size)) return size; +// Snap an arbitrary value onto the nearest value in `presets`. A legacy stored +// value (from an older scale) may no longer be a preset, which leaves its +// control with nothing highlighted; snapping keeps a preset always active. +const snapTo = (value, presets) => { + if (presets.includes(value)) return value; return presets.reduce((a, b) => - Math.abs(b - size) < Math.abs(a - size) ? b : a, + Math.abs(b - value) < Math.abs(a - value) ? b : a, ); }; +const snapToPreset = (size) => + snapTo( + size, + FONT_SIZES.map((s) => s.value), + ); +const snapWidth = (w) => + snapTo( + w, + STROKE_WIDTHS.map((s) => s.value), + ); const KEY = "wb-style"; // Bumped when the default style changes in a way existing stored styles should @@ -58,9 +70,10 @@ export const getStoredStyle = () => { ) { merged.fontSize = DEFAULT_STYLE.fontSize; } - // Keep the effective size on a real preset so the Size control is never - // blank (covers legacy sizes the version migration above doesn't name). + // Keep the effective size + width on real presets so their controls are + // never blank (covers legacy values the version migration doesn't name). merged.fontSize = snapToPreset(merged.fontSize); + merged.strokeWidth = snapWidth(merged.strokeWidth); merged._v = STYLE_VERSION; return merged; } catch { diff --git a/src/utils/stylePrefs.test.js b/src/utils/stylePrefs.test.js index 4465cbb..443b228 100644 --- a/src/utils/stylePrefs.test.js +++ b/src/utils/stylePrefs.test.js @@ -83,3 +83,16 @@ describe("getStoredStyle — off-preset sizes snap to the nearest preset", () => expect(getStoredStyle().fontSize).toBe(16); }); }); + +describe("getStoredStyle — stroke width snaps to the nearest preset", () => { + // Presets: 2/3/6. The default (3) is a preset so the Width control highlights. + test("default strokeWidth (3) is a preset and is kept", () => { + expect(getStoredStyle().strokeWidth).toBe(3); + expect(DEFAULT_STYLE.strokeWidth).toBe(3); + }); + + test("a legacy width (8) snaps to the nearest preset (6)", () => { + localStorage.setItem("wb-style", JSON.stringify({ strokeWidth: 8, _v: 4 })); + expect(getStoredStyle().strokeWidth).toBe(6); + }); +}); From 5aa1350f20dd117c92a7b36cc83f04b2b771c674 Mon Sep 17 00:00:00 2001 From: Amark19 Date: Mon, 17 Aug 2026 23:05:53 +0530 Subject: [PATCH 8/8] fix: anchor arrows to the ellipse curve, not its bounding box (no gap) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit borderPoint clipped the ray to the axis-aligned bounding box — exact for rectangles, but for a circle/ellipse the endpoint landed on the bounding square, leaving a visible gap between the arrowhead and the curve. Use a true ray-ellipse intersection for ellipse/circle; rect and others keep bbox clipping (exact for rects, close for polygons). Co-Authored-By: Claude Opus 4.8 --- src/utils/binding.js | 20 +++++++++++++------- src/utils/binding.test.js | 19 +++++++++++++++++++ 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/utils/binding.js b/src/utils/binding.js index fd4aa92..846d34d 100644 --- a/src/utils/binding.js +++ b/src/utils/binding.js @@ -52,9 +52,10 @@ const bboxContainsPoint = (shape, p, margin = 0) => { ); }; -// Point on the shape's border along the ray from its centre toward `toward`. -// Clips the direction vector to the bounding box so the arrow touches the edge -// rather than burying its head in the shape's middle. +// 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). export const borderPoint = (shape, toward) => { const c = shape.getCenterPoint(); const dx = toward.x - c.x; @@ -63,10 +64,15 @@ export const borderPoint = (shape, toward) => { const b = sceneBBox(shape); const hw = b.width / 2; const hh = b.height / 2; - const t = Math.min( - dx === 0 ? Infinity : hw / Math.abs(dx), - dy === 0 ? Infinity : hh / Math.abs(dy), - ); + const t = + shape.type === "ellipse" || shape.type === "circle" + ? // ray-ellipse: scale the direction so it lands exactly on the curve, + // otherwise the tip stops on the bounding square and leaves a gap. + 1 / Math.hypot(dx / hw, dy / hh) + : Math.min( + dx === 0 ? Infinity : hw / Math.abs(dx), + dy === 0 ? Infinity : hh / Math.abs(dy), + ); return { x: c.x + dx * t, y: c.y + dy * t }; }; diff --git a/src/utils/binding.test.js b/src/utils/binding.test.js index 9d1b329..b29f614 100644 --- a/src/utils/binding.test.js +++ b/src/utils/binding.test.js @@ -75,6 +75,25 @@ describe("borderPoint", () => { // straight up -> top edge centre (100, 70) expect(borderPoint(r, { x: 100, y: -100 })).toEqual({ x: 100, y: 70 }); }); + + test("an ellipse lands on its curve, not the bounding box corner", () => { + // centre (100,100), rx 50, ry 30 (strokeWidth 0 -> bbox == geometry) + const e = new fabric.Ellipse({ + left: 50, + top: 70, + rx: 50, + ry: 30, + strokeWidth: 0, + }); + // straight right -> the right vertex (150,100) + const right = borderPoint(e, { x: 400, y: 100 }); + expect(Math.round(right.x)).toBe(150); + expect(Math.round(right.y)).toBe(100); + // diagonal -> a point that actually satisfies the ellipse equation (~1) + const p = borderPoint(e, { x: 400, y: 400 }); + const on = ((p.x - 100) / 50) ** 2 + ((p.y - 100) / 30) ** 2; + expect(on).toBeCloseTo(1, 2); + }); }); describe("shapeUnderPoint", () => {