From 1762d9d14aa4d8b514916431a430d1e51e174f63 Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 10 May 2026 17:21:57 +0200 Subject: [PATCH 1/3] feat(canvas): unify selection visuals across all shapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract the previously-hardcoded indigo selection colour to CanvasColors.selection so per-shape strokes and the Konva Transformer share one source. Indigo stays (industry-standard for design tools); the UI accent (amber) remains separate on purpose — it would clash with B/W shape fills. - Style the Transformer's borderStroke / anchorStroke / anchorFill / anchorSize explicitly so all selection visuals are coherent. - Replace the line's solid-fill Circle endpoint handles with white Rect anchors that mirror the Transformer style (separate transparent hit-area Rect underneath); same look across line and other shapes. Inverted-shape rendering left as-is: the existing difference-blend body is print-correct (renders black on the white label, inverts darker shapes underneath), so no extra affordance is needed. --- src/components/Canvas/BarcodeObject.tsx | 12 +- src/components/Canvas/ImageObject.tsx | 6 +- src/components/Canvas/KonvaObject.tsx | 154 +++++++++++++++++++----- src/components/Canvas/LabelCanvas.tsx | 7 ++ src/components/Canvas/LineObject.tsx | 140 +++++++++++++++------ src/lib/useColorScheme.ts | 7 ++ src/registry/line.test.ts | 57 ++++++++- 7 files changed, 309 insertions(+), 74 deletions(-) diff --git a/src/components/Canvas/BarcodeObject.tsx b/src/components/Canvas/BarcodeObject.tsx index 7b10466d..7a79fa50 100644 --- a/src/components/Canvas/BarcodeObject.tsx +++ b/src/components/Canvas/BarcodeObject.tsx @@ -4,6 +4,7 @@ import { Image as KImage, Group, Rect, Text } from "react-konva"; import type Konva from "konva"; import { BARCODE_1D_TYPES, ObjectRegistry } from "../../registry"; import { dotsToPx, pxToDots } from "../../lib/coordinates"; +import { useColorScheme } from "../../lib/useColorScheme"; import type { KonvaObjectProps } from "./konvaObjectProps"; import { buildBwipOptions, @@ -36,6 +37,7 @@ export function BarcodeObject({ }: KonvaObjectProps) { const groupRef = useRef(null); const textRef = useRef(null); + const colors = useColorScheme(); // Exclude the HRI text from the parent Group's getClientRect. This anchors // the resize at the bar top (logmars: was anchoring at text top above bars) @@ -432,7 +434,7 @@ export function BarcodeObject({ width={bw} height={bh} imageSmoothingEnabled={false} - stroke={isSelected ? "#6366f1" : undefined} + stroke={isSelected ? colors.selection : undefined} strokeWidth={isSelected ? 2 : 0} strokeScaleEnabled={false} /> @@ -540,7 +542,7 @@ export function BarcodeObject({ width={bw} height={bh} imageSmoothingEnabled={false} - stroke={isSelected ? "#6366f1" : undefined} + stroke={isSelected ? colors.selection : undefined} strokeWidth={isSelected ? 2 : 0} strokeScaleEnabled={false} /> @@ -702,7 +704,7 @@ export function BarcodeObject({ @@ -745,7 +747,7 @@ export function BarcodeObject({ width={bw} height={bh} imageSmoothingEnabled={false} - stroke={isSelected ? "#6366f1" : undefined} + stroke={isSelected ? colors.selection : undefined} strokeWidth={isSelected ? 2 : 0} strokeScaleEnabled={false} /> @@ -773,7 +775,7 @@ export function BarcodeObject({ width={fbW} height={fbH} fill="#f9fafb" - stroke={isSelected ? "#6366f1" : "#9ca3af"} + stroke={isSelected ? colors.selection : "#9ca3af"} strokeWidth={isSelected ? 2 : 1} dash={isSelected ? undefined : [4, 2]} /> diff --git a/src/components/Canvas/ImageObject.tsx b/src/components/Canvas/ImageObject.tsx index cc21e8a3..3967ad51 100644 --- a/src/components/Canvas/ImageObject.tsx +++ b/src/components/Canvas/ImageObject.tsx @@ -4,6 +4,7 @@ import type Konva from "konva"; import type { LabelObject } from "../../registry"; import { dotsToPx, pxToDots } from "../../lib/coordinates"; import { getImage } from "../../lib/imageCache"; +import { useColorScheme } from "../../lib/useColorScheme"; import type { KonvaObjectProps } from "./konvaObjectProps"; type ImageLabelObject = Extract; @@ -25,6 +26,7 @@ export function ImageObject({ snap, }: Props) { const p = obj.props; + const colors = useColorScheme(); const cached = getImage(p.imageId); const w = dotsToPx(p.widthDots, scale, dpmm); // Guard against a 0-width cached image: the imageCache pipeline @@ -90,7 +92,7 @@ export function ImageObject({ image={htmlImg} width={w} height={h} - stroke={isSelected ? "#6366f1" : undefined} + stroke={isSelected ? colors.selection : undefined} strokeWidth={isSelected ? 2 : 0} draggable onClick={(e) => @@ -120,7 +122,7 @@ export function ImageObject({ width={w} height={h} fill="#f9fafb" - stroke={isSelected ? "#6366f1" : "#9ca3af"} + stroke={isSelected ? colors.selection : "#9ca3af"} strokeWidth={isSelected ? 2 : 1} dash={[4, 2]} /> diff --git a/src/components/Canvas/KonvaObject.tsx b/src/components/Canvas/KonvaObject.tsx index f2f37e08..114db650 100644 --- a/src/components/Canvas/KonvaObject.tsx +++ b/src/components/Canvas/KonvaObject.tsx @@ -6,6 +6,7 @@ import { LineObject } from "./LineObject"; import { ImageObject } from "./ImageObject"; import type Konva from "konva"; import { dotsToPx, pxToDots } from "../../lib/coordinates"; +import { useColorScheme } from "../../lib/useColorScheme"; import { objectToDisplay, displayToObject, @@ -15,6 +16,41 @@ import type { KonvaObjectProps } from "./konvaObjectProps"; type Props = KonvaObjectProps; +/** + * Selection outline drawn as a separate (non-listening) overlay so the + * underlying shape can keep its own stroke / fill / globalCompositeOperation + * without compromise. Sits at local (0, 0) inside the parent Group so it + * follows drag translations together with the body. + */ +function SelectionOverlay({ + width, + height, + strokeWidth, + color, + cornerRadius, +}: { + width: number; + height: number; + strokeWidth: number; + color: string; + cornerRadius?: number; +}) { + return ( + + ); +} + const BARCODE_TYPES = new Set([ "code128", "code39", @@ -54,6 +90,28 @@ export function KonvaObject(props_: Props) { return ; } +/** + * Per-type Konva renderer dispatcher (`KonvaObject` above narrows by + * `obj.type` and routes to the right component / case). + * + * Convention for adding a new shape type: + * + * 1. `id={obj.id}` sits on the **outermost render node**. Single-node + * shapes (e.g. plain Text, Ellipse, Circle) put it on that shape; + * multi-node shapes (Text+reverse, Box, Image, Line) wrap their + * parts in a `` and put the id there. Stage-level lookups + * (`stage.findOne(#id)`, snap, alt+click cycle) all walk up to the + * id'd ancestor, so this stays consistent. + * + * 2. Selection visuals: a single shape can put its own selection stroke + * on itself (`stroke={isSelected ? colors.selection : ...}`). A + * shape whose body uses `globalCompositeOperation: "difference"` + * for ZPL `^LRY` (currently Box and Line) needs an extra + * `` Rect drawn with normal blending, so the + * selection stroke isn't itself blended into a wrong colour. The + * overlay sits inside the same Group as the body so drag + * translations move both together. + */ function KonvaObjectInner({ obj, scale, @@ -66,6 +124,7 @@ function KonvaObjectInner({ snap, }: Props) { useFontCacheVersion(); + const colors = useColorScheme(); // For text/serial, ^FT (baseline) needs converting to Konva's top-left // anchor and the rotation introduces a 15-dot alignment offset. The // helper handles both; non-text types pass through unchanged. @@ -145,7 +204,7 @@ function KonvaObjectInner({ width={approxW} height={approxH} fill="#000000" - stroke={isSelected ? "#6366f1" : undefined} + stroke={isSelected ? colors.selection : undefined} strokeWidth={isSelected ? 1.5 : 0} /> @@ -207,7 +266,7 @@ function KonvaObjectInner({ fontStyle="bold" rotation={zplRotationDeg[p.rotation]} fill="#000000" - stroke={isSelected ? "#6366f1" : undefined} + stroke={isSelected ? colors.selection : undefined} strokeWidth={isSelected ? 1 : 0} draggable onClick={(e) => @@ -228,33 +287,55 @@ function KonvaObjectInner({ const cornerRadius = p.rounding * dotsToPx(Math.min(p.width, p.height) / 8, scale, dpmm); - const useReverse = !isSelected && p.reverse; - const stroke = useReverse - ? "#ffffff" - : p.color === "B" - ? "#000000" - : "#cccccc"; - const fill = useReverse + // Inverted (^LRY) regions print as a knockout. The difference-blend + // body renders print-correctly: on the white label it produces black + // (white-on-white inverted = black ink in print), and over darker + // shapes it inverts those pixels — matching what Zebra firmware + // actually prints. The body keeps that mode even while selected so + // the inversion visualisation doesn't disappear and hide whatever + // is layered behind. The selection outline is rendered as a separate + // overlay rect with normal blending. + // + // Special-cases: + // - reverse + filled drops the body stroke. Konva renders fill then + // stroke; with the difference blend the fill flips the destination + // to black and the (white) stroke then flips back to white inside + // the rect, producing a b/w/b banding artefact. The stroke and + // fill carry the same colour anyway so dropping it is visually + // identical without the artefact. + // - colour W filled (non-reverse) uses the light-grey shape colour + // for the fill too, otherwise white-on-white would make filled + // and outlined indistinguishable on canvas. + const isReverse = !!p.reverse; + const shapeColor = p.color === "B" ? "#000000" : "#cccccc"; + const stroke = isReverse + ? p.filled + ? "transparent" + : "#ffffff" + : shapeColor; + const fill = isReverse ? p.filled ? "#ffffff" : "transparent" : p.filled - ? p.color === "B" - ? "#000000" - : "#ffffff" + ? shapeColor : "transparent"; + // Wrap body + selection overlay in a draggable Group so both move + // together during a drag — without this the selection-stroke rect + // stays at the start position while the body translates, leaving a + // visible ghost outline behind the moving box until drag-end. + // id sits on the Group (not the inner Rect) so onTransformEnd reads the + // Group's absolute position via node.x()/y(); putting id on the Rect + // would return its local (0, 0) and the post-resize commit would land + // off-canvas. The Transformer + altClickCycle + snap all walk up to + // the id'd ancestor, so finding the Group via findOne(#id) is fine. + // Group.width/height defaults to 0; commitWidthHeightTransform doesn't + // need them (it uses obj.props.width * sx). return ( - onSelect(e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey) @@ -262,10 +343,29 @@ function KonvaObjectInner({ onTap={() => onSelect(false)} onDragMove={handleDragMove} onDragEnd={handleDragEnd} - globalCompositeOperation={ - !isSelected && p.reverse ? "difference" : "source-over" - } - /> + > + + {isSelected && ( + + )} + ); } @@ -287,7 +387,7 @@ function KonvaObjectInner({ y={y + ry} radiusX={rx} radiusY={ry} - stroke={isSelected ? "#6366f1" : stroke} + stroke={isSelected ? colors.selection : stroke} strokeWidth={isSelected ? Math.max(strokeWidth, 1.5) : strokeWidth} strokeScaleEnabled={false} fill={fill} @@ -327,7 +427,7 @@ function KonvaObjectInner({ x={x + r} y={y + r} radius={r} - stroke={isSelected ? "#6366f1" : stroke} + stroke={isSelected ? colors.selection : stroke} strokeWidth={isSelected ? Math.max(strokeWidth, 1.5) : strokeWidth} strokeScaleEnabled={false} fill={fill} diff --git a/src/components/Canvas/LabelCanvas.tsx b/src/components/Canvas/LabelCanvas.tsx index 91470e32..dba9ff19 100644 --- a/src/components/Canvas/LabelCanvas.tsx +++ b/src/components/Canvas/LabelCanvas.tsx @@ -721,6 +721,13 @@ export const LabelCanvas = forwardRef(function LabelCa onTransformStart={onTransformStart} boundBoxFunc={boundBoxFunc} onTransformEnd={onTransformEnd} + // Match the per-shape selection stroke and the line endpoint + // handles so all selection visuals share one colour. + borderStroke={colors.selection} + anchorStroke={colors.selection} + anchorFill="#ffffff" + anchorSize={7} + anchorStrokeWidth={1} // Exclude selection stroke from the bbox; otherwise scale-aware // stroke padding leaks into the resize math and produces sub-dot // drift in node.x()/y() that surfaces as 1-dot ZPL coordinate diff --git a/src/components/Canvas/LineObject.tsx b/src/components/Canvas/LineObject.tsx index f5a2a160..a011db7d 100644 --- a/src/components/Canvas/LineObject.tsx +++ b/src/components/Canvas/LineObject.tsx @@ -1,10 +1,17 @@ import { useState } from "react"; -import { Circle, Group, Line as KLine } from "react-konva"; +import { Group, Line as KLine, Rect } from "react-konva"; import type { LabelObject } from "../../registry"; import { dotsToPx, pxToDots } from "../../lib/coordinates"; import { constrainLine, type ConstrainMode } from "../../lib/lineConstrain"; +import { useColorScheme } from "../../lib/useColorScheme"; import type { KonvaObjectProps } from "./konvaObjectProps"; +/** Endpoint-handle visuals — small white square with a thin selection + * stroke, mirroring the look of the Konva Transformer's anchors. The + * hit area is a separate, larger transparent square. */ +const HANDLE_VISIBLE_SIZE = 7; +const HANDLE_HIT_SIZE = 14; + type LineLabelObject = Extract; type Props = Omit & { obj: LineLabelObject }; @@ -24,6 +31,7 @@ export function LineObject({ snap, }: Props) { const p = obj.props; + const colors = useColorScheme(); // All positions are absolute stage coordinates — the Group has no offset. // This eliminates any parent-child draggable conflict. const x1 = offsetX + dotsToPx(obj.x, scale, dpmm); @@ -33,13 +41,17 @@ export function LineObject({ const x2 = x1 + lenPx * Math.cos(rad); const y2 = y1 + lenPx * Math.sin(rad); - // ^LR uses difference blend with white: white over white bg = black, white over black text = white - const strokeColor = - !isSelected && p.reverse - ? "#ffffff" - : p.color === "B" - ? "#000000" - : "#cccccc"; + // Reverse (^LRY) uses a difference-blend body — print-correct: renders + // black on the white label and inverts darker shapes underneath. The + // body keeps that mode even while selected so the inversion visual + // doesn't disappear and hide whatever is layered behind; the selection + // highlight is rendered as a separate overlay below. + const isReverse = !!p.reverse; + const strokeColor = isReverse + ? "#ffffff" + : p.color === "B" + ? "#000000" + : "#cccccc"; const lineStrokeWidth = Math.max(dotsToPx(p.thickness, scale, dpmm), 1); // Live positions while handles are being dragged (snapped preview) @@ -107,17 +119,28 @@ export function LineObject({ return ( - {/* Visible line — tracks both whole-drag and handle-drag live */} + {/* Visible line — tracks both whole-drag and handle-drag live. + Difference blend keeps the reverse case print-correct: on the + white label it renders black, over darker shapes it inverts + those pixels. Stays in reverse mode even when selected so the + inversion visualisation isn't masked. */} + {isSelected && ( + + )} {/* Wide transparent hit area — handles click-to-select and whole-line drag. id is here (not on the Group) so the Stage snap handler can find this node via e.target.id() and apply object-snap correctly. */} @@ -157,32 +180,43 @@ export function LineObject({ /> {isSelected && ( <> - {/* Start point — dragging moves the origin; end point stays fixed */} - { const endDotX = pxToDots(x2 - offsetX, scale, dpmm); const endDotY = pxToDots(y2 - offsetY, scale, dpmm); const r = project( - e.target.x(), - e.target.y(), + e.target.x() + HANDLE_HIT_SIZE / 2, + e.target.y() + HANDLE_HIT_SIZE / 2, endDotX, endDotY, true, e.evt.shiftKey, ); - e.target.position(r.movingPx); + e.target.position({ + x: r.movingPx.x - HANDLE_HIT_SIZE / 2, + y: r.movingPx.y - HANDLE_HIT_SIZE / 2, + }); setLivePt1(r.movingPx); }} onDragEnd={(e) => { - const cursor = livePt1 ?? { x: e.target.x(), y: e.target.y() }; - e.target.position({ x: x1 + dx, y: y1 + dy }); + const cursor = livePt1 ?? { + x: e.target.x() + HANDLE_HIT_SIZE / 2, + y: e.target.y() + HANDLE_HIT_SIZE / 2, + }; + e.target.position({ + x: x1 + dx - HANDLE_HIT_SIZE / 2, + y: y1 + dy - HANDLE_HIT_SIZE / 2, + }); setLivePt1(null); const endDotX = pxToDots(x2 - offsetX, scale, dpmm); const endDotY = pxToDots(y2 - offsetY, scale, dpmm); @@ -201,30 +235,48 @@ export function LineObject({ }); }} /> + {/* End point — dragging changes length & angle */} - { const r = project( - e.target.x(), - e.target.y(), + e.target.x() + HANDLE_HIT_SIZE / 2, + e.target.y() + HANDLE_HIT_SIZE / 2, obj.x, obj.y, false, e.evt.shiftKey, ); - e.target.position(r.movingPx); + e.target.position({ + x: r.movingPx.x - HANDLE_HIT_SIZE / 2, + y: r.movingPx.y - HANDLE_HIT_SIZE / 2, + }); setLivePt2(r.movingPx); }} onDragEnd={(e) => { - const cursor = livePt2 ?? { x: e.target.x(), y: e.target.y() }; - e.target.position({ x: x2 + dx, y: y2 + dy }); + const cursor = livePt2 ?? { + x: e.target.x() + HANDLE_HIT_SIZE / 2, + y: e.target.y() + HANDLE_HIT_SIZE / 2, + }; + e.target.position({ + x: x2 + dx - HANDLE_HIT_SIZE / 2, + y: y2 + dy - HANDLE_HIT_SIZE / 2, + }); setLivePt2(null); const r = project( cursor.x, @@ -237,6 +289,16 @@ export function LineObject({ onChange({ props: { length: r.length, angle: r.angle } }); }} /> + )} diff --git a/src/lib/useColorScheme.ts b/src/lib/useColorScheme.ts index c3d53b4d..18d0c840 100644 --- a/src/lib/useColorScheme.ts +++ b/src/lib/useColorScheme.ts @@ -11,6 +11,11 @@ export interface CanvasColors { rulerMinorTick: string; rulerLabel: string; rulerSeparator: string; + /** Selection stroke / handle colour for shapes and the Konva Transformer. + * Distinct from the UI accent (amber) on purpose — design tools follow + * a blue-ish convention here (Figma, Sketch, Illustrator) to keep + * contrast usable across the B/W shape colour space. */ + selection: string; } export const DARK_COLORS: CanvasColors = { @@ -24,6 +29,7 @@ export const DARK_COLORS: CanvasColors = { rulerMinorTick: '#686868', rulerLabel: '#cccccc', rulerSeparator: '#2a2a2a', + selection: '#6366f1', }; export const LIGHT_COLORS: CanvasColors = { @@ -37,6 +43,7 @@ export const LIGHT_COLORS: CanvasColors = { rulerMinorTick: '#71717a', rulerLabel: '#27272a', rulerSeparator: '#d4d4d8', + selection: '#6366f1', }; export function useColorScheme(): CanvasColors { diff --git a/src/registry/line.test.ts b/src/registry/line.test.ts index e2f1148d..387bd910 100644 --- a/src/registry/line.test.ts +++ b/src/registry/line.test.ts @@ -1,5 +1,23 @@ import { describe, it, expect } from "vitest"; -import { pickAngle } from "./line"; +import { pickAngle, line } from "./line"; +import type { LabelObject } from "./index"; + +const makeLine = (overrides: Partial<{ + x: number; y: number; angle: number; length: number; thickness: number; +}> = {}) => ({ + id: "test", + type: "line" as const, + positionType: "FO" as const, + x: overrides.x ?? 0, + y: overrides.y ?? 0, + rotation: 0 as const, + props: { + angle: overrides.angle ?? 0, + length: overrides.length ?? 100, + thickness: overrides.thickness ?? 3, + color: "B" as const, + }, +}) as Extract; describe("pickAngle", () => { describe("nearest-candidate behaviour at viewRotation=0", () => { @@ -80,3 +98,40 @@ describe("pickAngle", () => { }); }); }); + +describe("line.toZPL — thickness only affects perpendicular dimension", () => { + // Regression: thickness must change only the line's perpendicular extent + // (the ^GB rectangle's "short" side). The user-visible line length and + // start position must stay identical at any thickness so changing the + // stroke width doesn't accidentally extend the line. + + it("horizontal line: width param = length, height param = thickness", () => { + expect(line.toZPL(makeLine({ x: 50, y: 100, length: 200, thickness: 3 }))) + .toBe("^FO50,100^GB200,3,3,B,0^FS"); + expect(line.toZPL(makeLine({ x: 50, y: 100, length: 200, thickness: 20 }))) + .toBe("^FO50,100^GB200,20,20,B,0^FS"); + // Length param identical; only thickness changed. + }); + + it("vertical line: width param = thickness, height param = length", () => { + expect(line.toZPL(makeLine({ x: 50, y: 100, length: 200, thickness: 3, angle: 90 }))) + .toBe("^FO50,100^GB3,200,3,B,0^FS"); + expect(line.toZPL(makeLine({ x: 50, y: 100, length: 200, thickness: 20, angle: 90 }))) + .toBe("^FO50,100^GB20,200,20,B,0^FS"); + }); + + it("changing thickness leaves the line's stored start position unchanged", () => { + const thin = line.toZPL(makeLine({ x: 50, y: 100, length: 200, thickness: 1 })); + const thick = line.toZPL(makeLine({ x: 50, y: 100, length: 200, thickness: 30 })); + // Both share the same ^FO start coordinate. + expect(thin).toMatch(/^\^FO50,100/); + expect(thick).toMatch(/^\^FO50,100/); + }); + + it("180° horizontal line shifts FO left by length but doesn't grow with thickness", () => { + expect(line.toZPL(makeLine({ x: 250, y: 100, length: 200, thickness: 3, angle: 180 }))) + .toBe("^FO50,100^GB200,3,3,B,0^FS"); + expect(line.toZPL(makeLine({ x: 250, y: 100, length: 200, thickness: 25, angle: 180 }))) + .toBe("^FO50,100^GB200,25,25,B,0^FS"); + }); +}); From d23aadf3430062a4a78749a392eaa1f5b5d54db6 Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 10 May 2026 18:38:39 +0200 Subject: [PATCH 2/3] fix(canvas): box bottom-edge resize drifts upward at low zoom / rotated view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause traced to a sub-pixel threshold in applyHeightSnap (introduced in be6f2b8): the half-dot tolerance used to detect a top-anchor resize becomes < 1 screen pixel at low zoom (1 dot < 2 px), Konva's per-frame scale-driven node-position FP noise easily exceeds it, every frame is falsely flagged as a top-anchor drag, and pinBottomEdge marches the box up out of the work area. A second false positive on deriveActiveEdges (0.5 px default tolerance) lets the resize-time object-snap pull a bottom-edge resize sideways. Fixes: - applyHeightSnap is now a no-op for shapes without a row anchor. Boxes and 1D barcodes don't need per-frame integer-dot snapping during the drag — onTransformEnd already rounds via the global snap function. Only stacked-2D barcodes (PDF417 / MicroPDF417 / Codablock), where a non-integer row count is invalid, keep the row-quantising path with pinBottomEdge intact. - deriveActiveEdges default tolerance bumped from 0.5 px to 2 px so FP drift on a single-edge drag doesn't flip a stationary edge to active and trigger a sideways snap. - New pinInactiveEdges enforces the resize invariant explicitly: edges the user did NOT grab (>2 px from start) are restored to their start-of-drag positions after every boundBoxFunc pass. - transformStartBboxRef is captured lazily on the first boundBoxFunc call (using its oldBox) instead of in onTransformStart via getClientRect. On a rotated parent group those two coord systems diverge; capturing the start in the same frame Konva emits keeps the pin / activeEdges math consistent. - When viewRotation is non-zero, the whole height-snap / object-snap / pin pipeline is skipped — Konva's bbox semantics there don't map onto an axis-aware model. Native resize is used; smart-align during resize is disabled in rotated views (drag-time smart-align unaffected). Adds 3 regression tests for applyHeightSnap covering the noop / row-snap / top-anchor pin paths. --- src/components/Canvas/LabelCanvas.tsx | 1 + .../Canvas/hooks/useKonvaTransformer.ts | 81 ++++++++------ .../Canvas/transformerGeometry.test.ts | 105 ++++++++++++++++++ src/components/Canvas/transformerGeometry.ts | 91 +++++++++++++++ src/lib/snapGuides.ts | 7 +- 5 files changed, 252 insertions(+), 33 deletions(-) diff --git a/src/components/Canvas/LabelCanvas.tsx b/src/components/Canvas/LabelCanvas.tsx index dba9ff19..392a8446 100644 --- a/src/components/Canvas/LabelCanvas.tsx +++ b/src/components/Canvas/LabelCanvas.tsx @@ -352,6 +352,7 @@ export const LabelCanvas = forwardRef(function LabelCa labelRect: transformerSnapLabelRect, objectSnapEnabled: !snapEnabled, setGuides, + viewRotation, }); const handleObjectChange = ( diff --git a/src/components/Canvas/hooks/useKonvaTransformer.ts b/src/components/Canvas/hooks/useKonvaTransformer.ts index fc104e08..0007fc30 100644 --- a/src/components/Canvas/hooks/useKonvaTransformer.ts +++ b/src/components/Canvas/hooks/useKonvaTransformer.ts @@ -6,9 +6,8 @@ import { BARCODE_1D_TYPES, STACKED_2D_TYPES, ObjectRegistry } from "../../../reg import type { LabelObject } from "../../../registry"; import type { ObjectChanges } from "../../../store/labelStore"; import { - snapBoxHeight, - pinBottomEdge, - isTopAnchorResize, + applyHeightSnap, + pinInactiveEdges, transformNodeTopLeft, positionDidMove, forceSquareBox, @@ -52,27 +51,6 @@ function captureOtherRects( return result; } -/** - * Phase 1 of resize: snap height to whole rowHeight increments for stacked-2D - * barcodes (or to whole dots otherwise), and pin the bottom edge if the resize - * originates from the top anchor. - */ -function applyHeightSnap( - oldBox: BoundingBox, - newBox: BoundingBox, - dotPx: number, - anchor: { nodeHeight: number; rowHeight: number } | null, -): BoundingBox { - const stepPx = - anchor && anchor.rowHeight > 0 && anchor.nodeHeight > 0 - ? anchor.nodeHeight / anchor.rowHeight - : dotPx; - const snappedH = snapBoxHeight(newBox.height, stepPx); - return isTopAnchorResize(oldBox, newBox, dotPx * 0.5) - ? pinBottomEdge(oldBox, newBox, snappedH) - : { ...newBox, height: snappedH }; -} - /** * Phase 2 of resize: snap moving edges to other objects' / label edges * (object-snap, mirrors drag-time snap). Pure delegation to `computeResizeSnap` @@ -110,6 +88,10 @@ interface Options { objectSnapEnabled: boolean; /** Pushes resize-time snap guides into the canvas's shared guide state. */ setGuides: (guides: SnapGuide[]) => void; + /** Canvas view rotation. When non-zero, the parent group is rotated and + * Konva's bbox semantics in boundBoxFunc no longer match our axis-aware + * snap / pin helpers — we fall back to native Konva resize there. */ + viewRotation: number; } export interface TransformerState { @@ -135,6 +117,7 @@ export function useKonvaTransformer({ labelRect, objectSnapEnabled, setGuides, + viewRotation, }: Options): TransformerState { // Captures node height and rowHeight at drag start so boundBoxFunc uses a // fixed step size throughout the entire drag session. @@ -230,8 +213,10 @@ export function useKonvaTransformer({ transformAnchorRef.current = obj && STACKED_2D_TYPES.has(obj.type) ? { nodeHeight: node.height(), rowHeight: (obj.props as { rowHeight: number }).rowHeight } : null; - const startRect = node.getClientRect({ skipShadow: true, skipStroke: true, relativeTo: stageRef.current }); - transformStartBboxRef.current = toSnapRect(singleId, startRect); + // startBbox is captured lazily on the first boundBoxFunc call — Konva + // passes those bboxes in the transformer's frame, which on rotated + // parents differs from getClientRect's stage frame. + transformStartBboxRef.current = null; othersSnapshotRef.current = captureOtherRects(stageRef.current, objects, singleId); }; @@ -240,19 +225,51 @@ export function useKonvaTransformer({ return oldBox; } if (isUniformScale) newBox = forceSquareBox(oldBox, newBox); + // When the canvas view is rotated, Konva's bbox semantics in this + // callback no longer match an axis-aware snap / pin model — visual + // bottom-edge becomes node-local-left etc. Skip the height snap, + // object-snap and inactive-edge pin in that case and let Konva + // resize natively. Stacked-2D row quantisation only matters when + // not rotated anyway. + // + // Latent coord-frame mismatch: at viewRotation=0 the transformer-frame + // bboxes (oldBox/newBox here, and the lazily-captured startBbox below) + // coincide with stage coords, which is the same frame othersSnapshotRef + // and labelRect use. Removing this early-return without first lifting + // the others / labelRect snapshot into the transformer frame would + // re-introduce the rotated-resize drift this whole block guards against. + if (viewRotation !== 0) return newBox; const dotPx = scale / dpmm; - let bbox = applyHeightSnap(oldBox, newBox, dotPx, transformAnchorRef.current); - - // Object-snap during resize (mirrors drag-time snap). Only fires when - // grid-snap is off and the user is doing a free corner-resize — the 1D / - // stacked-2D anchor restrictions have their own height math above and - // would conflict with edge-driven snapping. + if (!transformStartBboxRef.current) { + transformStartBboxRef.current = { + id: selectedIds[0] ?? "", + x: oldBox.x, + y: oldBox.y, + width: oldBox.width, + height: oldBox.height, + }; + } const startBbox = transformStartBboxRef.current; + let bbox = applyHeightSnap(oldBox, newBox, dotPx, transformAnchorRef.current); if (objectSnapEnabled && isFreeResize && startBbox) { const snapped = applyResizeObjectSnap(bbox, startBbox, othersSnapshotRef.current, labelRect); setGuides(snapped.guides); bbox = snapped.bbox; } + if (startBbox) { + const startBox: BoundingBox = { + x: startBbox.x, + y: startBbox.y, + width: startBbox.width, + height: startBbox.height, + rotation: 0, + }; + const activeEdges = deriveActiveEdges(startBbox, { + id: startBbox.id, + x: bbox.x, y: bbox.y, width: bbox.width, height: bbox.height, + }); + bbox = pinInactiveEdges(bbox, startBox, activeEdges); + } return bbox; }; diff --git a/src/components/Canvas/transformerGeometry.test.ts b/src/components/Canvas/transformerGeometry.test.ts index 834fe77d..693c41a5 100644 --- a/src/components/Canvas/transformerGeometry.test.ts +++ b/src/components/Canvas/transformerGeometry.test.ts @@ -6,6 +6,9 @@ import { transformNodeTopLeft, positionDidMove, forceSquareBox, + applyHeightSnap, + pinInactiveEdges, + type BoundingBox, } from "./transformerGeometry"; describe("snapBoxHeight", () => { @@ -95,6 +98,108 @@ describe("positionDidMove", () => { }); }); +describe("applyHeightSnap", () => { + // Captured from a real low-zoom box bottom-edge resize that triggered + // the runaway top-anchor pin. dotPx ≈ 0.116 (≈ 4.3 px / dot at fit-zoom), + // newBox.y drifted 0.8–10 px from oldBox.y due to Konva FP scale-driven + // node-position updates — comfortably above the prior `dotPx * 0.5` + // threshold, which incorrectly identified each frame as top-anchor and + // pinned the bottom edge, marching the box upward. + const oldBoxLowZoom = { x: 100, y: 346.809, width: 50, height: 27.426, rotation: 0 }; + const newBoxLowZoom = { x: 100, y: 347.611, width: 50, height: 27.826, rotation: 0 }; + const dotPxLowZoom = 0.232; // threshold dotPx * 0.5 = 0.116 → false positive + + it("returns newBox unchanged for shapes without a row anchor (regression: low-zoom drift)", () => { + const result = applyHeightSnap(oldBoxLowZoom, newBoxLowZoom, dotPxLowZoom, null); + expect(result).toEqual(newBoxLowZoom); + }); + + it("row-quantises the height for stacked-2D barcodes with a row anchor", () => { + // anchor: nodeHeight=100, rowHeight=20 → stepPx = 5 + const anchor = { nodeHeight: 100, rowHeight: 20 }; + const oldBox = { x: 0, y: 0, width: 50, height: 100, rotation: 0 }; + const newBox = { x: 0, y: 0, width: 50, height: 113, rotation: 0 }; + const result = applyHeightSnap(oldBox, newBox, 1, anchor); + expect(result.height).toBe(115); // 113 rounds up to next 5-multiple + expect(result.y).toBe(0); + }); + + it("pins the bottom edge for stacked-2D top-anchor resize", () => { + const anchor = { nodeHeight: 100, rowHeight: 20 }; + const oldBox = { x: 0, y: 0, width: 50, height: 100, rotation: 0 }; + // Top moves UP by 30 → top-anchor resize + const newBox = { x: 0, y: -30, width: 50, height: 130, rotation: 0 }; + const result = applyHeightSnap(oldBox, newBox, 1, anchor); + expect(result.height).toBe(130); + // Bottom stays where it was (oldBox.y + oldBox.height = 100) + expect(result.y + result.height).toBe(oldBox.y + oldBox.height); + }); +}); + +describe("pinInactiveEdges + applyHeightSnap (multi-frame regression)", () => { + // Simulates a real low-zoom bottom-edge drag where Konva's per-frame + // node-position updates drift the y-coordinate sub-pixel. Each frame: + // 1. applyHeightSnap (no row anchor → no-op) + // 2. derive active edges against the lazily-captured start bbox + // 3. pinInactiveEdges restores stationary edges to the start bbox + // The invariant under test: the top-edge stays at start.y across all + // frames, regardless of Konva's drifted newBox.y. + + // Captured from the buggy reproduction at viewRotation=0, low zoom: + // Konva's per-frame y drifts by < 2 px on a pure bottom-edge drag + // (above 2 px would be a real intentional top-edge drag). + const start = { x: 100, y: 200, width: 80, height: 50, rotation: 0 }; + const driftedFrames = [ + { y: 200.4, height: 51.2 }, + { y: 200.9, height: 52.7 }, + { y: 201.5, height: 54.0 }, + { y: 199.8, height: 55.3 }, + { y: 201.2, height: 56.1 }, + { y: 200.6, height: 57.4 }, + ]; + const dotPx = 0.232; // representative low-zoom value (≈ 4.3 dots / px) + + it("keeps the top edge pinned to startBox.y across drifting frames", () => { + let oldBox: BoundingBox = start; + for (const frame of driftedFrames) { + const newBox: BoundingBox = { ...oldBox, y: frame.y, height: frame.height }; + const afterSnap = applyHeightSnap(oldBox, newBox, dotPx, null); + const active = { + left: Math.abs(afterSnap.x - start.x) > 2, + right: Math.abs((afterSnap.x + afterSnap.width) - (start.x + start.width)) > 2, + top: Math.abs(afterSnap.y - start.y) > 2, + bottom: Math.abs((afterSnap.y + afterSnap.height) - (start.y + start.height)) > 2, + }; + const pinned = pinInactiveEdges(afterSnap, start, active); + // Top edge invariant: stays at start.y (within float-cmp tolerance). + expect(pinned.y).toBe(start.y); + // Width and x untouched (no horizontal drag). + expect(pinned.x).toBe(start.x); + expect(pinned.width).toBe(start.width); + // Height must reflect the grown bottom edge. + expect(pinned.height).toBeGreaterThanOrEqual(start.height); + oldBox = pinned; + } + }); + + it("does not let cumulative drift carry the box out of frame", () => { + // After 6 frames of drift, top must still be at start.y, not drifted up. + let oldBox: BoundingBox = start; + for (const frame of driftedFrames) { + const newBox: BoundingBox = { ...oldBox, y: frame.y, height: frame.height }; + const afterSnap = applyHeightSnap(oldBox, newBox, dotPx, null); + const active = { + left: false, right: false, + top: Math.abs(afterSnap.y - start.y) > 2, + bottom: Math.abs((afterSnap.y + afterSnap.height) - (start.y + start.height)) > 2, + }; + oldBox = pinInactiveEdges(afterSnap, start, active); + } + // Final state: top has not drifted; bottom is wherever the user dragged. + expect(oldBox.y).toBe(start.y); + }); +}); + describe("forceSquareBox", () => { const oldBox = { x: 100, y: 100, width: 50, height: 50, rotation: 0 }; diff --git a/src/components/Canvas/transformerGeometry.ts b/src/components/Canvas/transformerGeometry.ts index 8f0f85bf..51bd3750 100644 --- a/src/components/Canvas/transformerGeometry.ts +++ b/src/components/Canvas/transformerGeometry.ts @@ -71,6 +71,38 @@ export function transformNodeTopLeft( return { x: nodeX - dx, y: nodeY - dy }; } +/** + * Phase 1 of resize: row-quantise the height for stacked-2D barcodes + * (PDF417, MicroPDF417, Codablock) where a non-integer row count is + * invalid. Pins the bottom edge if the resize comes from the top anchor + * so the dragged edge tracks the cursor. + * + * No-op for non-stacked-2D shapes — applying a height-snap there used to + * trigger pinBottomEdge whenever Konva's frame-to-frame y drifted past a + * sub-pixel threshold (low zoom = 1 dot < 1 screen pixel), compounding + * into a runaway top-anchor pin that marched the box out of the work + * area. Boxes / barcodes that don't need row-quantised heights run + * unsnapped here; rounding happens in the global onTransformEnd snap. + */ +export interface RowAnchor { + nodeHeight: number; + rowHeight: number; +} + +export function applyHeightSnap( + oldBox: BoundingBox, + newBox: BoundingBox, + dotPx: number, + anchor: RowAnchor | null, +): BoundingBox { + if (!anchor || anchor.rowHeight <= 0 || anchor.nodeHeight <= 0) return newBox; + const stepPx = anchor.nodeHeight / anchor.rowHeight; + const snappedH = snapBoxHeight(newBox.height, stepPx); + return isTopAnchorResize(oldBox, newBox, dotPx * 0.5) + ? pinBottomEdge(oldBox, newBox, snappedH) + : { ...newBox, height: snappedH }; +} + /** * Tolerance for `positionDidMove`. Sized to absorb float rounding from the * screen-pixel <-> dot conversion; anything within this margin counts as @@ -78,6 +110,65 @@ export function transformNodeTopLeft( */ export const POSITION_MOVE_TOLERANCE_DOTS = 1; +export interface ActiveEdgeFlags { + left: boolean; + right: boolean; + top: boolean; + bottom: boolean; +} + +/** + * Enforce the resize-invariant: edges the user did NOT grab stay at their + * start-of-drag positions. Konva's per-frame scale-driven node-position + * updates can drift sub-pixel for "stationary" edges even on a pure + * single-edge drag — without this, those drifts compound and the box + * walks away from where the user wanted it pinned. + * + * - Both side-edges inactive → restore start.x and start.width. + * - Only one side-edge active → keep the moving edge's current position + * and extend the size from the corresponding pinned start-edge. + * - Both active (e.g. uniform-scale corner drag) → pass through. + * + * Same logic on the y axis. + */ +export function pinInactiveEdges( + bbox: BoundingBox, + startBox: BoundingBox, + active: ActiveEdgeFlags, +): BoundingBox { + let { x, y, width, height } = bbox; + + if (!active.left && !active.right) { + x = startBox.x; + width = startBox.width; + } else if (!active.left) { + // right edge moves; left is pinned at start + const newRight = x + width; + x = startBox.x; + width = Math.max(0, newRight - x); + } else if (!active.right) { + // left edge moves; right is pinned at start + const startRight = startBox.x + startBox.width; + width = Math.max(0, startRight - x); + } + + if (!active.top && !active.bottom) { + y = startBox.y; + height = startBox.height; + } else if (!active.top) { + // bottom edge moves; top is pinned at start + const newBottom = y + height; + y = startBox.y; + height = Math.max(0, newBottom - y); + } else if (!active.bottom) { + // top edge moves; bottom is pinned at start + const startBottom = startBox.y + startBox.height; + height = Math.max(0, startBottom - y); + } + + return { ...bbox, x, y, width, height }; +} + /** * Decide whether the resize actually moved the object. When the user drags * a handle whose opposite anchor is the top-left, the position is visually diff --git a/src/lib/snapGuides.ts b/src/lib/snapGuides.ts index 8bf49627..23a7b931 100644 --- a/src/lib/snapGuides.ts +++ b/src/lib/snapGuides.ts @@ -250,11 +250,16 @@ export interface ResizeSnapResult { * Derive which edges of `newBox` are moving relative to `oldBox`. Used to * decide which edges should participate in resize-time snapping (the static * edges have nothing to align). + * + * The default tolerance is 2 screen pixels so Konva's per-frame FP scale- + * driven node-position drift (sub-pixel on a pure single-edge drag, more + * at low zoom) doesn't flip a static edge to "active" — that previously + * let the object-snap pull a bottom-edge resize sideways/up. */ export function deriveActiveEdges( oldBox: SnapRect, newBox: SnapRect, - tolerance = 0.5, + tolerance = 2, ): ActiveEdges { return { left: Math.abs(newBox.x - oldBox.x) > tolerance, From 0b280e0e021984d9606bef5de7007ba8a22a786d Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 10 May 2026 20:51:56 +0200 Subject: [PATCH 3/3] refactor(canvas): extract selectionHandlers helper The same '(e) => onSelect(shift||ctrl||meta)' click + 'onSelect(false)' tap pair was duplicated 14 times across the four shape renderers (KonvaObject, LineObject, ImageObject, BarcodeObject). Extracts a small selectionHandlers(onSelect) helper next to the shared KonvaObjectProps type so each renderer just spreads {...selectionHandlers(onSelect)} on its outermost selectable node. --- src/components/Canvas/BarcodeObject.tsx | 25 +++++------------- src/components/Canvas/ImageObject.tsx | 12 +++------ src/components/Canvas/KonvaObject.tsx | 32 +++++------------------ src/components/Canvas/LineObject.tsx | 7 ++--- src/components/Canvas/konvaObjectProps.ts | 19 ++++++++++++++ 5 files changed, 37 insertions(+), 58 deletions(-) diff --git a/src/components/Canvas/BarcodeObject.tsx b/src/components/Canvas/BarcodeObject.tsx index 7a79fa50..8d55e818 100644 --- a/src/components/Canvas/BarcodeObject.tsx +++ b/src/components/Canvas/BarcodeObject.tsx @@ -5,7 +5,7 @@ import type Konva from "konva"; import { BARCODE_1D_TYPES, ObjectRegistry } from "../../registry"; import { dotsToPx, pxToDots } from "../../lib/coordinates"; import { useColorScheme } from "../../lib/useColorScheme"; -import type { KonvaObjectProps } from "./konvaObjectProps"; +import { selectionHandlers, type KonvaObjectProps } from "./konvaObjectProps"; import { buildBwipOptions, getDisplaySize, @@ -417,10 +417,7 @@ export function BarcodeObject({ clipWidth={Math.max(w, 1) + clipLeft + clipRight} clipHeight={Math.max(h, 1) + textFontSize + textGap} draggable - onClick={(e) => - onSelect(e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey) - } - onTap={() => onSelect(false)} + {...selectionHandlers(onSelect)} onDragMove={(e) => e.target.position(snapPos(e.target.x(), e.target.y())) } @@ -510,10 +507,7 @@ export function BarcodeObject({ x={x} y={y} draggable - onClick={(e) => - onSelect(e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey) - } - onTap={() => onSelect(false)} + {...selectionHandlers(onSelect)} onDragMove={(e) => e.target.position(snapPos(e.target.x(), e.target.y())) } @@ -687,8 +681,7 @@ export function BarcodeObject({ return ( onSelect(e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey)} - onTap={() => onSelect(false)} + {...selectionHandlers(onSelect)} onDragMove={(e) => e.target.position(snapPos(e.target.x(), e.target.y()))} onDragEnd={handleDragEnd} > @@ -724,10 +717,7 @@ export function BarcodeObject({ x={x} y={y} draggable - onClick={(e) => - onSelect(e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey) - } - onTap={() => onSelect(false)} + {...selectionHandlers(onSelect)} onDragMove={handleDragMove} onDragEnd={handleDragEnd} > @@ -764,10 +754,7 @@ export function BarcodeObject({ x={x} y={y} draggable - onClick={(e) => - onSelect(e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey) - } - onTap={() => onSelect(false)} + {...selectionHandlers(onSelect)} onDragMove={handleDragMove} onDragEnd={handleDragEnd} > diff --git a/src/components/Canvas/ImageObject.tsx b/src/components/Canvas/ImageObject.tsx index 3967ad51..b430788e 100644 --- a/src/components/Canvas/ImageObject.tsx +++ b/src/components/Canvas/ImageObject.tsx @@ -5,7 +5,7 @@ import type { LabelObject } from "../../registry"; import { dotsToPx, pxToDots } from "../../lib/coordinates"; import { getImage } from "../../lib/imageCache"; import { useColorScheme } from "../../lib/useColorScheme"; -import type { KonvaObjectProps } from "./konvaObjectProps"; +import { selectionHandlers, type KonvaObjectProps } from "./konvaObjectProps"; type ImageLabelObject = Extract; type Props = Omit & { obj: ImageLabelObject }; @@ -95,10 +95,7 @@ export function ImageObject({ stroke={isSelected ? colors.selection : undefined} strokeWidth={isSelected ? 2 : 0} draggable - onClick={(e) => - onSelect(e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey) - } - onTap={() => onSelect(false)} + {...selectionHandlers(onSelect)} onDragMove={handleDragMove} onDragEnd={handleDragEnd} /> @@ -111,10 +108,7 @@ export function ImageObject({ x={x} y={y} draggable - onClick={(e) => - onSelect(e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey) - } - onTap={() => onSelect(false)} + {...selectionHandlers(onSelect)} onDragMove={handleDragMove} onDragEnd={handleDragEnd} > diff --git a/src/components/Canvas/KonvaObject.tsx b/src/components/Canvas/KonvaObject.tsx index 114db650..578746e8 100644 --- a/src/components/Canvas/KonvaObject.tsx +++ b/src/components/Canvas/KonvaObject.tsx @@ -12,7 +12,7 @@ import { displayToObject, ZPL_FONT_HEIGHT_TO_CSS_RATIO, } from "./textPositionTransforms"; -import type { KonvaObjectProps } from "./konvaObjectProps"; +import { selectionHandlers, type KonvaObjectProps } from "./konvaObjectProps"; type Props = KonvaObjectProps; @@ -193,10 +193,7 @@ function KonvaObjectInner({ y={y} rotation={zplRotationDeg[p.rotation]} draggable - onClick={(e) => - onSelect(e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey) - } - onTap={() => onSelect(false)} + {...selectionHandlers(onSelect)} onDragMove={handleDragMove} onDragEnd={handleDragEnd} > @@ -233,10 +230,7 @@ function KonvaObjectInner({ stroke={isSelected ? colors.selection : undefined} strokeWidth={isSelected ? 1 : 0} draggable - onClick={(e) => - onSelect(e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey) - } - onTap={() => onSelect(false)} + {...selectionHandlers(onSelect)} onDragMove={handleDragMove} onDragEnd={handleDragEnd} /> @@ -269,10 +263,7 @@ function KonvaObjectInner({ stroke={isSelected ? colors.selection : undefined} strokeWidth={isSelected ? 1 : 0} draggable - onClick={(e) => - onSelect(e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey) - } - onTap={() => onSelect(false)} + {...selectionHandlers(onSelect)} onDragMove={handleDragMove} onDragEnd={handleDragEnd} /> @@ -337,10 +328,7 @@ function KonvaObjectInner({ x={x} y={y} draggable - onClick={(e) => - onSelect(e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey) - } - onTap={() => onSelect(false)} + {...selectionHandlers(onSelect)} onDragMove={handleDragMove} onDragEnd={handleDragEnd} > @@ -392,10 +380,7 @@ function KonvaObjectInner({ strokeScaleEnabled={false} fill={fill} draggable - onClick={(e) => - onSelect(e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey) - } - onTap={() => onSelect(false)} + {...selectionHandlers(onSelect)} onDragMove={(e) => { // Center-anchored: snap the top-left corner, then re-add radius const snapped = snapPos(e.target.x() - rx, e.target.y() - ry); @@ -432,10 +417,7 @@ function KonvaObjectInner({ strokeScaleEnabled={false} fill={fill} draggable - onClick={(e) => - onSelect(e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey) - } - onTap={() => onSelect(false)} + {...selectionHandlers(onSelect)} onDragMove={(e) => { const snapped = snapPos(e.target.x() - r, e.target.y() - r); e.target.position({ x: snapped.x + r, y: snapped.y + r }); diff --git a/src/components/Canvas/LineObject.tsx b/src/components/Canvas/LineObject.tsx index a011db7d..f20e5d47 100644 --- a/src/components/Canvas/LineObject.tsx +++ b/src/components/Canvas/LineObject.tsx @@ -4,7 +4,7 @@ import type { LabelObject } from "../../registry"; import { dotsToPx, pxToDots } from "../../lib/coordinates"; import { constrainLine, type ConstrainMode } from "../../lib/lineConstrain"; import { useColorScheme } from "../../lib/useColorScheme"; -import type { KonvaObjectProps } from "./konvaObjectProps"; +import { selectionHandlers, type KonvaObjectProps } from "./konvaObjectProps"; /** Endpoint-handle visuals — small white square with a thin selection * stroke, mirroring the look of the Konva Transformer's anchors. The @@ -150,10 +150,7 @@ export function LineObject({ stroke="transparent" strokeWidth={Math.max(lineStrokeWidth, 14)} draggable - onClick={(e) => - onSelect(e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey) - } - onTap={() => onSelect(false)} + {...selectionHandlers(onSelect)} onDragMove={(e) => { // Snap the absolute start-point position to the grid (not // the delta), then derive the delta to apply. Snapping the diff --git a/src/components/Canvas/konvaObjectProps.ts b/src/components/Canvas/konvaObjectProps.ts index eb3829f8..3e0dd50d 100644 --- a/src/components/Canvas/konvaObjectProps.ts +++ b/src/components/Canvas/konvaObjectProps.ts @@ -1,6 +1,25 @@ +import type Konva from "konva"; import type { LabelObject } from "../../registry"; import type { ObjectChanges } from "../../store/labelStore"; +/** + * Click / tap handlers shared across every per-type renderer. Click reads + * shift / ctrl / meta to toggle multi-select; tap (touch) is always a + * single-select. Spread onto the outermost selectable Konva node: + * + * + */ +export function selectionHandlers(onSelect: (add: boolean) => void): { + onClick: (e: Konva.KonvaEventObject) => void; + onTap: () => void; +} { + return { + onClick: (e) => + onSelect(e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey), + onTap: () => onSelect(false), + }; +} + /** Shared props for the per-type renderers under KonvaObject (LineObject, * ImageObject, BarcodeObject, KonvaObjectInner). LineObject and * ImageObject re-narrow `obj` at the type level via `Omit & { obj: ... }`