Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
161 changes: 89 additions & 72 deletions src/components/Canvas/KonvaObject.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
<Ellipse
x={rx}
y={ry}
radiusX={rx}
radiusY={ry}
stroke={color}
strokeWidth={1.5}
strokeScaleEnabled={false}
fill="transparent"
listening={false}
/>
);
}

const BARCODE_TYPES = new Set([
"code128",
"code39",
Expand Down Expand Up @@ -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 `<Group>` 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 `<Group>` 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
* `<SelectionOverlay>` 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,
Expand Down Expand Up @@ -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;
Expand All @@ -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 (
<Ellipse
<Group
id={obj.id}
x={x + rx}
y={y + ry}
radiusX={insetRx}
radiusY={insetRy}
stroke={isSelected ? colors.selection : stroke}
strokeWidth={
isSelected
? Math.max(strokeWidth, 1.5)
: renderFilled
? 0
: strokeWidth
}
strokeScaleEnabled={false}
fill={fill}
x={x}
y={y}
draggable={!obj.locked}
{...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);
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}
>
<Ellipse
x={rx}
y={ry}
radiusX={insetRx}
radiusY={insetRy}
stroke={stroke}
strokeWidth={renderFilled ? 0 : strokeWidth}
strokeScaleEnabled={false}
fill={fill}
/>
{isSelected && (
<EllipseSelectionOverlay rx={rx} ry={ry} color={colors.selection} />
)}
</Group>
);
}

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
Expand All @@ -433,34 +456,28 @@ function KonvaObjectInner({
: "#ffffff"
: "transparent";
return (
<Circle
<Group
id={obj.id}
x={x + r}
y={y + r}
radius={insetR}
stroke={isSelected ? colors.selection : stroke}
strokeWidth={
isSelected
? Math.max(strokeWidth, 1.5)
: renderFilled
? 0
: strokeWidth
}
strokeScaleEnabled={false}
fill={fill}
x={x}
y={y}
draggable={!obj.locked}
{...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 });
}}
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}
>
<Circle
x={r}
y={r}
radius={insetR}
stroke={stroke}
strokeWidth={renderFilled ? 0 : strokeWidth}
strokeScaleEnabled={false}
fill={fill}
/>
{isSelected && (
<EllipseSelectionOverlay rx={r} ry={r} color={colors.selection} />
)}
</Group>
);
}

Expand Down
16 changes: 2 additions & 14 deletions src/components/Canvas/hooks/useKonvaTransformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import { findObjectById, isGroup } from "../../../types/Group";
import {
applyHeightSnap,
pinInactiveEdges,
transformNodeTopLeft,
positionDidMove,
forceSquareBox,
type BoundingBox,
Expand Down Expand Up @@ -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);
Expand All @@ -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-
Expand Down
24 changes: 0 additions & 24 deletions src/components/Canvas/transformerGeometry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import {
snapBoxHeight,
pinBottomEdge,
isTopAnchorResize,
transformNodeTopLeft,
positionDidMove,
forceSquareBox,
applyHeightSnap,
Expand Down Expand Up @@ -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);
Expand Down
21 changes: 0 additions & 21 deletions src/components/Canvas/transformerGeometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion src/registry/circle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ export const circle: ObjectTypeDefinition<CircleProps> = {
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
Expand Down
1 change: 0 additions & 1 deletion src/registry/ellipse.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ export const ellipse: ObjectTypeDefinition<EllipseProps> = {
color: 'B',
},
defaultSize: { width: 150, height: 100 },
nodeOrigin: 'center',

commitTransform: commitWidthHeightTransform,

Expand Down
7 changes: 0 additions & 7 deletions src/types/ObjectType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,6 @@ export interface ObjectTypeDefinition<P extends object = object> {
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
Expand Down