From 61cc1ad7f901af1108f9df0e8c57268ccf9f0196 Mon Sep 17 00:00:00 2001 From: u8array Date: Sat, 9 May 2026 10:40:16 +0200 Subject: [PATCH 1/5] refactor(canvas): split KonvaObject god component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KonvaObject.tsx had grown to 718 lines bundling four component bodies (LineObject, ImageObject, BarcodeObject dispatch, KonvaObjectInner) plus duplicated FT-baseline + 15-dot rotation math. Three changes: - Pure helpers in textPositionTransforms.ts (objectToDisplay / displayToObject) with a round-trip property test that locks the inverse property across all rotation × positionType combinations. Kills the duplicated math at the render path and the drag-end handler. - LineObject and ImageObject move to their own files and share the renderer Props contract via konvaObjectProps.ts. - The dispatcher narrows obj before passing it down, so each per-type renderer takes the narrowed LabelObject variant directly and the runtime cast disappears. KonvaObject.tsx is now 339 lines, focused on dispatch + the shape/ text body in KonvaObjectInner. --- src/components/Canvas/ImageObject.tsx | 116 +++++ src/components/Canvas/KonvaObject.tsx | 442 ++---------------- src/components/Canvas/LineObject.tsx | 204 ++++++++ src/components/Canvas/konvaObjectProps.ts | 17 + .../Canvas/textPositionTransforms.test.ts | 48 ++ .../Canvas/textPositionTransforms.ts | 89 ++++ 6 files changed, 508 insertions(+), 408 deletions(-) create mode 100644 src/components/Canvas/ImageObject.tsx create mode 100644 src/components/Canvas/LineObject.tsx create mode 100644 src/components/Canvas/konvaObjectProps.ts create mode 100644 src/components/Canvas/textPositionTransforms.test.ts create mode 100644 src/components/Canvas/textPositionTransforms.ts diff --git a/src/components/Canvas/ImageObject.tsx b/src/components/Canvas/ImageObject.tsx new file mode 100644 index 00000000..6c579366 --- /dev/null +++ b/src/components/Canvas/ImageObject.tsx @@ -0,0 +1,116 @@ +import { useState, useEffect, useRef } from "react"; +import { Group, Image as KImage, Rect, Text } from "react-konva"; +import type Konva from "konva"; +import type { LabelObject } from "../../registry"; +import { dotsToPx, pxToDots } from "../../lib/coordinates"; +import { getImage } from "../../lib/imageCache"; +import type { KonvaObjectProps } from "./konvaObjectProps"; + +type ImageLabelObject = Extract; +type Props = Omit & { obj: ImageLabelObject }; + +/** Image renderer. Hosted as its own component so hooks (useState/ + * useEffect for async image loading) can run without violating + * rules-of-hooks. The dispatcher in KonvaObject narrows `obj` + * before passing — no runtime cast needed here. */ +export function ImageObject({ + obj, + scale, + dpmm, + offsetX, + offsetY, + isSelected, + onSelect, + onChange, +}: Props) { + const p = obj.props; + const cached = getImage(p.imageId); + const w = dotsToPx(p.widthDots, scale, dpmm); + const h = cached ? w * (cached.height / cached.width) : w; + const x = offsetX + dotsToPx(obj.x, scale, dpmm); + const y = offsetY + dotsToPx(obj.y, scale, dpmm); + + const [htmlImg, setHtmlImg] = useState(null); + // Reset the cached HTMLImageElement during render when the source changes, + // instead of inside an effect. The "set state during render on prop change" + // pattern is the official React workaround for what would otherwise be a + // setState-in-effect anti-pattern. The next render observes prevDataUrl + // already updated, so this does not loop. + const prevDataUrlRef = useRef(cached?.dataUrl); + if (prevDataUrlRef.current !== cached?.dataUrl) { + prevDataUrlRef.current = cached?.dataUrl; + setHtmlImg(null); + } + useEffect(() => { + if (!cached) return; + let active = true; + const img = new window.Image(); + img.src = cached.dataUrl; + img.onload = () => { + if (active) setHtmlImg(img); + }; + return () => { + active = false; + }; + }, [cached]); + + const handleDragMove = (e: Konva.KonvaEventObject) => { + onChange({ + x: pxToDots(e.target.x() - offsetX, scale, dpmm), + y: pxToDots(e.target.y() - offsetY, scale, dpmm), + }); + }; + + if (htmlImg && cached) { + return ( + + onSelect(e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey) + } + onTap={() => onSelect(false)} + onDragMove={handleDragMove} + onDragEnd={handleDragMove} + /> + ); + } + + return ( + + onSelect(e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey) + } + onTap={() => onSelect(false)} + onDragMove={handleDragMove} + onDragEnd={handleDragMove} + > + + + + ); +} diff --git a/src/components/Canvas/KonvaObject.tsx b/src/components/Canvas/KonvaObject.tsx index 852e9b20..e8f5f55a 100644 --- a/src/components/Canvas/KonvaObject.tsx +++ b/src/components/Canvas/KonvaObject.tsx @@ -1,334 +1,15 @@ -import { useState, useEffect, useRef } from "react"; import { getFontFamily } from "../../lib/fontCache"; import { useFontCacheVersion } from "../../hooks/useFontCacheVersion"; -import { - Circle, - Ellipse, - Group, - Image as KImage, - Line as KLine, - Rect, - Text, -} from "react-konva"; +import { Circle, Ellipse, Group, Rect, Text } from "react-konva"; import { BarcodeObject } from "./BarcodeObject"; +import { LineObject } from "./LineObject"; +import { ImageObject } from "./ImageObject"; import type Konva from "konva"; -import type { LabelObject } from "../../registry"; -import type { ObjectChanges } from "../../store/labelStore"; import { dotsToPx, pxToDots } from "../../lib/coordinates"; -import { getImage } from "../../lib/imageCache"; +import { objectToDisplay, displayToObject } from "./textPositionTransforms"; +import type { KonvaObjectProps } from "./konvaObjectProps"; -interface Props { - obj: LabelObject; - scale: number; - dpmm: number; - offsetX: number; - offsetY: number; - isSelected: boolean; - onSelect: (addToSelection: boolean) => void; - onChange: (changes: ObjectChanges) => void; - snap: (dots: number) => number; -} - -type LineLabelObject = Extract; - -// Separate component so hooks (useState) can be used for live endpoint drag -// Called only after obj.type === 'line' guard in KonvaObject, so the cast is safe. -function LineObject({ - obj: obj_, - scale, - dpmm, - offsetX, - offsetY, - isSelected, - onSelect, - onChange, - snap, -}: Props) { - const obj = obj_ as LineLabelObject; - const p = obj.props; - // 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); - const y1 = offsetY + dotsToPx(obj.y, scale, dpmm); - const rad = (p.angle * Math.PI) / 180; - const lenPx = dotsToPx(p.length, scale, dpmm); - 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"; - const lineStrokeWidth = Math.max(dotsToPx(p.thickness, scale, dpmm), 1); - - // Live positions while handles are being dragged (snapped preview) - const [livePt1, setLivePt1] = useState<{ x: number; y: number } | null>(null); - const [livePt2, setLivePt2] = useState<{ x: number; y: number } | null>(null); - - // Live drag delta while the whole line is being dragged - const [dragDelta, setDragDelta] = useState<{ x: number; y: number }>({ - x: 0, - y: 0, - }); - const dx = dragDelta.x; - const dy = dragDelta.y; - - // Visual endpoints: handle-drag overrides whole-line delta - const dispX1 = livePt1?.x ?? x1 + dx; - const dispY1 = livePt1?.y ?? y1 + dy; - const dispX2 = livePt2?.x ?? x2 + dx; - const dispY2 = livePt2?.y ?? y2 + dy; - - return ( - - {/* Visible line — tracks both whole-drag and handle-drag live */} - - {/* 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. */} - - onSelect(e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey) - } - onTap={() => onSelect(false)} - onDragMove={(e) => { - setDragDelta({ x: e.target.x(), y: e.target.y() }); - }} - onDragEnd={(e) => { - const deltaXPx = e.target.x(); - const deltaYPx = e.target.y(); - e.target.position({ x: 0, y: 0 }); - setDragDelta({ x: 0, y: 0 }); - onChange({ - x: obj.x + pxToDots(deltaXPx, scale, dpmm), - y: obj.y + pxToDots(deltaYPx, scale, dpmm), - }); - }} - /> - {isSelected && ( - <> - {/* Start point — dragging moves the origin; end point stays fixed */} - { - const snappedX = - offsetX + - dotsToPx( - snap(pxToDots(e.target.x() - offsetX, scale, dpmm)), - scale, - dpmm, - ); - const snappedY = - offsetY + - dotsToPx( - snap(pxToDots(e.target.y() - offsetY, scale, dpmm)), - scale, - dpmm, - ); - e.target.position({ x: snappedX, y: snappedY }); - setLivePt1({ x: snappedX, y: snappedY }); - }} - onDragEnd={(e) => { - const snapped = livePt1 ?? { x: e.target.x(), y: e.target.y() }; - e.target.position({ x: x1 + dx, y: y1 + dy }); - setLivePt1(null); - const newStartDotX = pxToDots(snapped.x - offsetX, scale, dpmm); - const newStartDotY = pxToDots(snapped.y - offsetY, scale, dpmm); - const endDotX = pxToDots(x2 - offsetX, scale, dpmm); - const endDotY = pxToDots(y2 - offsetY, scale, dpmm); - const dxDots = endDotX - newStartDotX; - const dyDots = endDotY - newStartDotY; - const newLen = Math.sqrt(dxDots * dxDots + dyDots * dyDots); - const newAngle = Math.round( - (Math.atan2(dyDots, dxDots) * 180) / Math.PI, - ); - onChange({ - x: newStartDotX, - y: newStartDotY, - props: { - length: Math.max(1, Math.round(newLen)), - angle: newAngle, - }, - }); - }} - /> - {/* End point — dragging changes length & angle */} - { - const snappedX = - offsetX + - dotsToPx( - snap(pxToDots(e.target.x() - offsetX, scale, dpmm)), - scale, - dpmm, - ); - const snappedY = - offsetY + - dotsToPx( - snap(pxToDots(e.target.y() - offsetY, scale, dpmm)), - scale, - dpmm, - ); - e.target.position({ x: snappedX, y: snappedY }); - setLivePt2({ x: snappedX, y: snappedY }); - }} - onDragEnd={(e) => { - const snapped = livePt2 ?? { x: e.target.x(), y: e.target.y() }; - e.target.position({ x: x2 + dx, y: y2 + dy }); - setLivePt2(null); - const dxDots = pxToDots(snapped.x - offsetX, scale, dpmm) - obj.x; - const dyDots = pxToDots(snapped.y - offsetY, scale, dpmm) - obj.y; - const newLen = Math.sqrt(dxDots * dxDots + dyDots * dyDots); - const newAngle = Math.round( - (Math.atan2(dyDots, dxDots) * 180) / Math.PI, - ); - onChange({ - props: { - length: Math.max(1, Math.round(newLen)), - angle: newAngle, - }, - }); - }} - /> - - )} - - ); -} - -// Separate component so hooks (useState/useEffect) can be used without violating rules-of-hooks. -function ImageObject({ - obj: obj_, - scale, - dpmm, - offsetX, - offsetY, - isSelected, - onSelect, - onChange, -}: Props) { - const obj = obj_ as Extract; - const p = obj.props; - const cached = getImage(p.imageId); - const w = dotsToPx(p.widthDots, scale, dpmm); - const h = cached ? w * (cached.height / cached.width) : w; - const x = offsetX + dotsToPx(obj.x, scale, dpmm); - const y = offsetY + dotsToPx(obj.y, scale, dpmm); - - const [htmlImg, setHtmlImg] = useState(null); - // Reset the cached HTMLImageElement during render when the source changes, - // instead of inside an effect. The "set state during render on prop change" - // pattern is the official React workaround for what would otherwise be a - // setState-in-effect anti-pattern. The next render observes prevDataUrl - // already updated, so this does not loop. - const prevDataUrlRef = useRef(cached?.dataUrl); - if (prevDataUrlRef.current !== cached?.dataUrl) { - prevDataUrlRef.current = cached?.dataUrl; - setHtmlImg(null); - } - useEffect(() => { - if (!cached) return; - let active = true; - const img = new window.Image(); - img.src = cached.dataUrl; - img.onload = () => { - if (active) setHtmlImg(img); - }; - return () => { - active = false; - }; - }, [cached]); - - const handleDragMove = (e: Konva.KonvaEventObject) => { - onChange({ - x: pxToDots(e.target.x() - offsetX, scale, dpmm), - y: pxToDots(e.target.y() - offsetY, scale, dpmm), - }); - }; - - if (htmlImg && cached) { - return ( - - onSelect(e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey) - } - onTap={() => onSelect(false)} - onDragMove={handleDragMove} - onDragEnd={handleDragMove} - /> - ); - } - - return ( - - onSelect(e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey) - } - onTap={() => onSelect(false)} - onDragMove={handleDragMove} - onDragEnd={handleDragMove} - > - - - - ); -} +type Props = KonvaObjectProps; const BARCODE_TYPES = new Set([ "code128", @@ -358,9 +39,14 @@ const BARCODE_TYPES = new Set([ ]); export function KonvaObject(props_: Props) { - if (props_.obj.type === "line") return ; - if (props_.obj.type === "image") return ; - if (BARCODE_TYPES.has(props_.obj.type)) return ; + // Pass `obj` explicitly after the spread so each per-type renderer + // receives the narrowed type (LineLabelObject, ImageLabelObject) + // rather than the wide LabelObject. Without the explicit prop the + // spread would re-widen and the renderer would need a runtime cast. + const { obj } = props_; + if (obj.type === "line") return ; + if (obj.type === "image") return ; + if (BARCODE_TYPES.has(obj.type)) return ; return ; } @@ -376,52 +62,16 @@ function KonvaObjectInner({ snap, }: Props) { useFontCacheVersion(); - // If the object was imported with ^FT (baseline position), compute display offset. - // ^FT positions text at the baseline; ^FO at the top-left corner. - // We need to convert FT→FO for canvas rendering only. - let displayX = obj.x; - let displayY = obj.y; - if (obj.positionType === "FT") { - if (obj.type === "text" || obj.type === "serial") { - const p = obj.props; - // ^FT places the origin at the baseline of the first character. - // The Konva anchor point after rotation sits at a different corner - // of the visual bounding box than the ZPL FT baseline origin: - // N (0°): FT=bottom-left, Konva=top-left → shift Y up by fontHeight - // R (90°): FT=bottom-left, Konva=top-right → shift X right by rendered height - // I (180°): FT=top-right, Konva=bottom-right → shift Y down by rendered height - // B (270°): FT=top-right, Konva=bottom-left → shift X left by rendered height - // For R/I/B the anchor is at the far end of the text, so we must use the actual - // Konva-rendered font height (fontHeight / 1.3) — not the raw ZPL fontHeight. - const renderedH = p.fontHeight / 1.3; - if (p.rotation === "N") { - displayY -= p.fontHeight; - } else if (p.rotation === "R") { - displayX += renderedH; - } else if (p.rotation === "I") { - displayY += renderedH; - } else if (p.rotation === "B") { - displayX -= renderedH; - } - } - } - - // Konva rotates text around its top-left corner, but ZPL's ^FO anchor - // shifts with rotation. 15 dots is an empirically determined fixed offset. - if (obj.type === "text" || obj.type === "serial") { - const p = obj.props; - const ROTATION_OFFSET = 15; // dots — empirical canvas/ZPL alignment correction - if (p.rotation === "I") { - displayY -= ROTATION_OFFSET; - } else if (p.rotation === "R") { - displayX -= ROTATION_OFFSET; - } else if (p.rotation === "B") { - displayX += ROTATION_OFFSET; - } - } + // 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. + const display = + obj.type === "text" || obj.type === "serial" + ? objectToDisplay(obj.x, obj.y, obj.props, obj.positionType) + : { x: obj.x, y: obj.y }; - const x = offsetX + dotsToPx(displayX, scale, dpmm); - const y = offsetY + dotsToPx(displayY, scale, dpmm); + const x = offsetX + dotsToPx(display.x, scale, dpmm); + const y = offsetY + dotsToPx(display.y, scale, dpmm); // Snap a stage-position to the nearest grid point, returns stage-position. const snapPos = (stageX: number, stageY: number) => ({ @@ -438,43 +88,19 @@ function KonvaObjectInner({ }; const handleDragEnd = (e: Konva.KonvaEventObject) => { - let finalX = pxToDots(e.target.x() - offsetX, scale, dpmm); - let finalY = pxToDots(e.target.y() - offsetY, scale, dpmm); - - if (obj.type === "text" || obj.type === "serial") { - const p = obj.props; - const ROTATION_OFFSET = 15; - if (p.rotation === "I") { - finalY += ROTATION_OFFSET; - } else if (p.rotation === "R") { - finalX += ROTATION_OFFSET; - } else if (p.rotation === "B") { - finalX -= ROTATION_OFFSET; - } - } - - // Inverse of the ^FT display offset applied in the render phase above. - // Without this, dragging an ^FT object saves its top-left Konva position - // instead of the ZPL baseline coordinate, causing a vertical jump on re-render. - if (obj.positionType === "FT") { - if (obj.type === "text" || obj.type === "serial") { - const p = obj.props; - const renderedH = p.fontHeight / 1.3; - if (p.rotation === "N") { - finalY += p.fontHeight; - } else if (p.rotation === "R") { - finalX -= renderedH; - } else if (p.rotation === "I") { - finalY -= renderedH; - } else if (p.rotation === "B") { - finalX += renderedH; - } - } - } + const draggedX = pxToDots(e.target.x() - offsetX, scale, dpmm); + const draggedY = pxToDots(e.target.y() - offsetY, scale, dpmm); + // Inverse of the FT/rotation correction applied at render: without + // it, drag would save the Konva top-left position instead of the + // ZPL coordinate and re-render would jump. + const final = + obj.type === "text" || obj.type === "serial" + ? displayToObject(draggedX, draggedY, obj.props, obj.positionType) + : { x: draggedX, y: draggedY }; onChange({ - x: finalX, - y: finalY, + x: final.x, + y: final.y, }); }; diff --git a/src/components/Canvas/LineObject.tsx b/src/components/Canvas/LineObject.tsx new file mode 100644 index 00000000..801cace0 --- /dev/null +++ b/src/components/Canvas/LineObject.tsx @@ -0,0 +1,204 @@ +import { useState } from "react"; +import { Circle, Group, Line as KLine } from "react-konva"; +import type { LabelObject } from "../../registry"; +import { dotsToPx, pxToDots } from "../../lib/coordinates"; +import type { KonvaObjectProps } from "./konvaObjectProps"; + +type LineLabelObject = Extract; +type Props = Omit & { obj: LineLabelObject }; + +/** Line renderer. Hosted as its own component so hooks (useState for + * live endpoint drag) can run conditionally per object type without + * violating rules-of-hooks. The dispatcher in KonvaObject narrows + * `obj` before passing — no runtime cast needed here. */ +export function LineObject({ + obj, + scale, + dpmm, + offsetX, + offsetY, + isSelected, + onSelect, + onChange, + snap, +}: Props) { + const p = obj.props; + // 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); + const y1 = offsetY + dotsToPx(obj.y, scale, dpmm); + const rad = (p.angle * Math.PI) / 180; + const lenPx = dotsToPx(p.length, scale, dpmm); + 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"; + const lineStrokeWidth = Math.max(dotsToPx(p.thickness, scale, dpmm), 1); + + // Live positions while handles are being dragged (snapped preview) + const [livePt1, setLivePt1] = useState<{ x: number; y: number } | null>(null); + const [livePt2, setLivePt2] = useState<{ x: number; y: number } | null>(null); + + // Live drag delta while the whole line is being dragged + const [dragDelta, setDragDelta] = useState<{ x: number; y: number }>({ + x: 0, + y: 0, + }); + const dx = dragDelta.x; + const dy = dragDelta.y; + + // Visual endpoints: handle-drag overrides whole-line delta + const dispX1 = livePt1?.x ?? x1 + dx; + const dispY1 = livePt1?.y ?? y1 + dy; + const dispX2 = livePt2?.x ?? x2 + dx; + const dispY2 = livePt2?.y ?? y2 + dy; + + return ( + + {/* Visible line — tracks both whole-drag and handle-drag live */} + + {/* 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. */} + + onSelect(e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey) + } + onTap={() => onSelect(false)} + onDragMove={(e) => { + setDragDelta({ x: e.target.x(), y: e.target.y() }); + }} + onDragEnd={(e) => { + const deltaXPx = e.target.x(); + const deltaYPx = e.target.y(); + e.target.position({ x: 0, y: 0 }); + setDragDelta({ x: 0, y: 0 }); + onChange({ + x: obj.x + pxToDots(deltaXPx, scale, dpmm), + y: obj.y + pxToDots(deltaYPx, scale, dpmm), + }); + }} + /> + {isSelected && ( + <> + {/* Start point — dragging moves the origin; end point stays fixed */} + { + const snappedX = + offsetX + + dotsToPx( + snap(pxToDots(e.target.x() - offsetX, scale, dpmm)), + scale, + dpmm, + ); + const snappedY = + offsetY + + dotsToPx( + snap(pxToDots(e.target.y() - offsetY, scale, dpmm)), + scale, + dpmm, + ); + e.target.position({ x: snappedX, y: snappedY }); + setLivePt1({ x: snappedX, y: snappedY }); + }} + onDragEnd={(e) => { + const snapped = livePt1 ?? { x: e.target.x(), y: e.target.y() }; + e.target.position({ x: x1 + dx, y: y1 + dy }); + setLivePt1(null); + const newStartDotX = pxToDots(snapped.x - offsetX, scale, dpmm); + const newStartDotY = pxToDots(snapped.y - offsetY, scale, dpmm); + const endDotX = pxToDots(x2 - offsetX, scale, dpmm); + const endDotY = pxToDots(y2 - offsetY, scale, dpmm); + const dxDots = endDotX - newStartDotX; + const dyDots = endDotY - newStartDotY; + const newLen = Math.sqrt(dxDots * dxDots + dyDots * dyDots); + const newAngle = Math.round( + (Math.atan2(dyDots, dxDots) * 180) / Math.PI, + ); + onChange({ + x: newStartDotX, + y: newStartDotY, + props: { + length: Math.max(1, Math.round(newLen)), + angle: newAngle, + }, + }); + }} + /> + {/* End point — dragging changes length & angle */} + { + const snappedX = + offsetX + + dotsToPx( + snap(pxToDots(e.target.x() - offsetX, scale, dpmm)), + scale, + dpmm, + ); + const snappedY = + offsetY + + dotsToPx( + snap(pxToDots(e.target.y() - offsetY, scale, dpmm)), + scale, + dpmm, + ); + e.target.position({ x: snappedX, y: snappedY }); + setLivePt2({ x: snappedX, y: snappedY }); + }} + onDragEnd={(e) => { + const snapped = livePt2 ?? { x: e.target.x(), y: e.target.y() }; + e.target.position({ x: x2 + dx, y: y2 + dy }); + setLivePt2(null); + const dxDots = pxToDots(snapped.x - offsetX, scale, dpmm) - obj.x; + const dyDots = pxToDots(snapped.y - offsetY, scale, dpmm) - obj.y; + const newLen = Math.sqrt(dxDots * dxDots + dyDots * dyDots); + const newAngle = Math.round( + (Math.atan2(dyDots, dxDots) * 180) / Math.PI, + ); + onChange({ + props: { + length: Math.max(1, Math.round(newLen)), + angle: newAngle, + }, + }); + }} + /> + + )} + + ); +} diff --git a/src/components/Canvas/konvaObjectProps.ts b/src/components/Canvas/konvaObjectProps.ts new file mode 100644 index 00000000..49803bc5 --- /dev/null +++ b/src/components/Canvas/konvaObjectProps.ts @@ -0,0 +1,17 @@ +import type { LabelObject } from "../../registry"; +import type { ObjectChanges } from "../../store/labelStore"; + +/** Shared props for the per-type renderers under KonvaObject (LineObject, + * ImageObject, BarcodeObject, KonvaObjectInner). The dispatcher hands + * the wide LabelObject down; each renderer narrows internally. */ +export interface KonvaObjectProps { + obj: LabelObject; + scale: number; + dpmm: number; + offsetX: number; + offsetY: number; + isSelected: boolean; + onSelect: (addToSelection: boolean) => void; + onChange: (changes: ObjectChanges) => void; + snap: (dots: number) => number; +} diff --git a/src/components/Canvas/textPositionTransforms.test.ts b/src/components/Canvas/textPositionTransforms.test.ts new file mode 100644 index 00000000..3e5c1602 --- /dev/null +++ b/src/components/Canvas/textPositionTransforms.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { objectToDisplay, displayToObject } from './textPositionTransforms'; + +const ROT = ['N', 'R', 'I', 'B'] as const; + +describe('text position transforms', () => { + describe('objectToDisplay', () => { + it('shifts Y up by fontHeight under FT/N', () => { + const r = objectToDisplay(100, 200, { fontHeight: 30, rotation: 'N' }, 'FT'); + // FT N: dy = -fontHeight (-30); rotation offset for N is 0. + expect(r).toEqual({ x: 100, y: 170 }); + }); + + it('applies only the rotation offset under FO', () => { + // FO + I → no FT correction, rotation offset dy = -15. + const r = objectToDisplay(100, 200, { fontHeight: 30, rotation: 'I' }, 'FO'); + expect(r).toEqual({ x: 100, y: 185 }); + }); + + it('treats undefined positionType like FO', () => { + const r = objectToDisplay(100, 200, { fontHeight: 30, rotation: 'N' }, undefined); + expect(r).toEqual({ x: 100, y: 200 }); + }); + + it('combines FT correction and rotation offset for I', () => { + // FT I: dy = renderedH (30/1.3 ≈ 23.077). Rotation offset I: dy -15. + const r = objectToDisplay(100, 200, { fontHeight: 30, rotation: 'I' }, 'FT'); + expect(r.x).toBeCloseTo(100); + expect(r.y).toBeCloseTo(200 + 30 / 1.3 - 15); + }); + }); + + describe('round-trip', () => { + for (const rotation of ROT) { + for (const positionType of ['FO', 'FT', undefined] as const) { + it(`displayToObject ∘ objectToDisplay = id for ${rotation}/${positionType ?? 'undef'}`, () => { + const props = { fontHeight: 42, rotation }; + const objX = 123; + const objY = 456; + const display = objectToDisplay(objX, objY, props, positionType); + const back = displayToObject(display.x, display.y, props, positionType); + expect(back.x).toBeCloseTo(objX); + expect(back.y).toBeCloseTo(objY); + }); + } + } + }); +}); diff --git a/src/components/Canvas/textPositionTransforms.ts b/src/components/Canvas/textPositionTransforms.ts new file mode 100644 index 00000000..37e059f5 --- /dev/null +++ b/src/components/Canvas/textPositionTransforms.ts @@ -0,0 +1,89 @@ +/** Pure transforms between the text/serial object's saved coordinate + * (what ZPL persists) and the Konva-anchor coordinate (what we paint). + * + * Two corrections stack: + * 1. ^FT baseline correction (only when positionType === "FT"): + * ^FT places the origin at the baseline of the first character; + * Konva's Text anchor sits at a different corner depending on + * rotation. Shift accordingly so the painted text matches the + * baseline the ZPL describes. + * 2. Rotation alignment (always for text/serial): + * Konva rotates around the top-left corner; ZPL ^FO does not + * behave the same way. 15 dots is an empirically determined + * offset that lines the canvas back up with what the printer + * (and Labelary) renders. + * + * `displayToObject` is the exact inverse so a drag-end can recover + * the saved coordinate from the dragged Konva position. */ + +interface TextLikeProps { + fontHeight: number; + rotation: 'N' | 'R' | 'I' | 'B'; +} + +/** 15 dots empirical canvas/ZPL alignment offset for rotated text. */ +const ROTATION_OFFSET_DOTS = 15; + +function ftBaselineDelta(props: TextLikeProps): { dx: number; dy: number } { + // For R/I/B the Konva anchor sits at the far end of the rendered + // glyph, so we use the actual rendered height (fontHeight / 1.3). + // For N the anchor is at the top, so we shift up by the full ZPL + // fontHeight. + const renderedH = props.fontHeight / 1.3; + switch (props.rotation) { + case 'N': return { dx: 0, dy: -props.fontHeight }; + case 'R': return { dx: renderedH, dy: 0 }; + case 'I': return { dx: 0, dy: renderedH }; + case 'B': return { dx: -renderedH, dy: 0 }; + } +} + +function rotationOffsetDelta(props: TextLikeProps): { dx: number; dy: number } { + switch (props.rotation) { + case 'N': return { dx: 0, dy: 0 }; + case 'I': return { dx: 0, dy: -ROTATION_OFFSET_DOTS }; + case 'R': return { dx: -ROTATION_OFFSET_DOTS, dy: 0 }; + case 'B': return { dx: ROTATION_OFFSET_DOTS, dy: 0 }; + } +} + +/** Object position → display anchor, in dot space. */ +export function objectToDisplay( + objectX: number, + objectY: number, + props: TextLikeProps, + positionType: 'FO' | 'FT' | undefined, +): { x: number; y: number } { + let x = objectX; + let y = objectY; + if (positionType === 'FT') { + const ft = ftBaselineDelta(props); + x += ft.dx; + y += ft.dy; + } + const rot = rotationOffsetDelta(props); + x += rot.dx; + y += rot.dy; + return { x, y }; +} + +/** Inverse of objectToDisplay — recovers the saved coordinate from a + * display anchor (used at drag end). */ +export function displayToObject( + displayX: number, + displayY: number, + props: TextLikeProps, + positionType: 'FO' | 'FT' | undefined, +): { x: number; y: number } { + let x = displayX; + let y = displayY; + const rot = rotationOffsetDelta(props); + x -= rot.dx; + y -= rot.dy; + if (positionType === 'FT') { + const ft = ftBaselineDelta(props); + x -= ft.dx; + y -= ft.dy; + } + return { x, y }; +} From 01ef568bc0e40f75b382fef268243b1010423110 Mon Sep 17 00:00:00 2001 From: u8array Date: Sat, 9 May 2026 11:06:29 +0200 Subject: [PATCH 2/5] refactor(canvas): name the 1.3 fontHeight ratio and dedupe renderer Props Three small cleanups on top of the KonvaObject split: - The 1.3 ZPL-fontHeight to CSS-fontSize ratio is now a named export (ZPL_FONT_HEIGHT_TO_CSS_RATIO) used by textPositionTransforms and the text/serial render paths in KonvaObjectInner. Same number, no longer a magic literal sprinkled across files. - BarcodeObject was redeclaring the renderer Props interface structurally identical to KonvaObjectProps. Drop the duplicate and import the shared one. - konvaObjectProps.ts docstring updated: LineObject/ImageObject now receive the narrowed obj from the dispatcher (no internal cast) while BarcodeObject and KonvaObjectInner still narrow internally. --- src/components/Canvas/BarcodeObject.tsx | 16 ++-------------- src/components/Canvas/KonvaObject.tsx | 16 +++++++++++++--- src/components/Canvas/konvaObjectProps.ts | 7 +++++-- src/components/Canvas/textPositionTransforms.ts | 14 ++++++++++---- 4 files changed, 30 insertions(+), 23 deletions(-) diff --git a/src/components/Canvas/BarcodeObject.tsx b/src/components/Canvas/BarcodeObject.tsx index 1fc88930..d976c2f7 100644 --- a/src/components/Canvas/BarcodeObject.tsx +++ b/src/components/Canvas/BarcodeObject.tsx @@ -2,10 +2,9 @@ import React, { useCallback, useRef } from "react"; import bwipjs from "bwip-js/browser"; import { Image as KImage, Group, Rect, Text } from "react-konva"; import type Konva from "konva"; -import type { LabelObject } from "../../registry"; import { BARCODE_1D_TYPES, ObjectRegistry } from "../../registry"; -import type { ObjectChanges } from "../../store/labelStore"; import { dotsToPx, pxToDots } from "../../lib/coordinates"; +import type { KonvaObjectProps } from "./konvaObjectProps"; import { buildBwipOptions, getDisplaySize, @@ -23,17 +22,6 @@ import { EAN_UPC_TYPES, } from "./bwipConstants"; -interface Props { - obj: LabelObject; - scale: number; - dpmm: number; - offsetX: number; - offsetY: number; - isSelected: boolean; - onSelect: (addToSelection: boolean) => void; - onChange: (changes: ObjectChanges) => void; - snap: (dots: number) => number; -} export function BarcodeObject({ obj, scale, @@ -44,7 +32,7 @@ export function BarcodeObject({ onSelect, onChange, snap, -}: Props) { +}: KonvaObjectProps) { const groupRef = useRef(null); const textRef = useRef(null); diff --git a/src/components/Canvas/KonvaObject.tsx b/src/components/Canvas/KonvaObject.tsx index e8f5f55a..f2f37e08 100644 --- a/src/components/Canvas/KonvaObject.tsx +++ b/src/components/Canvas/KonvaObject.tsx @@ -6,7 +6,11 @@ import { LineObject } from "./LineObject"; import { ImageObject } from "./ImageObject"; import type Konva from "konva"; import { dotsToPx, pxToDots } from "../../lib/coordinates"; -import { objectToDisplay, displayToObject } from "./textPositionTransforms"; +import { + objectToDisplay, + displayToObject, + ZPL_FONT_HEIGHT_TO_CSS_RATIO, +} from "./textPositionTransforms"; import type { KonvaObjectProps } from "./konvaObjectProps"; type Props = KonvaObjectProps; @@ -106,7 +110,10 @@ function KonvaObjectInner({ if (obj.type === "text") { const p = obj.props; - const fontSize = Math.max(dotsToPx(p.fontHeight, scale, dpmm) / 1.3, 6); + const fontSize = Math.max( + dotsToPx(p.fontHeight, scale, dpmm) / ZPL_FONT_HEIGHT_TO_CSS_RATIO, + 6, + ); const fontFamily = p.printerFontName ? (getFontFamily(p.printerFontName) ?? "'Roboto Condensed', sans-serif") : "'Roboto Condensed', sans-serif"; @@ -179,7 +186,10 @@ function KonvaObjectInner({ if (obj.type === "serial") { const p = obj.props; - const fontSize = Math.max(dotsToPx(p.fontHeight, scale, dpmm) / 1.3, 6); + const fontSize = Math.max( + dotsToPx(p.fontHeight, scale, dpmm) / ZPL_FONT_HEIGHT_TO_CSS_RATIO, + 6, + ); const zplRotationDeg: Record = { N: 0, R: 90, diff --git a/src/components/Canvas/konvaObjectProps.ts b/src/components/Canvas/konvaObjectProps.ts index 49803bc5..eb3829f8 100644 --- a/src/components/Canvas/konvaObjectProps.ts +++ b/src/components/Canvas/konvaObjectProps.ts @@ -2,8 +2,11 @@ import type { LabelObject } from "../../registry"; import type { ObjectChanges } from "../../store/labelStore"; /** Shared props for the per-type renderers under KonvaObject (LineObject, - * ImageObject, BarcodeObject, KonvaObjectInner). The dispatcher hands - * the wide LabelObject down; each renderer narrows internally. */ + * ImageObject, BarcodeObject, KonvaObjectInner). LineObject and + * ImageObject re-narrow `obj` at the type level via `Omit & { obj: ... }` + * and the dispatcher passes the narrowed value explicitly; BarcodeObject + * and KonvaObjectInner currently take the wide LabelObject and narrow + * internally. */ export interface KonvaObjectProps { obj: LabelObject; scale: number; diff --git a/src/components/Canvas/textPositionTransforms.ts b/src/components/Canvas/textPositionTransforms.ts index 37e059f5..03c02fc6 100644 --- a/src/components/Canvas/textPositionTransforms.ts +++ b/src/components/Canvas/textPositionTransforms.ts @@ -24,12 +24,18 @@ interface TextLikeProps { /** 15 dots empirical canvas/ZPL alignment offset for rotated text. */ const ROTATION_OFFSET_DOTS = 15; +/** Ratio between ZPL fontHeight (cap-height) and CSS/Konva fontSize + * (em-height) for Roboto Condensed Bold. Empirical: divide ZPL + * fontHeight by this to get the Konva-rendered height in dots, or to + * derive the Konva fontSize. Lives here because it appears in both + * the FT baseline math and the text/serial render paths. */ +export const ZPL_FONT_HEIGHT_TO_CSS_RATIO = 1.3; + function ftBaselineDelta(props: TextLikeProps): { dx: number; dy: number } { // For R/I/B the Konva anchor sits at the far end of the rendered - // glyph, so we use the actual rendered height (fontHeight / 1.3). - // For N the anchor is at the top, so we shift up by the full ZPL - // fontHeight. - const renderedH = props.fontHeight / 1.3; + // glyph, so we use the actual rendered height. For N the anchor is + // at the top, so we shift up by the full ZPL fontHeight. + const renderedH = props.fontHeight / ZPL_FONT_HEIGHT_TO_CSS_RATIO; switch (props.rotation) { case 'N': return { dx: 0, dy: -props.fontHeight }; case 'R': return { dx: renderedH, dy: 0 }; From a78367449018d8563cbd8f3ad0f3761a9c3e7a23 Mon Sep 17 00:00:00 2001 From: u8array Date: Sat, 9 May 2026 11:16:22 +0200 Subject: [PATCH 3/5] fix(canvas): commit ImageObject drag only on end, snap during move Image drag fired onChange on every onDragMove, hammering the store with one update per mouse pixel and inconsistent with the shape/text/serial renderers in KonvaObjectInner. Split into a snap-during-drag handler and a single commit on drag end. The behavior now matches the rest of the canvas: visual feedback while dragging, one store update when the user releases. --- src/components/Canvas/ImageObject.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/components/Canvas/ImageObject.tsx b/src/components/Canvas/ImageObject.tsx index 6c579366..c9e27120 100644 --- a/src/components/Canvas/ImageObject.tsx +++ b/src/components/Canvas/ImageObject.tsx @@ -22,6 +22,7 @@ export function ImageObject({ isSelected, onSelect, onChange, + snap, }: Props) { const p = obj.props; const cached = getImage(p.imageId); @@ -54,7 +55,21 @@ export function ImageObject({ }; }, [cached]); + // Snap during drag for visual feedback; commit only on dragEnd so + // the store doesn't update on every mouse pixel. Mirrors the + // pattern KonvaObjectInner uses for shape/text objects. const handleDragMove = (e: Konva.KonvaEventObject) => { + e.target.position({ + x: + offsetX + + dotsToPx(snap(pxToDots(e.target.x() - offsetX, scale, dpmm)), scale, dpmm), + y: + offsetY + + dotsToPx(snap(pxToDots(e.target.y() - offsetY, scale, dpmm)), scale, dpmm), + }); + }; + + const handleDragEnd = (e: Konva.KonvaEventObject) => { onChange({ x: pxToDots(e.target.x() - offsetX, scale, dpmm), y: pxToDots(e.target.y() - offsetY, scale, dpmm), @@ -78,7 +93,7 @@ export function ImageObject({ } onTap={() => onSelect(false)} onDragMove={handleDragMove} - onDragEnd={handleDragMove} + onDragEnd={handleDragEnd} /> ); } @@ -94,7 +109,7 @@ export function ImageObject({ } onTap={() => onSelect(false)} onDragMove={handleDragMove} - onDragEnd={handleDragMove} + onDragEnd={handleDragEnd} > Date: Sat, 9 May 2026 11:18:44 +0200 Subject: [PATCH 4/5] fix(canvas): snap whole-line drag and guard image div-by-zero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LineObject's whole-line onDragMove only updated dragDelta with the raw cursor delta — the endpoint handles in the same component snap, and the rest of the canvas snaps too, so the inconsistency was visible. Mirror the endpoint idiom: round-trip pixel → dot → snap → pixel, then update both Konva position and the React state. ImageObject divided cached.height by cached.width to compute aspect ratio. A 0-width image would yield NaN. Guard with width > 0; falls back to a square placeholder. --- src/components/Canvas/ImageObject.tsx | 7 ++++++- src/components/Canvas/LineObject.tsx | 10 +++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/components/Canvas/ImageObject.tsx b/src/components/Canvas/ImageObject.tsx index c9e27120..cc21e8a3 100644 --- a/src/components/Canvas/ImageObject.tsx +++ b/src/components/Canvas/ImageObject.tsx @@ -27,7 +27,12 @@ export function ImageObject({ const p = obj.props; const cached = getImage(p.imageId); const w = dotsToPx(p.widthDots, scale, dpmm); - const h = cached ? w * (cached.height / cached.width) : w; + // Guard against a 0-width cached image: the imageCache pipeline + // doesn't normally produce one, but a malformed file could leak + // through and div-by-zero would render NaN-sized canvas nodes. + const h = cached && cached.width > 0 + ? w * (cached.height / cached.width) + : w; const x = offsetX + dotsToPx(obj.x, scale, dpmm); const y = offsetY + dotsToPx(obj.y, scale, dpmm); diff --git a/src/components/Canvas/LineObject.tsx b/src/components/Canvas/LineObject.tsx index 801cace0..7079aea1 100644 --- a/src/components/Canvas/LineObject.tsx +++ b/src/components/Canvas/LineObject.tsx @@ -86,7 +86,15 @@ export function LineObject({ } onTap={() => onSelect(false)} onDragMove={(e) => { - setDragDelta({ x: e.target.x(), y: e.target.y() }); + // Snap the whole-line delta in dot space, then mirror back + // to pixels — same idiom the endpoint handles use, and + // keeps the line consistent with shape/text/image drag. + const deltaXPx = + dotsToPx(snap(pxToDots(e.target.x(), scale, dpmm)), scale, dpmm); + const deltaYPx = + dotsToPx(snap(pxToDots(e.target.y(), scale, dpmm)), scale, dpmm); + e.target.position({ x: deltaXPx, y: deltaYPx }); + setDragDelta({ x: deltaXPx, y: deltaYPx }); }} onDragEnd={(e) => { const deltaXPx = e.target.x(); From 2243f0c037b50dfe73ec925d4b90afe1766fb39d Mon Sep 17 00:00:00 2001 From: u8array Date: Sat, 9 May 2026 11:25:51 +0200 Subject: [PATCH 5/5] fix(canvas): snap whole-line drag to absolute grid, not delta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snapping the drag delta meant an off-grid line (e.g. x=11 imported into a snap=8 grid) would stay off-grid forever — drag delta of 8 yields x=19 instead of grid-aligned x=16. Snap the absolute start position instead and derive the delta from the snap result, mirroring the endpoint-handle drag in this same file and the shape/text/image drag elsewhere. --- src/components/Canvas/LineObject.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/components/Canvas/LineObject.tsx b/src/components/Canvas/LineObject.tsx index 7079aea1..86a59322 100644 --- a/src/components/Canvas/LineObject.tsx +++ b/src/components/Canvas/LineObject.tsx @@ -86,13 +86,15 @@ export function LineObject({ } onTap={() => onSelect(false)} onDragMove={(e) => { - // Snap the whole-line delta in dot space, then mirror back - // to pixels — same idiom the endpoint handles use, and - // keeps the line consistent with shape/text/image drag. - const deltaXPx = - dotsToPx(snap(pxToDots(e.target.x(), scale, dpmm)), scale, dpmm); - const deltaYPx = - dotsToPx(snap(pxToDots(e.target.y(), scale, dpmm)), scale, dpmm); + // Snap the absolute start-point position to the grid (not + // the delta), then derive the delta to apply. Snapping the + // delta would let an off-grid line stay off-grid forever; + // shapes, text and the endpoint handles in this same + // component all snap absolute, so the line should too. + const newX = snap(obj.x + pxToDots(e.target.x(), scale, dpmm)); + const newY = snap(obj.y + pxToDots(e.target.y(), scale, dpmm)); + const deltaXPx = dotsToPx(newX - obj.x, scale, dpmm); + const deltaYPx = dotsToPx(newY - obj.y, scale, dpmm); e.target.position({ x: deltaXPx, y: deltaYPx }); setDragDelta({ x: deltaXPx, y: deltaYPx }); }}