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/ImageObject.tsx b/src/components/Canvas/ImageObject.tsx new file mode 100644 index 00000000..cc21e8a3 --- /dev/null +++ b/src/components/Canvas/ImageObject.tsx @@ -0,0 +1,136 @@ +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, + snap, +}: Props) { + const p = obj.props; + const cached = getImage(p.imageId); + const w = dotsToPx(p.widthDots, scale, dpmm); + // 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); + + 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]); + + // 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), + }); + }; + + if (htmlImg && cached) { + return ( + + onSelect(e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey) + } + onTap={() => onSelect(false)} + onDragMove={handleDragMove} + onDragEnd={handleDragEnd} + /> + ); + } + + return ( + + onSelect(e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey) + } + onTap={() => onSelect(false)} + onDragMove={handleDragMove} + onDragEnd={handleDragEnd} + > + + + + ); +} diff --git a/src/components/Canvas/KonvaObject.tsx b/src/components/Canvas/KonvaObject.tsx index 852e9b20..f2f37e08 100644 --- a/src/components/Canvas/KonvaObject.tsx +++ b/src/components/Canvas/KonvaObject.tsx @@ -1,334 +1,19 @@ -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"; - -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} - /> - ); - } +import { + objectToDisplay, + displayToObject, + ZPL_FONT_HEIGHT_TO_CSS_RATIO, +} from "./textPositionTransforms"; +import type { KonvaObjectProps } from "./konvaObjectProps"; - 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 +43,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 +66,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,49 +92,28 @@ 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, }); }; 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"; @@ -553,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/LineObject.tsx b/src/components/Canvas/LineObject.tsx new file mode 100644 index 00000000..86a59322 --- /dev/null +++ b/src/components/Canvas/LineObject.tsx @@ -0,0 +1,214 @@ +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) => { + // 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 }); + }} + 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..eb3829f8 --- /dev/null +++ b/src/components/Canvas/konvaObjectProps.ts @@ -0,0 +1,20 @@ +import type { LabelObject } from "../../registry"; +import type { ObjectChanges } from "../../store/labelStore"; + +/** 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: ... }` + * 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; + 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..03c02fc6 --- /dev/null +++ b/src/components/Canvas/textPositionTransforms.ts @@ -0,0 +1,95 @@ +/** 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; + +/** 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. 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 }; + 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 }; +}