Skip to content

Commit eba48db

Browse files
authored
Merge pull request #73 from u8array/refactor/unify-ellipse-circle
refactor(shape): unify circle into ellipse with lockAspect
2 parents eb77ac2 + 7745ce5 commit eba48db

52 files changed

Lines changed: 376 additions & 406 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎src/components/Canvas/KonvaObject.tsx‎

Lines changed: 2 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useFontCacheVersion } from "../../hooks/useFontCacheVersion";
2-
import { Circle, Ellipse, Group, Rect, Text } from "react-konva";
2+
import { Ellipse, Group, Rect, Text } from "react-konva";
33
import { BarcodeObject } from "./BarcodeObject";
44
import { LineObject } from "./LineObject";
55
import { ImageObject } from "./ImageObject";
@@ -297,7 +297,7 @@ function KonvaObjectInner({
297297
// `renderFilled`.
298298
// promoteFilled=true: see note in shapeRender.ts — ^GB rects extrude
299299
// their solid fill to max(w,t) × max(h,t) per Zebra firmware. The
300-
// ellipse / circle branches below leave this off because ^GE / ^GC
300+
// ellipse branch below leaves this off because ^GE / ^GC
301301
// collapse to solid at their declared bbox without promotion.
302302
const insetGeom = outlineInset(w, h, strokeWidth, p.filled, true);
303303
const renderFilled = insetGeom.renderFilled;
@@ -440,46 +440,5 @@ function KonvaObjectInner({
440440
);
441441
}
442442

443-
if (obj.type === "circle") {
444-
const p = obj.props;
445-
const d = dotsToPx(p.diameter, scale, dpmm);
446-
const r = d / 2;
447-
const stroke = p.color === "B" ? "#000000" : "#cccccc";
448-
const strokeWidth = Math.max(dotsToPx(p.thickness, scale, dpmm), 0.5);
449-
// Option-A geometry — same outlineInset() definition as box/ellipse.
450-
const insetGeom = outlineInset(d, d, strokeWidth, p.filled);
451-
const renderFilled = insetGeom.renderFilled;
452-
const insetR = insetGeom.width / 2;
453-
const fill = renderFilled
454-
? p.color === "B"
455-
? "#000000"
456-
: "#ffffff"
457-
: "transparent";
458-
return (
459-
<Group
460-
id={obj.id}
461-
x={x}
462-
y={y}
463-
draggable={!obj.locked}
464-
{...selectionHandlers(onSelect)}
465-
onDragMove={handleDragMove}
466-
onDragEnd={handleDragEnd}
467-
>
468-
<Circle
469-
x={r}
470-
y={r}
471-
radius={insetR}
472-
stroke={stroke}
473-
strokeWidth={renderFilled ? 0 : strokeWidth}
474-
strokeScaleEnabled={false}
475-
fill={fill}
476-
/>
477-
{isSelected && (
478-
<EllipseSelectionOverlay rx={r} ry={r} color={colors.selection} />
479-
)}
480-
</Group>
481-
);
482-
}
483-
484443
return null;
485444
}

‎src/components/Canvas/LabelCanvas.tsx‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -469,7 +469,7 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(function LabelCa
469469

470470
// Quick 90°-rotation button overlay. Only step-rotation objects (those
471471
// with a `rotation: N|R|I|B` prop — text, serial, all barcodes) get the
472-
// affordance; box/ellipse/circle/line/image rotate freely via the
472+
// affordance; box/ellipse/line/image rotate freely via the
473473
// Transformer or have no rotation. Positioned at the visual top-right
474474
// corner of the selected node, derived from getClientRect so it tracks
475475
// the rendered bbox through both object-rotation and viewRotation.
@@ -701,21 +701,29 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(function LabelCa
701701
}
702702
const pos = pointerToLabelDots(lastPointerRef.current.x, lastPointerRef.current.y);
703703
if (!pos) return;
704-
const type = (event.active.data.current as PaletteDragData | undefined)?.type;
704+
const dragData = event.active.data.current as PaletteDragData | undefined;
705+
const type = dragData?.type;
705706
if (!type) return;
706707
const def = ObjectRegistry[type];
707708
if (!def) return;
708-
setGhost({ id: "__ghost__", type, ...pos, rotation: 0, props: def.defaultProps } as LeafObject);
709+
setGhost({
710+
id: "__ghost__",
711+
type,
712+
...pos,
713+
rotation: 0,
714+
props: { ...def.defaultProps, ...dragData?.propsOverride },
715+
} as LeafObject);
709716
},
710717
onDragEnd(event) {
711718
setGhost(null);
712719
if (previewLocks) return;
713720
if (event.over?.id !== "canvas") return;
714721
const pos = pointerToLabelDots(lastPointerRef.current.x, lastPointerRef.current.y);
715722
if (!pos) return;
716-
const type = (event.active.data.current as PaletteDragData | undefined)?.type;
723+
const dragData = event.active.data.current as PaletteDragData | undefined;
724+
const type = dragData?.type;
717725
if (!type) return;
718-
addObject(type, pos);
726+
addObject(type, pos, dragData.propsOverride);
719727
},
720728
onDragCancel() {
721729
setGhost(null);

‎src/components/Canvas/hooks/useKonvaTransformer.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,12 @@ export function useKonvaTransformer({
189189
: undefined;
190190
const resizeEnabled = selectedIds.length <= 1 && !singleSelected?.locked;
191191
const singleType = singleSelected?.type ?? "";
192-
const isUniformScale = !!ObjectRegistry[singleType]?.uniformScale;
192+
const uniformScaleDef = ObjectRegistry[singleType]?.uniformScale;
193+
const isUniformScale =
194+
typeof uniformScaleDef === "function"
195+
? !!singleSelected && !isGroup(singleSelected) &&
196+
uniformScaleDef(singleSelected.props as object)
197+
: !!uniformScaleDef;
193198
const enabledAnchors: string[] | undefined =
194199
selectedIds.length > 1
195200
? []

‎src/components/Palette/ObjectPalette.tsx‎

Lines changed: 69 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { useDraggable } from '@dnd-kit/core';
22
import { ObjectRegistry } from '../../registry';
33
import { PALETTE_GROUPS } from './paletteGroups';
4-
import type { ObjectTypeDefinition } from '../../types/ObjectType';
4+
import { VIRTUAL_PALETTE_ENTRIES, type VirtualPaletteEntry } from './virtualEntries';
5+
import type { ObjectGroup } from '../../types/ObjectType';
56
import { useT } from '../../lib/useT';
67
import { useLabelStore } from '../../store/labelStore';
78
import { mmToDots } from '../../lib/coordinates';
@@ -10,24 +11,33 @@ import { CollapsibleSection } from '../ui/CollapsibleSection';
1011
import type { PaletteDragData } from '../../dnd/types';
1112

1213
interface PaletteEntryProps {
14+
/** Unique within the palette: registry type or virtual entry id. */
15+
id: string;
16+
/** Registry type to instantiate. Equals `id` for non-virtual entries. */
1317
type: string;
14-
def: ObjectTypeDefinition;
18+
icon: string;
19+
label: string;
20+
defaultSize: { width: number; height: number };
21+
propsOverride?: object;
1522
}
1623

17-
function PaletteEntry({ type, def }: PaletteEntryProps) {
18-
const t = useT();
24+
function PaletteEntry({ id, type, icon, label, defaultSize, propsOverride }: PaletteEntryProps) {
1925
const addObject = useLabelStore((s) => s.addObject);
2026
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
21-
id: `palette-${type}`,
22-
data: { type } satisfies PaletteDragData,
27+
id: `palette-${id}`,
28+
data: { type, propsOverride } satisfies PaletteDragData,
2329
});
2430

2531
const handleDoubleClick = () => {
26-
const { label } = useLabelStore.getState();
27-
addObject(type, {
28-
x: Math.round(mmToDots(label.widthMm, label.dpmm) / 2 - def.defaultSize.width / 2),
29-
y: Math.round(mmToDots(label.heightMm, label.dpmm) / 2 - def.defaultSize.height / 2),
30-
});
32+
const { label: labelConfig } = useLabelStore.getState();
33+
addObject(
34+
type,
35+
{
36+
x: Math.round(mmToDots(labelConfig.widthMm, labelConfig.dpmm) / 2 - defaultSize.width / 2),
37+
y: Math.round(mmToDots(labelConfig.heightMm, labelConfig.dpmm) / 2 - defaultSize.height / 2),
38+
},
39+
propsOverride,
40+
);
3141
};
3242

3343
return (
@@ -47,34 +57,71 @@ function PaletteEntry({ type, def }: PaletteEntryProps) {
4757
`}
4858
>
4959
<DragHandleIcon className="w-2 h-3.5 shrink-0 text-muted opacity-0 group-hover:opacity-60 transition-opacity" />
50-
<span className="font-mono text-[11px] text-accent w-6 text-center shrink-0">
51-
{def.icon}
52-
</span>
53-
<span className="text-xs text-text">
54-
{(t.types as Record<string, string>)[type] ?? def.label}
55-
</span>
60+
<span className="font-mono text-[11px] text-accent w-6 text-center shrink-0">{icon}</span>
61+
<span className="text-xs text-text">{label}</span>
5662
</div>
5763
);
5864
}
5965

66+
interface ResolvedEntry {
67+
id: string;
68+
type: string;
69+
icon: string;
70+
label: string;
71+
defaultSize: { width: number; height: number };
72+
propsOverride?: object;
73+
}
74+
75+
function resolveEntries(
76+
group: ObjectGroup,
77+
types: Record<string, string>,
78+
): ResolvedEntry[] {
79+
const registry = Object.entries(ObjectRegistry)
80+
.filter(([, def]) => def.group === group)
81+
.map(([type, def]): ResolvedEntry => ({
82+
id: type,
83+
type,
84+
icon: def.icon,
85+
label: types[type] ?? def.label,
86+
defaultSize: def.defaultSize,
87+
}));
88+
const virtual = VIRTUAL_PALETTE_ENTRIES
89+
.filter((v) => v.group === group)
90+
.map((v: VirtualPaletteEntry): ResolvedEntry => ({
91+
id: v.id,
92+
type: v.type,
93+
icon: v.icon,
94+
label: types[v.labelKey] ?? v.fallbackLabel,
95+
defaultSize: v.defaultSize,
96+
propsOverride: v.propsOverride,
97+
}));
98+
return [...registry, ...virtual];
99+
}
100+
60101
export function ObjectPalette() {
61102
const t = useT();
62103

63104
return (
64105
<div className="p-3 flex flex-col gap-3">
65106
{PALETTE_GROUPS.map((group) => {
66-
const entries = Object.entries(ObjectRegistry).filter(
67-
([, def]) => def.group === group.key,
68-
);
107+
const entries = resolveEntries(group.key, t.types as Record<string, string>);
69108
if (entries.length === 0) return null;
70109
return (
71110
<CollapsibleSection
72111
key={group.key}
73112
id={`palette-${group.key}`}
74113
title={t.palette[group.labelKey]}
75114
>
76-
{entries.map(([type, def]) => (
77-
<PaletteEntry key={type} type={type} def={def} />
115+
{entries.map((e) => (
116+
<PaletteEntry
117+
key={e.id}
118+
id={e.id}
119+
type={e.type}
120+
icon={e.icon}
121+
label={e.label}
122+
defaultSize={e.defaultSize}
123+
propsOverride={e.propsOverride}
124+
/>
78125
))}
79126
</CollapsibleSection>
80127
);
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { ObjectGroup } from '../../types/ObjectType';
2+
import type { EllipseProps } from '../../registry/ellipse';
3+
4+
/**
5+
* Palette-only sugar entries: surface alternative starting configs for a
6+
* registry type without inflating the type union. The "Circle" entry
7+
* instantiates an `ellipse` with `lockAspect: true` so the transformer
8+
* keeps it square; round-trips through ^GC on export and ^GC on import
9+
* preserve the flag, so the file format stays canonical.
10+
*/
11+
export interface VirtualPaletteEntry {
12+
/** Unique key inside the palette ("circle"). Does NOT collide with
13+
* registry types — those use their own key directly. */
14+
id: string;
15+
/** Registry type to instantiate. */
16+
type: string;
17+
group: ObjectGroup;
18+
icon: string;
19+
/** Key into `t.types` for the visible label. */
20+
labelKey: string;
21+
/** Display label fallback when the locale is missing the key. */
22+
fallbackLabel: string;
23+
/** Default size used by the drop-on-canvas position centring math. */
24+
defaultSize: { width: number; height: number };
25+
/** Merged on top of the registry type's `defaultProps` at creation. */
26+
propsOverride: object;
27+
}
28+
29+
export const VIRTUAL_PALETTE_ENTRIES: VirtualPaletteEntry[] = [
30+
{
31+
id: 'circle',
32+
type: 'ellipse',
33+
group: 'shape',
34+
icon: '●',
35+
labelKey: 'circle',
36+
fallbackLabel: 'Circle',
37+
defaultSize: { width: 100, height: 100 },
38+
propsOverride: {
39+
width: 100,
40+
height: 100,
41+
lockAspect: true,
42+
} satisfies Partial<EllipseProps>,
43+
},
44+
];

‎src/dnd/types.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export interface PaletteDragData {
22
type: string;
3+
propsOverride?: object;
34
}

‎src/lib/shapeGeometry.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* Mirrors Zebra firmware's rendering semantics so that the on-screen
55
* Konva canvas, the @napi-rs/canvas pixel-regression renderer, and the
66
* ZPL output all describe the same shape:
7-
* - Outlines (box / ellipse / circle) extrude thickness *inward* from
7+
* - Outlines (box / ellipse) extrude thickness *inward* from
88
* the declared bbox; thickness ≥ min(w, h)/2 collapses to solid.
99
* - Diagonal lines (^GD) place the conceptual line on the *left long
1010
* edge* of a parallelogram and extrude thickness in +x only — both
@@ -16,7 +16,7 @@
1616
*/
1717

1818
/**
19-
* Inset values for an outline rectangle / ellipse / circle whose
19+
* Inset values for an outline rectangle / ellipse whose
2020
* declared bbox is (0, 0, w, h) with stroke thickness t. The caller
2121
* uses these to position a *centred-stroke* primitive whose outer
2222
* edge lands on the declared bbox.

‎src/lib/shapeRender.ts‎

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import type { LabelObject } from "../types/Group";
22
import { diagonalPolygonPoints, outlineInset } from "./shapeGeometry";
33

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

109-
case "circle": {
110-
drawEllipticalOutline(
111-
ctx,
112-
obj.x, obj.y,
113-
obj.props.diameter, obj.props.diameter,
114-
obj.props.thickness, obj.props.filled, obj.props.color,
115-
);
116-
return;
117-
}
118-
119108
case "line": {
120109
const p = obj.props;
121110
const color = p.color === "B" ? "#000000" : "#ffffff";

‎src/lib/zplParser.test.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,11 @@ describe('parseZPL — ^GE ellipse', () => {
487487
const { objects } = parseZPL('^XA^FO0,0^GE100,80,80,B^FS^XZ', 8);
488488
expect(props(objects[0]).filled).toBe(true);
489489
});
490+
491+
it('preserves the original thickness on filled ^GE (lossless round-trip)', () => {
492+
const { objects } = parseZPL('^XA^FO0,0^GE100,80,80,B^FS^XZ', 8);
493+
expect(props(objects[0]).thickness).toBe(80);
494+
});
490495
});
491496

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

502508
it('creates a filled circle when thickness >= diameter', () => {
503509
const { objects } = parseZPL('^XA^FO0,0^GC50,50,B^FS^XZ', 8);
504510
expect(props(objects[0]).filled).toBe(true);
505511
});
512+
513+
it('preserves the original thickness on filled ^GC (lossless round-trip)', () => {
514+
const { objects } = parseZPL('^XA^FO0,0^GC50,50,B^FS^XZ', 8);
515+
expect(props(objects[0]).thickness).toBe(50);
516+
});
506517
});
507518

508519
describe('parseZPL — ^GD diagonal line', () => {

0 commit comments

Comments
 (0)