Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
162 changes: 90 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,19 @@ 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**. Plain Text
* puts it directly on the Text node; every other shape wraps body
* plus selection overlay in a `<Group>` and puts the id there.
Comment thread
u8array marked this conversation as resolved.
Outdated
* 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 +393,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 +411,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 +457,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