diff --git a/README.md b/README.md index 775b2679..bb640cd6 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ Use `.json` (File → Save Design) to save your work. It preserves every object ## Limitations - ZPL import covers the most common commands but not the full ZPL II command set. Labels using printer-stored images, downloaded graphics, or printer-specific extensions may not import completely. -- The canvas is a design preview, not a pixel-perfect simulation: fonts and exact rendering may differ from what the printer produces. Shapes, spacing, and positions should match. For an accurate render, use the **Preview** in the bottom-right panel (powered by Labelary). +- The canvas is a design preview, not a pixel-perfect simulation. Shapes, spacing, and positions match the print; text approximates Zebra's built-in font to within a few dots, but exact letterforms and anti-aliasing differ. For a faithful render, use the **Preview** in the bottom-right panel (powered by Labelary). - Label preview requires a connection to `api.labelary.com`. - The Labelary preview doesn't render every ZPL feature. Some less common elements (e.g. Codablock F barcodes) may be missing or wrong in the preview even when the actual print is fine. - The Labelary preview shows only the current page; the printed/exported ZPL still contains every page. diff --git a/src/components/Canvas/KonvaObject.tsx b/src/components/Canvas/KonvaObject.tsx index f6b95df6..38d85a4f 100644 --- a/src/components/Canvas/KonvaObject.tsx +++ b/src/components/Canvas/KonvaObject.tsx @@ -17,7 +17,9 @@ 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. + * follows drag translations together with the body. Tracing the declared + * bbox (not the inset body) means a thick outline shape gets a marker on + * its outer pixel, matching the user's mental model of selection. */ function SelectionOverlay({ width, @@ -48,6 +50,31 @@ function SelectionOverlay({ ); } +/** Ellipse-shaped counterpart of `SelectionOverlay` for ^GE / ^GC bodies. */ +function EllipseSelectionOverlay({ + rx, + ry, + color, +}: { + rx: number; + ry: number; + color: string; +}) { + return ( + + ); +} + const BARCODE_TYPES = new Set([ "code128", "code39", @@ -93,21 +120,18 @@ export function KonvaObject(props_: Props) { * * 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. + * 1. `id={obj.id}` sits on the **outermost render node**. Every shape + * wraps body plus selection overlay in a `` and puts 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. + * 2. Selection visuals: shapes draw a dedicated overlay component + * (`SelectionOverlay` for rectangular bodies, `EllipseSelectionOverlay` + * for ^GE / ^GC) that traces the declared outer bbox. Decoupling the + * marker from the body means a thick outline (or a ^LRY difference- + * blended body) keeps a clean blue marker on its outer pixel rather + * than re-tracing the body's stroke. The overlay sits inside the same + * Group as the body so drag translations move both together. */ function KonvaObjectInner({ obj, @@ -368,14 +392,16 @@ function KonvaObjectInner({ if (obj.type === "ellipse") { const p = obj.props; - const rx = dotsToPx(p.width, scale, dpmm) / 2; - const ry = dotsToPx(p.height, scale, dpmm) / 2; + const w = dotsToPx(p.width, scale, dpmm); + const h = dotsToPx(p.height, scale, dpmm); + const rx = w / 2; + const ry = h / 2; const stroke = p.color === "B" ? "#000000" : "#cccccc"; const strokeWidth = Math.max(dotsToPx(p.thickness, scale, dpmm), 0.5); // Option-A geometry — same outlineInset() definition as the box // path so the firmware's clamp-to-solid rule stays consistent // across shapes; only the centred-stroke placement differs. - const insetGeom = outlineInset(rx * 2, ry * 2, strokeWidth, p.filled); + const insetGeom = outlineInset(w, h, strokeWidth, p.filled); const renderFilled = insetGeom.renderFilled; const insetRx = insetGeom.width / 2; const insetRy = insetGeom.height / 2; @@ -384,47 +410,44 @@ function KonvaObjectInner({ ? "#000000" : "#ffffff" : "transparent"; + // Group-at-top-left wrapper mirrors the box path: keeps the body's + // own stroke + thickness while a separate EllipseSelectionOverlay + // traces the outer bbox in the selection colour. return ( - { - // Center-anchored: snap the top-left corner, then re-add radius - const snapped = snapPos(e.target.x() - rx, e.target.y() - ry); - e.target.position({ x: snapped.x + rx, y: snapped.y + ry }); - }} - onDragEnd={(e) => { - onChange({ - x: pxToDots(e.target.x() - rx - offsetX, scale, dpmm), - y: pxToDots(e.target.y() - ry - offsetY, scale, dpmm), - }); - }} - /> + onDragMove={handleDragMove} + onDragEnd={handleDragEnd} + > + + {isSelected && ( + + )} + ); } if (obj.type === "circle") { const p = obj.props; - const r = dotsToPx(p.diameter, scale, dpmm) / 2; + const d = dotsToPx(p.diameter, scale, dpmm); + const r = d / 2; const stroke = p.color === "B" ? "#000000" : "#cccccc"; const strokeWidth = Math.max(dotsToPx(p.thickness, scale, dpmm), 0.5); // Option-A geometry — same outlineInset() definition as box/ellipse. - const insetGeom = outlineInset(r * 2, r * 2, strokeWidth, p.filled); + const insetGeom = outlineInset(d, d, strokeWidth, p.filled); const renderFilled = insetGeom.renderFilled; const insetR = insetGeom.width / 2; const fill = renderFilled @@ -433,34 +456,28 @@ function KonvaObjectInner({ : "#ffffff" : "transparent"; return ( - { - const snapped = snapPos(e.target.x() - r, e.target.y() - r); - e.target.position({ x: snapped.x + r, y: snapped.y + r }); - }} - onDragEnd={(e) => { - onChange({ - x: pxToDots(e.target.x() - r - offsetX, scale, dpmm), - y: pxToDots(e.target.y() - r - offsetY, scale, dpmm), - }); - }} - /> + onDragMove={handleDragMove} + onDragEnd={handleDragEnd} + > + + {isSelected && ( + + )} + ); } diff --git a/src/components/Canvas/hooks/useKonvaTransformer.ts b/src/components/Canvas/hooks/useKonvaTransformer.ts index 0eacca70..887007af 100644 --- a/src/components/Canvas/hooks/useKonvaTransformer.ts +++ b/src/components/Canvas/hooks/useKonvaTransformer.ts @@ -9,7 +9,6 @@ import { findObjectById, isGroup } from "../../../types/Group"; import { applyHeightSnap, pinInactiveEdges, - transformNodeTopLeft, positionDidMove, forceSquareBox, type BoundingBox, @@ -303,7 +302,6 @@ export function useKonvaTransformer({ } const sx = node.scaleX(); const sy = node.scaleY(); - const nodeWidth = node.width(); const nodeHeight = node.height(); node.scaleX(1); node.scaleY(1); @@ -312,18 +310,8 @@ export function useKonvaTransformer({ cleanupTransformState(); return; } - const isCenterAnchored = ObjectRegistry[obj.type]?.nodeOrigin === "center"; - const topLeft = transformNodeTopLeft( - node.x(), - node.y(), - nodeWidth, - nodeHeight, - sx, - sy, - isCenterAnchored, - ); - const renderedXDots = pxToDots(topLeft.x - objectsOffsetX, scale, dpmm); - const renderedYDots = pxToDots(topLeft.y - labelOffsetY, scale, dpmm); + const renderedXDots = pxToDots(node.x() - objectsOffsetX, scale, dpmm); + const renderedYDots = pxToDots(node.y() - labelOffsetY, scale, dpmm); // For FT-anchored 1D barcodes, model.y is the bar baseline — needs the // post-resize bar height to convert from the bbox top back. Pipe the // scaled height through the same snap() commitBarcodeWidthHeight- diff --git a/src/components/Canvas/transformerGeometry.test.ts b/src/components/Canvas/transformerGeometry.test.ts index 693c41a5..f37978a3 100644 --- a/src/components/Canvas/transformerGeometry.test.ts +++ b/src/components/Canvas/transformerGeometry.test.ts @@ -3,7 +3,6 @@ import { snapBoxHeight, pinBottomEdge, isTopAnchorResize, - transformNodeTopLeft, positionDidMove, forceSquareBox, applyHeightSnap, @@ -62,29 +61,6 @@ describe("isTopAnchorResize", () => { }); }); -describe("transformNodeTopLeft", () => { - it("passes top-left-anchored nodes through unchanged", () => { - // Rect / Image / Text use their top-left as the Konva origin. - const result = transformNodeTopLeft(100, 50, 200, 100, 1.5, 1, false); - expect(result).toEqual({ x: 100, y: 50 }); - }); - - it("subtracts half the visual size for center-anchored nodes (Ellipse)", () => { - // Ellipse with intrinsic size 100x80, scaled 2x in both axes. - // node.x()/y() are the center; visual radius = nodeSize * scale / 2. - const result = transformNodeTopLeft(200, 100, 100, 80, 2, 2, true); - expect(result).toEqual({ x: 100, y: 20 }); - }); - - it("uses the captured (pre-reset) node size, not the post-reset one", () => { - // Even when scale is no longer 1 conceptually, the formula uses the - // intrinsic nodeWidth/nodeHeight times the scale to derive visual size. - const result = transformNodeTopLeft(150, 150, 50, 50, 4, 4, true); - expect(result.x).toBe(50); // 150 - (50 * 4) / 2 = 150 - 100 - expect(result.y).toBe(50); - }); -}); - describe("positionDidMove", () => { it("returns false when the position matches within the tolerance", () => { expect(positionDidMove(100, 100)).toBe(false); diff --git a/src/components/Canvas/transformerGeometry.ts b/src/components/Canvas/transformerGeometry.ts index ae9ab4ad..72e8c4a5 100644 --- a/src/components/Canvas/transformerGeometry.ts +++ b/src/components/Canvas/transformerGeometry.ts @@ -50,27 +50,6 @@ export function isTopAnchorResize( return Math.abs(newBox.y - oldBox.y) > thresholdPx; } -/** - * Translate a transformed Konva node's coordinate into the model's top-left - * stage-pixel coordinate. Konva's Ellipse positions by its center; everything - * else by top-left. The visual size after a Transformer drag is `nodeSize * s` - * (full width / height): the node's intrinsic size is unchanged at this point, - * so only the scale captured before reset reflects the post-drag dimensions. - */ -export function transformNodeTopLeft( - nodeX: number, - nodeY: number, - nodeWidth: number, - nodeHeight: number, - sx: number, - sy: number, - isCenterAnchored: boolean, -): { x: number; y: number } { - const dx = isCenterAnchored ? (nodeWidth * sx) / 2 : 0; - const dy = isCenterAnchored ? (nodeHeight * sy) / 2 : 0; - 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 diff --git a/src/registry/circle.tsx b/src/registry/circle.tsx index ecf2e0d6..cbf1c6fa 100644 --- a/src/registry/circle.tsx +++ b/src/registry/circle.tsx @@ -22,7 +22,6 @@ export const circle: ObjectTypeDefinition = { color: 'B', }, defaultSize: { width: 100, height: 100 }, - nodeOrigin: 'center', uniformScale: true, // Force a uniform scale: take the smaller of the two axes so the resized diff --git a/src/registry/ellipse.tsx b/src/registry/ellipse.tsx index e0489130..c77295f8 100644 --- a/src/registry/ellipse.tsx +++ b/src/registry/ellipse.tsx @@ -25,7 +25,6 @@ export const ellipse: ObjectTypeDefinition = { color: 'B', }, defaultSize: { width: 150, height: 100 }, - nodeOrigin: 'center', commitTransform: commitWidthHeightTransform, diff --git a/src/types/ObjectType.ts b/src/types/ObjectType.ts index f36c3e33..fc0748d2 100644 --- a/src/types/ObjectType.ts +++ b/src/types/ObjectType.ts @@ -73,13 +73,6 @@ export interface ObjectTypeDefinition

{ group: ObjectGroup; defaultProps: P; defaultSize: { width: number; height: number }; - /** - * Origin of the Konva node used to render this type. Defaults to 'top-left'. - * Set to 'center' for shapes whose Konva counterpart positions by their - * center (e.g. Ellipse), so the transformer can convert the node coordinate - * back to the model's top-left convention. - */ - nodeOrigin?: 'center' | 'top-left'; /** * True if the rendered height is fixed by the symbology spec rather than the * `height` prop (e.g. GS1 Databar Omnidirectional). The transformer disables