diff --git a/src/Handlers/EventHandlers/arrowBindingHandler.js b/src/Handlers/EventHandlers/arrowBindingHandler.js new file mode 100644 index 0000000..d049a84 --- /dev/null +++ b/src/Handlers/EventHandlers/arrowBindingHandler.js @@ -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; 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/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/Handlers/ToolsHandler/tools/arrow.js b/src/Handlers/ToolsHandler/tools/arrow.js index 688c593..58e7342 100644 --- a/src/Handlers/ToolsHandler/tools/arrow.js +++ b/src/Handlers/ToolsHandler/tools/arrow.js @@ -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"; @@ -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. @@ -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 }, + ); } } 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/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 new file mode 100644 index 0000000..846d34d --- /dev/null +++ b/src/utils/binding.js @@ -0,0 +1,234 @@ +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 && !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; + +// --- 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, margin = 0) => { + const b = sceneBBox(shape); + return ( + p.x >= b.left - margin && + p.x <= b.left + b.width + margin && + p.y >= b.top - margin && + p.y <= b.top + b.height + margin + ); +}; + +// 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; + 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 = + 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 }; +}; + +// --- 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; + +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) }; +}; + +// 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 +// 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); + // 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; + + setArrowEndpoints(arrow, tail, tip); + return true; +}; + +// --- bind on draw --------------------------------------------------------- +// Topmost bindable shape whose bbox contains `point` (scene coords), skipping +// `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 (skip.includes(o) || !isBindable(o)) continue; + if (bboxContainsPoint(o, point, BIND_MARGIN)) return o; + } + return null; +}; + +// 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) bindEnd(arrow, "start", startShape, tailScene); + if (endShape) bindEnd(arrow, "end", endShape, tipScene); + if (startShape || endShape) 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", + "startAnchor", + "endAnchor", + ...(propertiesToInclude || []), + ]); + }; +}; diff --git a/src/utils/binding.test.js b/src/utils/binding.test.js new file mode 100644 index 0000000..b29f614 --- /dev/null +++ b/src/utils/binding.test.js @@ -0,0 +1,253 @@ +import { fabric } from "fabric"; +import { + ensureId, + isBindable, + borderPoint, + boundArrows, + shapeUnderPoint, + rerouteArrow, + bindArrowOnDraw, + bindEnd, + unbindEnd, + arrowEndScene, + enableBindingPersistence, + BIND_MARGIN, +} 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); + }); + + test("an object flagged __nonBindable (e.g. the bind highlight) is skipped", () => { + expect(isBindable(rect({ __nonBindable: true }))).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 }); + }); + + 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", () => { + 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); + }); + + 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 + 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", () => { + 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); + }); + + 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", () => { + 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"); + }); +}); diff --git a/src/utils/bindingHighlight.js b/src/utils/bindingHighlight.js new file mode 100644 index 0000000..404ef39 --- /dev/null +++ b/src/utils/bindingHighlight.js @@ -0,0 +1,68 @@ +import { fabric } from "fabric"; + +// 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. + +// 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); + 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", + }); +}; + +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(); +}; + +export const clearBindHighlight = (canvas) => { + const arr = canvas && canvas.__bindHL; + if (arr && arr.length) { + arr.forEach((r) => canvas.remove(r)); + canvas.requestRenderAll(); + } + if (canvas) { + canvas.__bindHL = []; + canvas.__bindHLShapes = []; + } +}; 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); + }); +});