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
9 changes: 7 additions & 2 deletions src/components/AppShell.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { DndContext, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
import { ObjectPalette } from "./Palette/ObjectPalette";
import { LabelCanvas } from "./Canvas/LabelCanvas";
import type { LabelCanvasHandle } from "./Canvas/LabelCanvas";
import { PropertiesPanel } from "./Properties/PropertiesPanel";
import { LayersPanel } from "./Properties/LayersPanel";
import { FontManager } from "./Fonts/FontManager";
Expand Down Expand Up @@ -91,6 +92,9 @@ export function AppShell() {
handlePrint,
} = useZplImportExport();
const outputPanel = useOutputPanel(OUTPUT_DEFAULT_H);
// Imperative handle to the canvas for actions PropertiesPanel needs live
// render bboxes for (e.g. align-to-label centring).
const canvasRef = useRef<LabelCanvasHandle>(null);

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

<main className="flex-1 overflow-hidden">
<LabelCanvas
ref={canvasRef}
unit={unit}
showGrid={showGrid}
onGridToggle={() => setCanvasSettings({ showGrid: !showGrid })}
Expand Down Expand Up @@ -319,7 +324,7 @@ export function AppShell() {
</button>
</div>
<div className="flex-1 overflow-y-auto">
{rightTab === "properties" && <PropertiesPanel />}
{rightTab === "properties" && <PropertiesPanel canvasRef={canvasRef} />}
{rightTab === "layers" && <LayersPanel />}
{rightTab === "fonts" && <FontManager />}
</div>
Expand Down
92 changes: 82 additions & 10 deletions src/components/Canvas/LabelCanvas.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import {
forwardRef,
useImperativeHandle,
useMemo,
useRef,
useEffect,
useState,
Expand All @@ -14,6 +17,8 @@ import { SNAP_OPTIONS } from "../../lib/units";
import type { Unit } from "../../lib/units";
import { computeSnap } from "../../lib/snapGuides";
import type { SnapGuide } from "../../lib/snapGuides";
import { computeGroupCenterDelta } from "../../lib/alignment";
import type { AlignAxis } from "../../lib/alignment";
import { KonvaObject } from "./KonvaObject";
import { Grid } from "./Grid";
import { GuideLines } from "./GuideLines";
Expand Down Expand Up @@ -50,7 +55,14 @@ interface Props {
onViewRotationChange: (rotation: ViewRotation) => void;
}

export function LabelCanvas({
/** Imperative actions sibling components (PropertiesPanel) need from the
* canvas. The canvas owns the live render bboxes via Konva — co-locating
* the action with the data avoids round-tripping through the store. */
export interface LabelCanvasHandle {
alignSelectionToLabel: (axis: AlignAxis) => void;
}

export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(function LabelCanvas({
unit,
showGrid,
onGridToggle,
Expand All @@ -62,7 +74,7 @@ export function LabelCanvas({
onZoomChange,
viewRotation,
onViewRotationChange,
}: Props) {
}, ref) {
const containerRef = useRef<HTMLDivElement>(null);
const stageRef = useRef<Konva.Stage>(null);
const transformerRef = useRef<Konva.Transformer>(null);
Expand Down Expand Up @@ -246,13 +258,73 @@ export function LabelCanvas({
// here matches the visual (rotation-aware) bounds — snap math operates in
// stage-screen space, so it must reflect what the user sees, not the
// un-rotated layout coordinates.
const transformerSnapLabelRect = {
id: "_lbl",
x: visualLabelX,
y: visualLabelY,
width: visualLabelWidthPx,
height: visualLabelHeightPx,
};
const transformerSnapLabelRect = useMemo(
() => ({
id: "_lbl",
x: visualLabelX,
y: visualLabelY,
width: visualLabelWidthPx,
height: visualLabelHeightPx,
}),
[visualLabelX, visualLabelY, visualLabelWidthPx, visualLabelHeightPx],
);

// Imperative align-to-label: PropertiesPanel calls this directly via the
// forwarded ref. Co-located with the render data — measure each selected
// node's rendered bbox via Konva clientRect (single source of truth for
// type-specific footprints like text baselines / barcode text-zones),
// compute the centre-delta in screen px, map back through view rotation
// and px-per-dot to model coordinates.
useImperativeHandle(
ref,
() => ({
alignSelectionToLabel: (axis) => {
const stage = stageRef.current;
if (!stage) return;
const state = useLabelStore.getState();
const ids = state.selectedIds;
if (ids.length === 0) return;
const objs = currentObjects(state);

const boxes = ids.flatMap((id) => {
const node = stage.findOne<Konva.Node>(`#${id}`);
if (!node) return [];
const r = node.getClientRect({ relativeTo: stage });
return [{ id, x: r.x, y: r.y, width: r.width, height: r.height }];
});
if (boxes.length === 0) return;

const { dx: screenDx, dy: screenDy } = computeGroupCenterDelta(
boxes,
transformerSnapLabelRect,
axis,
);
if (screenDx === 0 && screenDy === 0) return;

const [layoutDx, layoutDy] = inverseRotateDelta(
screenDx,
screenDy,
viewRotation,
);
// Round to integer dots — matches the `mmToDots` convention used by
// PropertiesPanel inputs and keeps the store's x/y invariant so ZPL
// emit doesn't see fractional coordinates.
const pxPerDot = scale / label.dpmm;
const dxDots = Math.round(layoutDx / pxPerDot);
const dyDots = Math.round(layoutDy / pxPerDot);

const updates = ids.flatMap((id) => {
const obj = objs.find((o) => o.id === id);
if (!obj) return [];
return [
{ id, changes: { x: obj.x + dxDots, y: obj.y + dyDots } },
];
});
Comment thread
u8array marked this conversation as resolved.
if (updates.length > 0) updateObjects(updates);
},
}),
[transformerSnapLabelRect, scale, label.dpmm, viewRotation, updateObjects],
);

const {
rotateEnabled,
Expand Down Expand Up @@ -673,4 +745,4 @@ export function LabelCanvas({
)}
</div>
);
}
});
39 changes: 39 additions & 0 deletions src/components/Properties/AlignButtons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { useT } from "../../lib/useT";
import type { AlignAxis } from "../../lib/alignment";
import { CenterHIcon, CenterVIcon, CenterBothIcon } from "./AlignIcons";

const BUTTON_CLS =
"p-1.5 rounded border border-border text-muted hover:text-text hover:bg-surface-2 transition-colors";

/**
* Three-button row that centres the current selection on the label. Pure
* presentation: the caller supplies the imperative `onAlign` handler so the
* component stays decoupled from the canvas (which owns the live render
* bboxes).
*/
export function AlignButtons({ onAlign }: { onAlign: (axis: AlignAxis) => void }) {
const t = useT();

const buttons: { axis: AlignAxis; title: string; Icon: typeof CenterHIcon }[] = [
{ axis: "h", title: t.properties.alignCenterH, Icon: CenterHIcon },
{ axis: "v", title: t.properties.alignCenterV, Icon: CenterVIcon },
{ axis: "both", title: t.properties.alignCenterBoth, Icon: CenterBothIcon },
];

return (
<div className="flex gap-1">
{buttons.map(({ axis, title, Icon }) => (
<button
key={axis}
type="button"
className={BUTTON_CLS}
title={title}
aria-label={title}
onClick={() => onAlign(axis)}
>
<Icon className="w-3.5 h-3.5" />
</button>
))}
</div>
);
}
34 changes: 34 additions & 0 deletions src/components/Properties/AlignIcons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
interface IconProps {
className?: string;
}

/** Object centred on a vertical dashed line — represents horizontal centring. */
export function CenterHIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" aria-hidden="true" className={className}>
<line x1="8" y1="1.5" x2="8" y2="14.5" strokeDasharray="2 2" strokeWidth="1" />
<rect x="3" y="5" width="10" height="6" strokeWidth="1" />
</svg>
);
}

/** Object centred on a horizontal dashed line — represents vertical centring. */
export function CenterVIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" aria-hidden="true" className={className}>
<line x1="1.5" y1="8" x2="14.5" y2="8" strokeDasharray="2 2" strokeWidth="1" />
<rect x="5" y="3" width="6" height="10" strokeWidth="1" />
</svg>
);
}

/** Object on the cross-hair of two dashed lines — represents bi-axial centring. */
export function CenterBothIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" aria-hidden="true" className={className}>
<line x1="8" y1="1.5" x2="8" y2="14.5" strokeDasharray="2 2" strokeWidth="1" />
<line x1="1.5" y1="8" x2="14.5" y2="8" strokeDasharray="2 2" strokeWidth="1" />
<rect x="5" y="5" width="6" height="6" strokeWidth="1" />
</svg>
);
}
26 changes: 22 additions & 4 deletions src/components/Properties/PropertiesPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type { RefObject } from "react";
import { InformationCircleIcon } from "@heroicons/react/16/solid";
import { useLabelStore, useCurrentObjects } from "../../store/labelStore";
import type { LabelCanvasHandle } from "../Canvas/LabelCanvas";
import type { AlignAxis } from "../../lib/alignment";
import { ObjectRegistry } from "../../registry";
import { BWIP_VISUAL_APPROX_TYPES } from "../Canvas/bwipConstants";
import { stripZplCommandChars } from "../../registry/zplHelpers";
Expand All @@ -15,10 +18,19 @@ import type { Unit } from "../../lib/units";
import { useT } from "../../lib/useT";
import { parseIntOrUndef } from "../../lib/inputParse";
import { CollapsibleSection } from "../ui/CollapsibleSection";
import { AlignButtons } from "./AlignButtons";
import { inputCls, labelCls } from "./styles";
import type { LabelConfig } from "../../types/ObjectType";

export function PropertiesPanel() {
interface PropertiesPanelProps {
/** Imperative handle on the canvas — used for actions that need live render
* bboxes (alignment, future zoom-to-selection, etc.). Required so the
* type system forces the caller to wire it up; the inner null-check on
* `.current` only covers the brief window before LabelCanvas mounts. */
canvasRef: RefObject<LabelCanvasHandle | null>;
}

export function PropertiesPanel({ canvasRef }: PropertiesPanelProps) {
const t = useT();
const {
selectedIds,
Expand All @@ -31,6 +43,8 @@ export function PropertiesPanel() {
const objects = useCurrentObjects();
const unit = canvasSettings.unit;
const obj = objects.find((o) => o.id === selectedIds[0]);
const handleAlign = (axis: AlignAxis) =>
canvasRef.current?.alignSelectionToLabel(axis);

if (selectedIds.length > 1) {
return (
Expand All @@ -41,9 +55,12 @@ export function PropertiesPanel() {
{t.properties.multipleSelectedFmt.replace('{n}', String(selectedIds.length))}
</span>
</div>
<p className="px-3 py-3 text-xs text-muted">
{t.properties.x} / {t.properties.y}: {t.properties.multipleSelectedHint}
</p>
<div className="px-3 py-3 flex flex-col gap-3">
<p className="text-xs text-muted">
{t.properties.x} / {t.properties.y}: {t.properties.multipleSelectedHint}
</p>
<AlignButtons onAlign={handleAlign} />
</div>
</div>
);
}
Expand Down Expand Up @@ -127,6 +144,7 @@ export function PropertiesPanel() {
/>
</div>
</div>
<AlignButtons onAlign={handleAlign} />
</div>

<div className="border-t border-border" />
Expand Down
50 changes: 50 additions & 0 deletions src/lib/alignment.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, it, expect } from "vitest";
import { computeGroupCenterDelta } from "./alignment";

const target = { x: 0, y: 0, width: 100, height: 60 };

describe("computeGroupCenterDelta", () => {
it("returns zero delta for empty input", () => {
expect(computeGroupCenterDelta([], target, "both")).toEqual({ dx: 0, dy: 0 });
});

it("centres a single box on both axes", () => {
const box = { id: "a", x: 0, y: 0, width: 20, height: 10 };
expect(computeGroupCenterDelta([box], target, "both")).toEqual({
dx: 40,
dy: 25,
});
});

it("only shifts the requested axis", () => {
const box = { id: "a", x: 0, y: 0, width: 20, height: 10 };
expect(computeGroupCenterDelta([box], target, "h")).toEqual({ dx: 40, dy: 0 });
expect(computeGroupCenterDelta([box], target, "v")).toEqual({ dx: 0, dy: 25 });
});

it("centres the group bbox of a multi-select, preserving relative positions", () => {
// Two boxes at x=10..30 and x=50..60 → group bbox 10..60 (width 50)
// Centre target (width 100) → group should land at 25..75 → dx = +15
const boxes = [
{ id: "a", x: 10, y: 5, width: 20, height: 10 },
{ id: "b", x: 50, y: 20, width: 10, height: 10 },
];
const { dx } = computeGroupCenterDelta(boxes, target, "h");
expect(dx).toBe(15);
});

it("handles bboxes that already exceed the target (no clamp)", () => {
const box = { id: "a", x: -10, y: 0, width: 200, height: 10 };
// Centre 200-wide box on 100-wide target → leftEdge target = -50, dx = -40
expect(computeGroupCenterDelta([box], target, "h").dx).toBe(-40);
});

it("respects target offset (target not anchored at origin)", () => {
const offsetTarget = { x: 100, y: 100, width: 100, height: 60 };
const box = { id: "a", x: 100, y: 100, width: 20, height: 10 };
expect(computeGroupCenterDelta([box], offsetTarget, "both")).toEqual({
dx: 40,
dy: 25,
});
});
});
Loading