Skip to content

Commit 9e55a15

Browse files
authored
Merge pull request #41 from u8array/feat/align-object-to-label
Feat/align object to label
2 parents 96ae77a + ecb4334 commit 9e55a15

39 files changed

Lines changed: 389 additions & 16 deletions

‎src/components/AppShell.tsx‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { useState, useEffect } from "react";
1+
import { useState, useEffect, useRef } from "react";
22
import { DndContext, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
33
import { ObjectPalette } from "./Palette/ObjectPalette";
44
import { LabelCanvas } from "./Canvas/LabelCanvas";
5+
import type { LabelCanvasHandle } from "./Canvas/LabelCanvas";
56
import { PropertiesPanel } from "./Properties/PropertiesPanel";
67
import { LayersPanel } from "./Properties/LayersPanel";
78
import { FontManager } from "./Fonts/FontManager";
@@ -91,6 +92,9 @@ export function AppShell() {
9192
handlePrint,
9293
} = useZplImportExport();
9394
const outputPanel = useOutputPanel(OUTPUT_DEFAULT_H);
95+
// Imperative handle to the canvas for actions PropertiesPanel needs live
96+
// render bboxes for (e.g. align-to-label centring).
97+
const canvasRef = useRef<LabelCanvasHandle>(null);
9498

9599
return (
96100
<div className="flex flex-col h-screen overflow-hidden bg-bg text-text font-sans">
@@ -271,6 +275,7 @@ export function AppShell() {
271275

272276
<main className="flex-1 overflow-hidden">
273277
<LabelCanvas
278+
ref={canvasRef}
274279
unit={unit}
275280
showGrid={showGrid}
276281
onGridToggle={() => setCanvasSettings({ showGrid: !showGrid })}
@@ -319,7 +324,7 @@ export function AppShell() {
319324
</button>
320325
</div>
321326
<div className="flex-1 overflow-y-auto">
322-
{rightTab === "properties" && <PropertiesPanel />}
327+
{rightTab === "properties" && <PropertiesPanel canvasRef={canvasRef} />}
323328
{rightTab === "layers" && <LayersPanel />}
324329
{rightTab === "fonts" && <FontManager />}
325330
</div>

‎src/components/Canvas/LabelCanvas.tsx‎

Lines changed: 82 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import {
2+
forwardRef,
3+
useImperativeHandle,
4+
useMemo,
25
useRef,
36
useEffect,
47
useState,
@@ -14,6 +17,8 @@ import { SNAP_OPTIONS } from "../../lib/units";
1417
import type { Unit } from "../../lib/units";
1518
import { computeSnap } from "../../lib/snapGuides";
1619
import type { SnapGuide } from "../../lib/snapGuides";
20+
import { computeGroupCenterDelta } from "../../lib/alignment";
21+
import type { AlignAxis } from "../../lib/alignment";
1722
import { KonvaObject } from "./KonvaObject";
1823
import { Grid } from "./Grid";
1924
import { GuideLines } from "./GuideLines";
@@ -50,7 +55,14 @@ interface Props {
5055
onViewRotationChange: (rotation: ViewRotation) => void;
5156
}
5257

53-
export function LabelCanvas({
58+
/** Imperative actions sibling components (PropertiesPanel) need from the
59+
* canvas. The canvas owns the live render bboxes via Konva — co-locating
60+
* the action with the data avoids round-tripping through the store. */
61+
export interface LabelCanvasHandle {
62+
alignSelectionToLabel: (axis: AlignAxis) => void;
63+
}
64+
65+
export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(function LabelCanvas({
5466
unit,
5567
showGrid,
5668
onGridToggle,
@@ -62,7 +74,7 @@ export function LabelCanvas({
6274
onZoomChange,
6375
viewRotation,
6476
onViewRotationChange,
65-
}: Props) {
77+
}, ref) {
6678
const containerRef = useRef<HTMLDivElement>(null);
6779
const stageRef = useRef<Konva.Stage>(null);
6880
const transformerRef = useRef<Konva.Transformer>(null);
@@ -246,13 +258,73 @@ export function LabelCanvas({
246258
// here matches the visual (rotation-aware) bounds — snap math operates in
247259
// stage-screen space, so it must reflect what the user sees, not the
248260
// un-rotated layout coordinates.
249-
const transformerSnapLabelRect = {
250-
id: "_lbl",
251-
x: visualLabelX,
252-
y: visualLabelY,
253-
width: visualLabelWidthPx,
254-
height: visualLabelHeightPx,
255-
};
261+
const transformerSnapLabelRect = useMemo(
262+
() => ({
263+
id: "_lbl",
264+
x: visualLabelX,
265+
y: visualLabelY,
266+
width: visualLabelWidthPx,
267+
height: visualLabelHeightPx,
268+
}),
269+
[visualLabelX, visualLabelY, visualLabelWidthPx, visualLabelHeightPx],
270+
);
271+
272+
// Imperative align-to-label: PropertiesPanel calls this directly via the
273+
// forwarded ref. Co-located with the render data — measure each selected
274+
// node's rendered bbox via Konva clientRect (single source of truth for
275+
// type-specific footprints like text baselines / barcode text-zones),
276+
// compute the centre-delta in screen px, map back through view rotation
277+
// and px-per-dot to model coordinates.
278+
useImperativeHandle(
279+
ref,
280+
() => ({
281+
alignSelectionToLabel: (axis) => {
282+
const stage = stageRef.current;
283+
if (!stage) return;
284+
const state = useLabelStore.getState();
285+
const ids = state.selectedIds;
286+
if (ids.length === 0) return;
287+
const objs = currentObjects(state);
288+
289+
const boxes = ids.flatMap((id) => {
290+
const node = stage.findOne<Konva.Node>(`#${id}`);
291+
if (!node) return [];
292+
const r = node.getClientRect({ relativeTo: stage });
293+
return [{ id, x: r.x, y: r.y, width: r.width, height: r.height }];
294+
});
295+
if (boxes.length === 0) return;
296+
297+
const { dx: screenDx, dy: screenDy } = computeGroupCenterDelta(
298+
boxes,
299+
transformerSnapLabelRect,
300+
axis,
301+
);
302+
if (screenDx === 0 && screenDy === 0) return;
303+
304+
const [layoutDx, layoutDy] = inverseRotateDelta(
305+
screenDx,
306+
screenDy,
307+
viewRotation,
308+
);
309+
// Round to integer dots — matches the `mmToDots` convention used by
310+
// PropertiesPanel inputs and keeps the store's x/y invariant so ZPL
311+
// emit doesn't see fractional coordinates.
312+
const pxPerDot = scale / label.dpmm;
313+
const dxDots = Math.round(layoutDx / pxPerDot);
314+
const dyDots = Math.round(layoutDy / pxPerDot);
315+
316+
const updates = ids.flatMap((id) => {
317+
const obj = objs.find((o) => o.id === id);
318+
if (!obj) return [];
319+
return [
320+
{ id, changes: { x: obj.x + dxDots, y: obj.y + dyDots } },
321+
];
322+
});
323+
if (updates.length > 0) updateObjects(updates);
324+
},
325+
}),
326+
[transformerSnapLabelRect, scale, label.dpmm, viewRotation, updateObjects],
327+
);
256328

257329
const {
258330
rotateEnabled,
@@ -673,4 +745,4 @@ export function LabelCanvas({
673745
)}
674746
</div>
675747
);
676-
}
748+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { useT } from "../../lib/useT";
2+
import type { AlignAxis } from "../../lib/alignment";
3+
import { CenterHIcon, CenterVIcon, CenterBothIcon } from "./AlignIcons";
4+
5+
const BUTTON_CLS =
6+
"p-1.5 rounded border border-border text-muted hover:text-text hover:bg-surface-2 transition-colors";
7+
8+
/**
9+
* Three-button row that centres the current selection on the label. Pure
10+
* presentation: the caller supplies the imperative `onAlign` handler so the
11+
* component stays decoupled from the canvas (which owns the live render
12+
* bboxes).
13+
*/
14+
export function AlignButtons({ onAlign }: { onAlign: (axis: AlignAxis) => void }) {
15+
const t = useT();
16+
17+
const buttons: { axis: AlignAxis; title: string; Icon: typeof CenterHIcon }[] = [
18+
{ axis: "h", title: t.properties.alignCenterH, Icon: CenterHIcon },
19+
{ axis: "v", title: t.properties.alignCenterV, Icon: CenterVIcon },
20+
{ axis: "both", title: t.properties.alignCenterBoth, Icon: CenterBothIcon },
21+
];
22+
23+
return (
24+
<div className="flex gap-1">
25+
{buttons.map(({ axis, title, Icon }) => (
26+
<button
27+
key={axis}
28+
type="button"
29+
className={BUTTON_CLS}
30+
title={title}
31+
aria-label={title}
32+
onClick={() => onAlign(axis)}
33+
>
34+
<Icon className="w-3.5 h-3.5" />
35+
</button>
36+
))}
37+
</div>
38+
);
39+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
interface IconProps {
2+
className?: string;
3+
}
4+
5+
/** Object centred on a vertical dashed line — represents horizontal centring. */
6+
export function CenterHIcon({ className }: IconProps) {
7+
return (
8+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" aria-hidden="true" className={className}>
9+
<line x1="8" y1="1.5" x2="8" y2="14.5" strokeDasharray="2 2" strokeWidth="1" />
10+
<rect x="3" y="5" width="10" height="6" strokeWidth="1" />
11+
</svg>
12+
);
13+
}
14+
15+
/** Object centred on a horizontal dashed line — represents vertical centring. */
16+
export function CenterVIcon({ className }: IconProps) {
17+
return (
18+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" aria-hidden="true" className={className}>
19+
<line x1="1.5" y1="8" x2="14.5" y2="8" strokeDasharray="2 2" strokeWidth="1" />
20+
<rect x="5" y="3" width="6" height="10" strokeWidth="1" />
21+
</svg>
22+
);
23+
}
24+
25+
/** Object on the cross-hair of two dashed lines — represents bi-axial centring. */
26+
export function CenterBothIcon({ className }: IconProps) {
27+
return (
28+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" aria-hidden="true" className={className}>
29+
<line x1="8" y1="1.5" x2="8" y2="14.5" strokeDasharray="2 2" strokeWidth="1" />
30+
<line x1="1.5" y1="8" x2="14.5" y2="8" strokeDasharray="2 2" strokeWidth="1" />
31+
<rect x="5" y="5" width="6" height="6" strokeWidth="1" />
32+
</svg>
33+
);
34+
}

‎src/components/Properties/PropertiesPanel.tsx‎

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import type { RefObject } from "react";
12
import { InformationCircleIcon } from "@heroicons/react/16/solid";
23
import { useLabelStore, useCurrentObjects } from "../../store/labelStore";
4+
import type { LabelCanvasHandle } from "../Canvas/LabelCanvas";
5+
import type { AlignAxis } from "../../lib/alignment";
36
import { ObjectRegistry } from "../../registry";
47
import { BWIP_VISUAL_APPROX_TYPES } from "../Canvas/bwipConstants";
58
import { stripZplCommandChars } from "../../registry/zplHelpers";
@@ -15,10 +18,19 @@ import type { Unit } from "../../lib/units";
1518
import { useT } from "../../lib/useT";
1619
import { parseIntOrUndef } from "../../lib/inputParse";
1720
import { CollapsibleSection } from "../ui/CollapsibleSection";
21+
import { AlignButtons } from "./AlignButtons";
1822
import { inputCls, labelCls } from "./styles";
1923
import type { LabelConfig } from "../../types/ObjectType";
2024

21-
export function PropertiesPanel() {
25+
interface PropertiesPanelProps {
26+
/** Imperative handle on the canvas — used for actions that need live render
27+
* bboxes (alignment, future zoom-to-selection, etc.). Required so the
28+
* type system forces the caller to wire it up; the inner null-check on
29+
* `.current` only covers the brief window before LabelCanvas mounts. */
30+
canvasRef: RefObject<LabelCanvasHandle | null>;
31+
}
32+
33+
export function PropertiesPanel({ canvasRef }: PropertiesPanelProps) {
2234
const t = useT();
2335
const {
2436
selectedIds,
@@ -31,6 +43,8 @@ export function PropertiesPanel() {
3143
const objects = useCurrentObjects();
3244
const unit = canvasSettings.unit;
3345
const obj = objects.find((o) => o.id === selectedIds[0]);
46+
const handleAlign = (axis: AlignAxis) =>
47+
canvasRef.current?.alignSelectionToLabel(axis);
3448

3549
if (selectedIds.length > 1) {
3650
return (
@@ -41,9 +55,12 @@ export function PropertiesPanel() {
4155
{t.properties.multipleSelectedFmt.replace('{n}', String(selectedIds.length))}
4256
</span>
4357
</div>
44-
<p className="px-3 py-3 text-xs text-muted">
45-
{t.properties.x} / {t.properties.y}: {t.properties.multipleSelectedHint}
46-
</p>
58+
<div className="px-3 py-3 flex flex-col gap-3">
59+
<p className="text-xs text-muted">
60+
{t.properties.x} / {t.properties.y}: {t.properties.multipleSelectedHint}
61+
</p>
62+
<AlignButtons onAlign={handleAlign} />
63+
</div>
4764
</div>
4865
);
4966
}
@@ -127,6 +144,7 @@ export function PropertiesPanel() {
127144
/>
128145
</div>
129146
</div>
147+
<AlignButtons onAlign={handleAlign} />
130148
</div>
131149

132150
<div className="border-t border-border" />

‎src/lib/alignment.test.ts‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { describe, it, expect } from "vitest";
2+
import { computeGroupCenterDelta } from "./alignment";
3+
4+
const target = { x: 0, y: 0, width: 100, height: 60 };
5+
6+
describe("computeGroupCenterDelta", () => {
7+
it("returns zero delta for empty input", () => {
8+
expect(computeGroupCenterDelta([], target, "both")).toEqual({ dx: 0, dy: 0 });
9+
});
10+
11+
it("centres a single box on both axes", () => {
12+
const box = { id: "a", x: 0, y: 0, width: 20, height: 10 };
13+
expect(computeGroupCenterDelta([box], target, "both")).toEqual({
14+
dx: 40,
15+
dy: 25,
16+
});
17+
});
18+
19+
it("only shifts the requested axis", () => {
20+
const box = { id: "a", x: 0, y: 0, width: 20, height: 10 };
21+
expect(computeGroupCenterDelta([box], target, "h")).toEqual({ dx: 40, dy: 0 });
22+
expect(computeGroupCenterDelta([box], target, "v")).toEqual({ dx: 0, dy: 25 });
23+
});
24+
25+
it("centres the group bbox of a multi-select, preserving relative positions", () => {
26+
// Two boxes at x=10..30 and x=50..60 → group bbox 10..60 (width 50)
27+
// Centre target (width 100) → group should land at 25..75 → dx = +15
28+
const boxes = [
29+
{ id: "a", x: 10, y: 5, width: 20, height: 10 },
30+
{ id: "b", x: 50, y: 20, width: 10, height: 10 },
31+
];
32+
const { dx } = computeGroupCenterDelta(boxes, target, "h");
33+
expect(dx).toBe(15);
34+
});
35+
36+
it("handles bboxes that already exceed the target (no clamp)", () => {
37+
const box = { id: "a", x: -10, y: 0, width: 200, height: 10 };
38+
// Centre 200-wide box on 100-wide target → leftEdge target = -50, dx = -40
39+
expect(computeGroupCenterDelta([box], target, "h").dx).toBe(-40);
40+
});
41+
42+
it("respects target offset (target not anchored at origin)", () => {
43+
const offsetTarget = { x: 100, y: 100, width: 100, height: 60 };
44+
const box = { id: "a", x: 100, y: 100, width: 20, height: 10 };
45+
expect(computeGroupCenterDelta([box], offsetTarget, "both")).toEqual({
46+
dx: 40,
47+
dy: 25,
48+
});
49+
});
50+
});

0 commit comments

Comments
 (0)