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
45 changes: 2 additions & 43 deletions src/components/Canvas/KonvaObject.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useFontCacheVersion } from "../../hooks/useFontCacheVersion";
import { Circle, Ellipse, Group, Rect, Text } from "react-konva";
import { Ellipse, Group, Rect, Text } from "react-konva";
import { BarcodeObject } from "./BarcodeObject";
import { LineObject } from "./LineObject";
import { ImageObject } from "./ImageObject";
Expand Down Expand Up @@ -297,7 +297,7 @@ function KonvaObjectInner({
// `renderFilled`.
// promoteFilled=true: see note in shapeRender.ts — ^GB rects extrude
// their solid fill to max(w,t) × max(h,t) per Zebra firmware. The
// ellipse / circle branches below leave this off because ^GE / ^GC
// ellipse branch below leaves this off because ^GE / ^GC
// collapse to solid at their declared bbox without promotion.
const insetGeom = outlineInset(w, h, strokeWidth, p.filled, true);
const renderFilled = insetGeom.renderFilled;
Expand Down Expand Up @@ -440,46 +440,5 @@ function KonvaObjectInner({
);
}

if (obj.type === "circle") {
const p = obj.props;
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(d, d, strokeWidth, p.filled);
const renderFilled = insetGeom.renderFilled;
const insetR = insetGeom.width / 2;
const fill = renderFilled
? p.color === "B"
? "#000000"
: "#ffffff"
: "transparent";
return (
<Group
id={obj.id}
x={x}
y={y}
draggable={!obj.locked}
{...selectionHandlers(onSelect)}
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>
);
}

return null;
}
18 changes: 13 additions & 5 deletions src/components/Canvas/LabelCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -469,7 +469,7 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(function LabelCa

// Quick 90°-rotation button overlay. Only step-rotation objects (those
// with a `rotation: N|R|I|B` prop — text, serial, all barcodes) get the
// affordance; box/ellipse/circle/line/image rotate freely via the
// affordance; box/ellipse/line/image rotate freely via the
// Transformer or have no rotation. Positioned at the visual top-right
// corner of the selected node, derived from getClientRect so it tracks
// the rendered bbox through both object-rotation and viewRotation.
Expand Down Expand Up @@ -701,21 +701,29 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(function LabelCa
}
const pos = pointerToLabelDots(lastPointerRef.current.x, lastPointerRef.current.y);
if (!pos) return;
const type = (event.active.data.current as PaletteDragData | undefined)?.type;
const dragData = event.active.data.current as PaletteDragData | undefined;
const type = dragData?.type;
if (!type) return;
const def = ObjectRegistry[type];
if (!def) return;
setGhost({ id: "__ghost__", type, ...pos, rotation: 0, props: def.defaultProps } as LeafObject);
setGhost({
id: "__ghost__",
type,
...pos,
rotation: 0,
props: { ...def.defaultProps, ...dragData?.propsOverride },
} as LeafObject);
},
onDragEnd(event) {
setGhost(null);
if (previewLocks) return;
if (event.over?.id !== "canvas") return;
const pos = pointerToLabelDots(lastPointerRef.current.x, lastPointerRef.current.y);
if (!pos) return;
const type = (event.active.data.current as PaletteDragData | undefined)?.type;
const dragData = event.active.data.current as PaletteDragData | undefined;
const type = dragData?.type;
if (!type) return;
addObject(type, pos);
addObject(type, pos, dragData.propsOverride);
},
onDragCancel() {
setGhost(null);
Expand Down
7 changes: 6 additions & 1 deletion src/components/Canvas/hooks/useKonvaTransformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,12 @@ export function useKonvaTransformer({
: undefined;
const resizeEnabled = selectedIds.length <= 1 && !singleSelected?.locked;
const singleType = singleSelected?.type ?? "";
const isUniformScale = !!ObjectRegistry[singleType]?.uniformScale;
const uniformScaleDef = ObjectRegistry[singleType]?.uniformScale;
const isUniformScale =
typeof uniformScaleDef === "function"
? !!singleSelected && !isGroup(singleSelected) &&
uniformScaleDef(singleSelected.props as object)
: !!uniformScaleDef;
const enabledAnchors: string[] | undefined =
selectedIds.length > 1
? []
Expand Down
91 changes: 69 additions & 22 deletions src/components/Palette/ObjectPalette.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useDraggable } from '@dnd-kit/core';
import { ObjectRegistry } from '../../registry';
import { PALETTE_GROUPS } from './paletteGroups';
import type { ObjectTypeDefinition } from '../../types/ObjectType';
import { VIRTUAL_PALETTE_ENTRIES, type VirtualPaletteEntry } from './virtualEntries';
import type { ObjectGroup } from '../../types/ObjectType';
import { useT } from '../../lib/useT';
import { useLabelStore } from '../../store/labelStore';
import { mmToDots } from '../../lib/coordinates';
Expand All @@ -10,24 +11,33 @@ import { CollapsibleSection } from '../ui/CollapsibleSection';
import type { PaletteDragData } from '../../dnd/types';

interface PaletteEntryProps {
/** Unique within the palette: registry type or virtual entry id. */
id: string;
/** Registry type to instantiate. Equals `id` for non-virtual entries. */
type: string;
def: ObjectTypeDefinition;
icon: string;
label: string;
defaultSize: { width: number; height: number };
propsOverride?: object;
}

function PaletteEntry({ type, def }: PaletteEntryProps) {
const t = useT();
function PaletteEntry({ id, type, icon, label, defaultSize, propsOverride }: PaletteEntryProps) {
const addObject = useLabelStore((s) => s.addObject);
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `palette-${type}`,
data: { type } satisfies PaletteDragData,
id: `palette-${id}`,
data: { type, propsOverride } satisfies PaletteDragData,
});

const handleDoubleClick = () => {
const { label } = useLabelStore.getState();
addObject(type, {
x: Math.round(mmToDots(label.widthMm, label.dpmm) / 2 - def.defaultSize.width / 2),
y: Math.round(mmToDots(label.heightMm, label.dpmm) / 2 - def.defaultSize.height / 2),
});
const { label: labelConfig } = useLabelStore.getState();
addObject(
type,
{
x: Math.round(mmToDots(labelConfig.widthMm, labelConfig.dpmm) / 2 - defaultSize.width / 2),
y: Math.round(mmToDots(labelConfig.heightMm, labelConfig.dpmm) / 2 - defaultSize.height / 2),
},
propsOverride,
);
};

return (
Expand All @@ -47,34 +57,71 @@ function PaletteEntry({ type, def }: PaletteEntryProps) {
`}
>
<DragHandleIcon className="w-2 h-3.5 shrink-0 text-muted opacity-0 group-hover:opacity-60 transition-opacity" />
<span className="font-mono text-[11px] text-accent w-6 text-center shrink-0">
{def.icon}
</span>
<span className="text-xs text-text">
{(t.types as Record<string, string>)[type] ?? def.label}
</span>
<span className="font-mono text-[11px] text-accent w-6 text-center shrink-0">{icon}</span>
<span className="text-xs text-text">{label}</span>
</div>
);
}

interface ResolvedEntry {
id: string;
type: string;
icon: string;
label: string;
defaultSize: { width: number; height: number };
propsOverride?: object;
}

function resolveEntries(
group: ObjectGroup,
types: Record<string, string>,
): ResolvedEntry[] {
const registry = Object.entries(ObjectRegistry)
.filter(([, def]) => def.group === group)
.map(([type, def]): ResolvedEntry => ({
id: type,
type,
icon: def.icon,
label: types[type] ?? def.label,
defaultSize: def.defaultSize,
}));
const virtual = VIRTUAL_PALETTE_ENTRIES
.filter((v) => v.group === group)
.map((v: VirtualPaletteEntry): ResolvedEntry => ({
id: v.id,
type: v.type,
icon: v.icon,
label: types[v.labelKey] ?? v.fallbackLabel,
defaultSize: v.defaultSize,
propsOverride: v.propsOverride,
}));
return [...registry, ...virtual];
}

export function ObjectPalette() {
const t = useT();

return (
<div className="p-3 flex flex-col gap-3">
{PALETTE_GROUPS.map((group) => {
const entries = Object.entries(ObjectRegistry).filter(
([, def]) => def.group === group.key,
);
const entries = resolveEntries(group.key, t.types as Record<string, string>);
if (entries.length === 0) return null;
return (
<CollapsibleSection
key={group.key}
id={`palette-${group.key}`}
title={t.palette[group.labelKey]}
>
{entries.map(([type, def]) => (
<PaletteEntry key={type} type={type} def={def} />
{entries.map((e) => (
<PaletteEntry
key={e.id}
id={e.id}
type={e.type}
icon={e.icon}
label={e.label}
defaultSize={e.defaultSize}
propsOverride={e.propsOverride}
/>
))}
</CollapsibleSection>
);
Expand Down
44 changes: 44 additions & 0 deletions src/components/Palette/virtualEntries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { ObjectGroup } from '../../types/ObjectType';
import type { EllipseProps } from '../../registry/ellipse';

/**
* Palette-only sugar entries: surface alternative starting configs for a
* registry type without inflating the type union. The "Circle" entry
* instantiates an `ellipse` with `lockAspect: true` so the transformer
* keeps it square; round-trips through ^GC on export and ^GC on import
* preserve the flag, so the file format stays canonical.
*/
export interface VirtualPaletteEntry {
/** Unique key inside the palette ("circle"). Does NOT collide with
* registry types — those use their own key directly. */
id: string;
/** Registry type to instantiate. */
type: string;
group: ObjectGroup;
icon: string;
/** Key into `t.types` for the visible label. */
labelKey: string;
/** Display label fallback when the locale is missing the key. */
fallbackLabel: string;
/** Default size used by the drop-on-canvas position centring math. */
defaultSize: { width: number; height: number };
/** Merged on top of the registry type's `defaultProps` at creation. */
propsOverride: object;
}

export const VIRTUAL_PALETTE_ENTRIES: VirtualPaletteEntry[] = [
{
id: 'circle',
type: 'ellipse',
group: 'shape',
icon: '●',
labelKey: 'circle',
fallbackLabel: 'Circle',
defaultSize: { width: 100, height: 100 },
propsOverride: {
width: 100,
height: 100,
lockAspect: true,
} satisfies Partial<EllipseProps>,
},
];
1 change: 1 addition & 0 deletions src/dnd/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export interface PaletteDragData {
type: string;
propsOverride?: object;
}
4 changes: 2 additions & 2 deletions src/lib/shapeGeometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* Mirrors Zebra firmware's rendering semantics so that the on-screen
* Konva canvas, the @napi-rs/canvas pixel-regression renderer, and the
* ZPL output all describe the same shape:
* - Outlines (box / ellipse / circle) extrude thickness *inward* from
* - Outlines (box / ellipse) extrude thickness *inward* from
* the declared bbox; thickness ≥ min(w, h)/2 collapses to solid.
* - Diagonal lines (^GD) place the conceptual line on the *left long
* edge* of a parallelogram and extrude thickness in +x only — both
Expand All @@ -16,7 +16,7 @@
*/

/**
* Inset values for an outline rectangle / ellipse / circle whose
* Inset values for an outline rectangle / ellipse whose
* declared bbox is (0, 0, w, h) with stroke thickness t. The caller
* uses these to position a *centred-stroke* primitive whose outer
* edge lands on the declared bbox.
Expand Down
17 changes: 3 additions & 14 deletions src/lib/shapeRender.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import type { LabelObject } from "../types/Group";
import { diagonalPolygonPoints, outlineInset } from "./shapeGeometry";

/** Inward-extruded ^GE / ^GC ring or solid disc, shared by ellipse and
* circle. Extracted so the two registry types — which carry different
* prop shapes — can each pass their normalised width / height in
* without the call-site needing a union-narrowing ternary. */
/** Inward-extruded ^GE / ^GC ring or solid disc for the ellipse type
* (circles round-trip as ellipse with `lockAspect:true`, sharing the
* same geometry). */
function drawEllipticalOutline(
ctx: CanvasRenderingContext2D,
x: number, y: number,
Expand Down Expand Up @@ -106,16 +105,6 @@ export function renderShape(
return;
}

case "circle": {
drawEllipticalOutline(
ctx,
obj.x, obj.y,
obj.props.diameter, obj.props.diameter,
obj.props.thickness, obj.props.filled, obj.props.color,
);
return;
}

case "line": {
const p = obj.props;
const color = p.color === "B" ? "#000000" : "#ffffff";
Expand Down
11 changes: 11 additions & 0 deletions src/lib/zplParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,11 @@ describe('parseZPL — ^GE ellipse', () => {
const { objects } = parseZPL('^XA^FO0,0^GE100,80,80,B^FS^XZ', 8);
expect(props(objects[0]).filled).toBe(true);
});

it('preserves the original thickness on filled ^GE (lossless round-trip)', () => {
const { objects } = parseZPL('^XA^FO0,0^GE100,80,80,B^FS^XZ', 8);
expect(props(objects[0]).thickness).toBe(80);
});
});

describe('parseZPL — ^GC circle', () => {
Expand All @@ -497,12 +502,18 @@ describe('parseZPL — ^GC circle', () => {
expect(props(objects[0]).width).toBe(100);
expect(props(objects[0]).height).toBe(100);
expect(props(objects[0]).filled).toBe(false);
expect(props(objects[0]).lockAspect).toBe(true);
});

it('creates a filled circle when thickness >= diameter', () => {
const { objects } = parseZPL('^XA^FO0,0^GC50,50,B^FS^XZ', 8);
expect(props(objects[0]).filled).toBe(true);
});

it('preserves the original thickness on filled ^GC (lossless round-trip)', () => {
const { objects } = parseZPL('^XA^FO0,0^GC50,50,B^FS^XZ', 8);
expect(props(objects[0]).thickness).toBe(50);
});
});

describe('parseZPL — ^GD diagonal line', () => {
Expand Down
Loading