From ef6c828ceb93324478d7dde71397568ba0012e99 Mon Sep 17 00:00:00 2001 From: u8array Date: Sat, 9 May 2026 23:09:08 +0200 Subject: [PATCH 1/6] feat(properties): align selection to label centre Three icon buttons (centre-H, centre-V, centre-both) in the Properties panel emit an alignment intent through the store; the canvas effect measures the rendered group bbox via Konva clientRect and dispatches the corresponding x/y delta. Stage measurement is the source of truth so text/barcode/etc. footprints match what the user sees. Multi-select centres the union bbox of all selected objects, preserving their relative positions (matches Figma's group-align-to-canvas). --- src/components/Canvas/LabelCanvas.tsx | 52 ++++++++++++++ src/components/Properties/AlignButtons.tsx | 71 +++++++++++++++++++ src/components/Properties/PropertiesPanel.tsx | 11 ++- src/lib/alignment.test.ts | 50 +++++++++++++ src/lib/alignment.ts | 59 +++++++++++++++ src/locales/ar.ts | 3 + src/locales/bg.ts | 3 + src/locales/cs.ts | 3 + src/locales/da.ts | 3 + src/locales/de.ts | 3 + src/locales/el.ts | 3 + src/locales/en.ts | 3 + src/locales/es.ts | 3 + src/locales/et.ts | 3 + src/locales/fa.ts | 3 + src/locales/fi.ts | 3 + src/locales/fr.ts | 3 + src/locales/he.ts | 3 + src/locales/hr.ts | 3 + src/locales/hu.ts | 3 + src/locales/it.ts | 3 + src/locales/ja.ts | 3 + src/locales/ko.ts | 3 + src/locales/lt.ts | 3 + src/locales/lv.ts | 3 + src/locales/nl.ts | 3 + src/locales/no.ts | 3 + src/locales/pl.ts | 3 + src/locales/pt.ts | 3 + src/locales/ro.ts | 3 + src/locales/sk.ts | 3 + src/locales/sl.ts | 3 + src/locales/sr.ts | 3 + src/locales/sv.ts | 3 + src/locales/tr.ts | 3 + src/locales/zh-hans.ts | 3 + src/locales/zh-hant.ts | 3 + src/store/labelStore.ts | 22 ++++++ 38 files changed, 358 insertions(+), 3 deletions(-) create mode 100644 src/components/Properties/AlignButtons.tsx create mode 100644 src/lib/alignment.test.ts create mode 100644 src/lib/alignment.ts diff --git a/src/components/Canvas/LabelCanvas.tsx b/src/components/Canvas/LabelCanvas.tsx index 59981fec..b5770a82 100644 --- a/src/components/Canvas/LabelCanvas.tsx +++ b/src/components/Canvas/LabelCanvas.tsx @@ -14,6 +14,7 @@ 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 { KonvaObject } from "./KonvaObject"; import { Grid } from "./Grid"; import { GuideLines } from "./GuideLines"; @@ -97,6 +98,7 @@ export function LabelCanvas({ selectObjects, } = useLabelStore(); const objects = useCurrentObjects(); + const alignmentRequest = useLabelStore((s) => s.alignmentRequest); useEffect(() => { const el = containerRef.current; @@ -254,6 +256,56 @@ export function LabelCanvas({ height: visualLabelHeightPx, }; + // Align-to-label intent: PropertiesPanel bumps `alignmentRequest.serial`, + // we measure the rendered group bbox via stage-clientRect, compute the + // centre-delta in screen px, then map to model dots. Stage measurement is + // the single source of truth — text/barcode/etc. have type-specific + // footprints (text-zone, baseline shift) that the model alone can't + // represent. + useEffect(() => { + if (!alignmentRequest) return; + 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(`#${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, + alignmentRequest.axis, + ); + if (screenDx === 0 && screenDy === 0) return; + + // Stage-screen → model dots: inverse-rotate into the un-rotated layout + // frame, then divide by px-per-dot. Mirrors handleStageDragMove's path. + const [layoutDx, layoutDy] = inverseRotateDelta(screenDx, screenDy, viewRotation); + const pxPerDot = scale / label.dpmm; + const dxDots = layoutDx / pxPerDot; + const dyDots = 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 } }]; + }); + if (updates.length > 0) updateObjects(updates); + // No need to clear alignmentRequest — the next click bumps `serial`. + // Effect intentionally only depends on alignmentRequest: we want to fire + // when the user clicks a button, not every time the canvas pans/zooms. + // The other values are read fresh from the closure on each fire. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [alignmentRequest]); + const { rotateEnabled, resizeEnabled, diff --git a/src/components/Properties/AlignButtons.tsx b/src/components/Properties/AlignButtons.tsx new file mode 100644 index 00000000..fcafee60 --- /dev/null +++ b/src/components/Properties/AlignButtons.tsx @@ -0,0 +1,71 @@ +import { useLabelStore } from "../../store/labelStore"; +import { useT } from "../../lib/useT"; +import type { AlignAxis } from "../../lib/alignment"; + +interface IconProps { + className?: string; +} + +function CenterHIcon({ className }: IconProps) { + return ( + + + + + ); +} + +function CenterVIcon({ className }: IconProps) { + return ( + + + + + ); +} + +function CenterBothIcon({ className }: IconProps) { + return ( + + + + + + ); +} + +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 requests an align-to-label centring of the current + * selection. The canvas owns the live render bboxes (Konva), so we just emit + * the intent — see LabelCanvas for the geometry. + */ +export function AlignButtons() { + const t = useT(); + const requestAlignment = useLabelStore((s) => s.requestAlignment); + + 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 ( +
+ {buttons.map(({ axis, title, Icon }) => ( + + ))} +
+ ); +} diff --git a/src/components/Properties/PropertiesPanel.tsx b/src/components/Properties/PropertiesPanel.tsx index 2698b3fb..f3806fca 100644 --- a/src/components/Properties/PropertiesPanel.tsx +++ b/src/components/Properties/PropertiesPanel.tsx @@ -15,6 +15,7 @@ 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"; @@ -41,9 +42,12 @@ export function PropertiesPanel() { {t.properties.multipleSelectedFmt.replace('{n}', String(selectedIds.length))} -

- {t.properties.x} / {t.properties.y}: {t.properties.multipleSelectedHint} -

+
+

+ {t.properties.x} / {t.properties.y}: {t.properties.multipleSelectedHint} +

+ +
); } @@ -127,6 +131,7 @@ export function PropertiesPanel() { /> +
diff --git a/src/lib/alignment.test.ts b/src/lib/alignment.test.ts new file mode 100644 index 00000000..3981d437 --- /dev/null +++ b/src/lib/alignment.test.ts @@ -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, + }); + }); +}); diff --git a/src/lib/alignment.ts b/src/lib/alignment.ts new file mode 100644 index 00000000..30ff1a28 --- /dev/null +++ b/src/lib/alignment.ts @@ -0,0 +1,59 @@ +/** + * Group-bbox alignment math, decoupled from Konva and the store. Given a + * collection of object footprints (rendered bboxes, expressed in any unit- + * consistent space) plus a target label rect, returns the position deltas + * required to centre the group bbox within the label. + * + * Multi-select uses the union of all bboxes and shifts every object by the + * same delta — matches Figma's "align to canvas" behaviour for grouped + * selections, where children keep their relative positions. + */ + +export type AlignAxis = "h" | "v" | "both"; + +/** Object footprint in the caller's coordinate space (screen pixels or dots). */ +export interface AlignBox { + id: string; + x: number; + y: number; + width: number; + height: number; +} + +export interface AlignTarget { + x: number; + y: number; + width: number; + height: number; +} + +/** + * Compute (dx, dy) — the same shift applied to every member of the group so + * that its combined bbox sits centred inside `target` along the requested + * axis. Returns zero on the inactive axis. Empty input → {0,0}. + */ +export function computeGroupCenterDelta( + boxes: readonly AlignBox[], + target: AlignTarget, + axis: AlignAxis, +): { dx: number; dy: number } { + if (boxes.length === 0) return { dx: 0, dy: 0 }; + + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const b of boxes) { + if (b.x < minX) minX = b.x; + if (b.y < minY) minY = b.y; + if (b.x + b.width > maxX) maxX = b.x + b.width; + if (b.y + b.height > maxY) maxY = b.y + b.height; + } + const groupW = maxX - minX; + const groupH = maxY - minY; + + const targetX = target.x + (target.width - groupW) / 2; + const targetY = target.y + (target.height - groupH) / 2; + + return { + dx: axis === "h" || axis === "both" ? targetX - minX : 0, + dy: axis === "v" || axis === "both" ? targetY - minY : 0, + }; +} diff --git a/src/locales/ar.ts b/src/locales/ar.ts index c36a433c..5db7d777 100644 --- a/src/locales/ar.ts +++ b/src/locales/ar.ts @@ -52,6 +52,9 @@ const ar = { multipleSelectedFmt: '{n} عناصر مختارة', multipleSelectedHint: 'استخدم أسهم الاتجاه للتحريك', visualApproxHint: 'العرض المرئي تقريبي؛ الأبعاد تطابق طباعة ZPL', + alignCenterH: 'توسيط أفقياً على الملصق', + alignCenterV: 'توسيط رأسياً على الملصق', + alignCenterBoth: 'توسيط على الملصق', }, label: { diff --git a/src/locales/bg.ts b/src/locales/bg.ts index 4fde2ab4..10024910 100644 --- a/src/locales/bg.ts +++ b/src/locales/bg.ts @@ -52,6 +52,9 @@ const bg = { multipleSelectedFmt: 'Избрани обекти: {n}', multipleSelectedHint: 'със стрелките местиш', visualApproxHint: 'Визуалното изобразяване е приблизително; размерите съответстват на ZPL отпечатъка', + alignCenterH: 'Хоризонтално центриране върху етикета', + alignCenterV: 'Вертикално центриране върху етикета', + alignCenterBoth: 'Центриране върху етикета', }, label: { diff --git a/src/locales/cs.ts b/src/locales/cs.ts index 76e56115..52645dc6 100644 --- a/src/locales/cs.ts +++ b/src/locales/cs.ts @@ -52,6 +52,9 @@ const cs = { multipleSelectedFmt: 'Vybráno objektů: {n}', multipleSelectedHint: 'šipkami posunete', visualApproxHint: 'Vizuální zobrazení je přibližné; rozměry odpovídají tisku ZPL', + alignCenterH: 'Vystředit vodorovně na štítku', + alignCenterV: 'Vystředit svisle na štítku', + alignCenterBoth: 'Vystředit na štítku', }, label: { diff --git a/src/locales/da.ts b/src/locales/da.ts index 0de45051..4d2909af 100644 --- a/src/locales/da.ts +++ b/src/locales/da.ts @@ -52,6 +52,9 @@ const da = { multipleSelectedFmt: '{n} objekter valgt', multipleSelectedHint: 'piletaster flytter', visualApproxHint: 'Visuel gengivelse er omtrentlig; dimensionerne svarer til ZPL-udskriften', + alignCenterH: 'Centrer vandret på label', + alignCenterV: 'Centrer lodret på label', + alignCenterBoth: 'Centrer på label', }, label: { diff --git a/src/locales/de.ts b/src/locales/de.ts index b004eed1..420df759 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -52,6 +52,9 @@ const de = { multipleSelectedFmt: '{n} Objekte ausgewählt', multipleSelectedHint: 'Pfeiltasten zum Verschieben', visualApproxHint: 'Visuelle Darstellung näherungsweise; Maße entsprechen dem ZPL-Druck', + alignCenterH: 'Horizontal auf Label zentrieren', + alignCenterV: 'Vertikal auf Label zentrieren', + alignCenterBoth: 'Auf Label zentrieren', }, label: { diff --git a/src/locales/el.ts b/src/locales/el.ts index 52d57817..827878c2 100644 --- a/src/locales/el.ts +++ b/src/locales/el.ts @@ -52,6 +52,9 @@ const el = { multipleSelectedFmt: '{n} αντικείμενα επιλέχθηκαν', multipleSelectedHint: 'τα βέλη μετακινούν', visualApproxHint: 'Η οπτική απόδοση είναι κατά προσέγγιση· οι διαστάσεις αντιστοιχούν στην εκτύπωση ZPL', + alignCenterH: 'Οριζόντιο κεντράρισμα στην ετικέτα', + alignCenterV: 'Κατακόρυφο κεντράρισμα στην ετικέτα', + alignCenterBoth: 'Κεντράρισμα στην ετικέτα', }, label: { diff --git a/src/locales/en.ts b/src/locales/en.ts index 73aeb8c6..daa38f8d 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -52,6 +52,9 @@ const en = { multipleSelectedFmt: '{n} objects selected', multipleSelectedHint: 'use arrow keys to move', visualApproxHint: 'Visual rendering approximate; dimensions match the ZPL print', + alignCenterH: 'Center horizontally on label', + alignCenterV: 'Center vertically on label', + alignCenterBoth: 'Center on label', }, label: { diff --git a/src/locales/es.ts b/src/locales/es.ts index 8123d922..317a8654 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -52,6 +52,9 @@ const es = { multipleSelectedFmt: '{n} objetos seleccionados', multipleSelectedHint: 'flechas para mover', visualApproxHint: 'Renderizado visual aproximado; las dimensiones coinciden con la impresión ZPL', + alignCenterH: 'Centrar horizontalmente en la etiqueta', + alignCenterV: 'Centrar verticalmente en la etiqueta', + alignCenterBoth: 'Centrar en la etiqueta', }, label: { diff --git a/src/locales/et.ts b/src/locales/et.ts index 7ba44eb5..3fe3f0fe 100644 --- a/src/locales/et.ts +++ b/src/locales/et.ts @@ -52,6 +52,9 @@ const et = { multipleSelectedFmt: '{n} objekti valitud', multipleSelectedHint: 'nooltega liigutad', visualApproxHint: 'Visuaalne kuva on ligikaudne; mõõtmed vastavad ZPL-väljatrükile', + alignCenterH: 'Keskjoonda sildil rõhtsalt', + alignCenterV: 'Keskjoonda sildil püstiselt', + alignCenterBoth: 'Keskjoonda sildil', }, label: { diff --git a/src/locales/fa.ts b/src/locales/fa.ts index e6eeb293..dc99e558 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -52,6 +52,9 @@ const fa = { multipleSelectedFmt: '{n} مورد انتخاب شده', multipleSelectedHint: 'با کلیدهای جهت‌دار جابه‌جا کنید', visualApproxHint: 'نمایش بصری تقریبی است؛ ابعاد با چاپ ZPL مطابقت دارد', + alignCenterH: 'وسط‌چین افقی روی برچسب', + alignCenterV: 'وسط‌چین عمودی روی برچسب', + alignCenterBoth: 'وسط‌چین برچسب', }, label: { diff --git a/src/locales/fi.ts b/src/locales/fi.ts index e44c69db..9c3c7534 100644 --- a/src/locales/fi.ts +++ b/src/locales/fi.ts @@ -52,6 +52,9 @@ const fi = { multipleSelectedFmt: '{n} objektia valittu', multipleSelectedHint: 'nuolinäppäimillä siirrät', visualApproxHint: 'Visuaalinen esitys on likimääräinen; mitat vastaavat ZPL-tulostetta', + alignCenterH: 'Keskitä vaakasuunnassa etikettiin', + alignCenterV: 'Keskitä pystysuunnassa etikettiin', + alignCenterBoth: 'Keskitä etikettiin', }, label: { diff --git a/src/locales/fr.ts b/src/locales/fr.ts index af83b6ec..edb460ba 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -52,6 +52,9 @@ const fr = { multipleSelectedFmt: '{n} objets sélectionnés', multipleSelectedHint: 'flèches pour déplacer', visualApproxHint: 'Rendu visuel approximatif ; les dimensions correspondent à l\'impression ZPL', + alignCenterH: 'Centrer horizontalement sur l\'étiquette', + alignCenterV: 'Centrer verticalement sur l\'étiquette', + alignCenterBoth: 'Centrer sur l\'étiquette', }, label: { diff --git a/src/locales/he.ts b/src/locales/he.ts index 7986c14b..1e093cc7 100644 --- a/src/locales/he.ts +++ b/src/locales/he.ts @@ -52,6 +52,9 @@ const he = { multipleSelectedFmt: '{n} פריטים נבחרו', multipleSelectedHint: 'מקשי החצים מזיזים', visualApproxHint: 'התצוגה החזותית מקורבת; הממדים תואמים את הדפסת ה-ZPL', + alignCenterH: 'מרכז אופקית במדבקה', + alignCenterV: 'מרכז אנכית במדבקה', + alignCenterBoth: 'מרכז במדבקה', }, label: { diff --git a/src/locales/hr.ts b/src/locales/hr.ts index 46d5e533..45200e92 100644 --- a/src/locales/hr.ts +++ b/src/locales/hr.ts @@ -52,6 +52,9 @@ const hr = { multipleSelectedFmt: 'Odabrano objekata: {n}', multipleSelectedHint: 'strelicama pomičeš', visualApproxHint: 'Vizualni prikaz je približan; dimenzije odgovaraju ZPL ispisu', + alignCenterH: 'Centriraj vodoravno na naljepnici', + alignCenterV: 'Centriraj okomito na naljepnici', + alignCenterBoth: 'Centriraj na naljepnici', }, label: { diff --git a/src/locales/hu.ts b/src/locales/hu.ts index cdd2b2b5..ccec1246 100644 --- a/src/locales/hu.ts +++ b/src/locales/hu.ts @@ -52,6 +52,9 @@ const hu = { multipleSelectedFmt: '{n} objektum kijelölve', multipleSelectedHint: 'nyilakkal mozgasd', visualApproxHint: 'A vizuális megjelenítés közelítő; a méretek megegyeznek a ZPL nyomtatással', + alignCenterH: 'Vízszintesen középre a címkén', + alignCenterV: 'Függőlegesen középre a címkén', + alignCenterBoth: 'Igazítás a címkén középre', }, label: { diff --git a/src/locales/it.ts b/src/locales/it.ts index 2e511354..7e09afb6 100644 --- a/src/locales/it.ts +++ b/src/locales/it.ts @@ -52,6 +52,9 @@ const it = { multipleSelectedFmt: '{n} oggetti selezionati', multipleSelectedHint: 'frecce per spostare', visualApproxHint: 'Rendering visivo approssimato; le dimensioni corrispondono alla stampa ZPL', + alignCenterH: 'Centra orizzontalmente sull\'etichetta', + alignCenterV: 'Centra verticalmente sull\'etichetta', + alignCenterBoth: 'Centra sull\'etichetta', }, label: { diff --git a/src/locales/ja.ts b/src/locales/ja.ts index 9edff78c..929419f7 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -52,6 +52,9 @@ const ja = { multipleSelectedFmt: '{n} 個のオブジェクトが選択されました', multipleSelectedHint: '矢印キーで移動', visualApproxHint: '視覚的表示は概略です。寸法は ZPL 印刷と一致します', + alignCenterH: 'ラベル上で水平方向に中央揃え', + alignCenterV: 'ラベル上で垂直方向に中央揃え', + alignCenterBoth: 'ラベル中央に配置', }, label: { diff --git a/src/locales/ko.ts b/src/locales/ko.ts index 4db12362..6a46bdf3 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -52,6 +52,9 @@ const ko = { multipleSelectedFmt: '{n}개 항목 선택됨', multipleSelectedHint: '화살표 키로 이동', visualApproxHint: '시각적 표시는 근사치이며, 치수는 ZPL 인쇄와 일치합니다', + alignCenterH: '라벨에서 가로 가운데 정렬', + alignCenterV: '라벨에서 세로 가운데 정렬', + alignCenterBoth: '라벨 가운데 정렬', }, label: { diff --git a/src/locales/lt.ts b/src/locales/lt.ts index 0e732121..33e07159 100644 --- a/src/locales/lt.ts +++ b/src/locales/lt.ts @@ -52,6 +52,9 @@ const lt = { multipleSelectedFmt: 'Pasirinkta objektų: {n}', multipleSelectedHint: 'rodyklėmis perkeli', visualApproxHint: 'Vaizdavimas apytikslis; matmenys atitinka ZPL spaudinį', + alignCenterH: 'Centruoti horizontaliai etiketėje', + alignCenterV: 'Centruoti vertikaliai etiketėje', + alignCenterBoth: 'Centruoti etiketėje', }, label: { diff --git a/src/locales/lv.ts b/src/locales/lv.ts index f3fe1f65..a9d65a35 100644 --- a/src/locales/lv.ts +++ b/src/locales/lv.ts @@ -52,6 +52,9 @@ const lv = { multipleSelectedFmt: 'Atlasīti {n} objekti', multipleSelectedHint: 'ar bultiņām pārvietot', visualApproxHint: 'Vizuālais attēlojums ir aptuvens; izmēri atbilst ZPL izdrukai', + alignCenterH: 'Centrēt horizontāli uz etiķetes', + alignCenterV: 'Centrēt vertikāli uz etiķetes', + alignCenterBoth: 'Centrēt uz etiķetes', }, label: { diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 5834c104..53db985c 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -52,6 +52,9 @@ const nl = { multipleSelectedFmt: '{n} objecten geselecteerd', multipleSelectedHint: 'pijltoetsen om te verplaatsen', visualApproxHint: 'Visuele weergave bij benadering; afmetingen komen overeen met de ZPL-afdruk', + alignCenterH: 'Horizontaal op label centreren', + alignCenterV: 'Verticaal op label centreren', + alignCenterBoth: 'Op label centreren', }, label: { diff --git a/src/locales/no.ts b/src/locales/no.ts index 2176f13c..8166f7ae 100644 --- a/src/locales/no.ts +++ b/src/locales/no.ts @@ -52,6 +52,9 @@ const no = { multipleSelectedFmt: '{n} objekter valgt', multipleSelectedHint: 'piltaster flytter', visualApproxHint: 'Visuell gjengivelse er omtrentlig; dimensjonene samsvarer med ZPL-utskriften', + alignCenterH: 'Sentrer horisontalt på etiketten', + alignCenterV: 'Sentrer vertikalt på etiketten', + alignCenterBoth: 'Sentrer på etiketten', }, label: { diff --git a/src/locales/pl.ts b/src/locales/pl.ts index e15e9d8d..d0c89ff5 100644 --- a/src/locales/pl.ts +++ b/src/locales/pl.ts @@ -52,6 +52,9 @@ const pl = { multipleSelectedFmt: 'Wybrano obiektów: {n}', multipleSelectedHint: 'strzałki przesuwają', visualApproxHint: 'Renderowanie wizualne jest przybliżone; wymiary odpowiadają wydrukowi ZPL', + alignCenterH: 'Wyśrodkuj poziomo na etykiecie', + alignCenterV: 'Wyśrodkuj pionowo na etykiecie', + alignCenterBoth: 'Wyśrodkuj na etykiecie', }, label: { diff --git a/src/locales/pt.ts b/src/locales/pt.ts index f5762cf8..849bb82b 100644 --- a/src/locales/pt.ts +++ b/src/locales/pt.ts @@ -52,6 +52,9 @@ const pt = { multipleSelectedFmt: '{n} objetos selecionados', multipleSelectedHint: 'setas para mover', visualApproxHint: 'Renderização visual aproximada; as dimensões correspondem à impressão ZPL', + alignCenterH: 'Centralizar horizontalmente no rótulo', + alignCenterV: 'Centralizar verticalmente no rótulo', + alignCenterBoth: 'Centralizar no rótulo', }, label: { diff --git a/src/locales/ro.ts b/src/locales/ro.ts index ffcb56b2..fd46accb 100644 --- a/src/locales/ro.ts +++ b/src/locales/ro.ts @@ -52,6 +52,9 @@ const ro = { multipleSelectedFmt: '{n} obiecte selectate', multipleSelectedHint: 'săgeți pentru mutare', visualApproxHint: 'Randarea vizuală este aproximativă; dimensiunile corespund tipăririi ZPL', + alignCenterH: 'Centrează orizontal pe etichetă', + alignCenterV: 'Centrează vertical pe etichetă', + alignCenterBoth: 'Centrează pe etichetă', }, label: { diff --git a/src/locales/sk.ts b/src/locales/sk.ts index 52f7e375..7114d5f4 100644 --- a/src/locales/sk.ts +++ b/src/locales/sk.ts @@ -52,6 +52,9 @@ const sk = { multipleSelectedFmt: 'Vybraných objektov: {n}', multipleSelectedHint: 'šípkami posuniete', visualApproxHint: 'Vizuálne zobrazenie je približné; rozmery zodpovedajú tlači ZPL', + alignCenterH: 'Vycentrovať vodorovne na štítku', + alignCenterV: 'Vycentrovať zvislo na štítku', + alignCenterBoth: 'Vycentrovať na štítku', }, label: { diff --git a/src/locales/sl.ts b/src/locales/sl.ts index 2b6e866f..e2080758 100644 --- a/src/locales/sl.ts +++ b/src/locales/sl.ts @@ -52,6 +52,9 @@ const sl = { multipleSelectedFmt: 'Izbranih objektov: {n}', multipleSelectedHint: 's puščicami premikaš', visualApproxHint: 'Vizualni prikaz je približen; mere se ujemajo s tiskom ZPL', + alignCenterH: 'Vodoravno sredinsko poravnaj na etiketi', + alignCenterV: 'Navpično sredinsko poravnaj na etiketi', + alignCenterBoth: 'Sredinsko poravnaj na etiketi', }, label: { diff --git a/src/locales/sr.ts b/src/locales/sr.ts index 78d51cc8..2b6f54b0 100644 --- a/src/locales/sr.ts +++ b/src/locales/sr.ts @@ -52,6 +52,9 @@ const sr = { multipleSelectedFmt: 'Изабрано објеката: {n}', multipleSelectedHint: 'стрелицама померај', visualApproxHint: 'Визуелни приказ је приближан; димензије одговарају ZPL отиску', + alignCenterH: 'Хоризонтално центрирање на етикети', + alignCenterV: 'Вертикално центрирање на етикети', + alignCenterBoth: 'Центрирање на етикети', }, label: { diff --git a/src/locales/sv.ts b/src/locales/sv.ts index 3fc5598d..9b6b2ca8 100644 --- a/src/locales/sv.ts +++ b/src/locales/sv.ts @@ -52,6 +52,9 @@ const sv = { multipleSelectedFmt: '{n} objekt markerade', multipleSelectedHint: 'pilar för att flytta', visualApproxHint: 'Visuell återgivning är ungefärlig; måtten matchar ZPL-utskriften', + alignCenterH: 'Centrera horisontellt på etikett', + alignCenterV: 'Centrera vertikalt på etikett', + alignCenterBoth: 'Centrera på etikett', }, label: { diff --git a/src/locales/tr.ts b/src/locales/tr.ts index 287dd9e8..f940b3e9 100644 --- a/src/locales/tr.ts +++ b/src/locales/tr.ts @@ -52,6 +52,9 @@ const tr = { multipleSelectedFmt: '{n} nesne seçildi', multipleSelectedHint: 'oklarla taşı', visualApproxHint: 'Görsel render yaklaşıktır; boyutlar ZPL çıktısıyla eşleşir', + alignCenterH: 'Etikette yatay ortala', + alignCenterV: 'Etikette dikey ortala', + alignCenterBoth: 'Etikete ortala', }, label: { diff --git a/src/locales/zh-hans.ts b/src/locales/zh-hans.ts index e5ffb499..06e5a3c6 100644 --- a/src/locales/zh-hans.ts +++ b/src/locales/zh-hans.ts @@ -52,6 +52,9 @@ const zhHans = { multipleSelectedFmt: '已选择 {n} 个对象', multipleSelectedHint: '方向键移动', visualApproxHint: '视觉渲染为近似值;尺寸与 ZPL 打印输出一致', + alignCenterH: '在标签上水平居中', + alignCenterV: '在标签上垂直居中', + alignCenterBoth: '在标签上居中', }, label: { diff --git a/src/locales/zh-hant.ts b/src/locales/zh-hant.ts index 72d6e39f..d0ff35f8 100644 --- a/src/locales/zh-hant.ts +++ b/src/locales/zh-hant.ts @@ -52,6 +52,9 @@ const zhHant = { multipleSelectedFmt: '已選擇 {n} 個物件', multipleSelectedHint: '方向鍵移動', visualApproxHint: '視覺呈現為近似值;尺寸與 ZPL 列印輸出一致', + alignCenterH: '於標籤水平置中', + alignCenterV: '於標籤垂直置中', + alignCenterBoth: '於標籤置中', }, label: { diff --git a/src/store/labelStore.ts b/src/store/labelStore.ts index 121a9e18..ac34bfcb 100644 --- a/src/store/labelStore.ts +++ b/src/store/labelStore.ts @@ -2,6 +2,7 @@ import { create, useStore } from 'zustand'; import { temporal } from 'zundo'; import { persist, createJSONStorage } from 'zustand/middleware'; import type { LabelConfig, ObjectChanges } from '../types/ObjectType'; +import type { AlignAxis } from '../lib/alignment'; import type { Unit } from '../lib/units'; import type { ViewRotation } from '../components/Canvas/rotationGeometry'; import { ObjectRegistry } from '../registry'; @@ -77,6 +78,13 @@ interface LabelState { clipboard: LabelObject[]; pasteCount: number; + /** Transient intent to centre the current selection on the label. The + * canvas owns the live render bboxes (via Konva), so PropertiesPanel + * bumps `serial` to fire the request and the canvas effect performs the + * geometry. Intentionally not persisted; the field exists only as the + * store-as-event-bus channel. */ + alignmentRequest: { axis: AlignAxis; serial: number } | null; + addObject: (type: string, position?: { x: number; y: number }) => void; updateObject: (id: string, changes: ObjectChanges) => void; updateObjects: (updates: { id: string; changes: ObjectChanges }[]) => void; @@ -89,6 +97,7 @@ interface LabelState { toggleSelectObject: (id: string) => void; selectObjects: (ids: string[]) => void; removeSelectedObjects: () => void; + requestAlignment: (axis: AlignAxis) => void; setLabelConfig: (config: Partial) => void; setLocale: (locale: LocaleCode) => void; setTheme: (theme: ThemePreference) => void; @@ -189,6 +198,7 @@ export const useLabelStore = create()( selectedIds: [], clipboard: [], pasteCount: 0, + alignmentRequest: null, locale: detectLocale(), theme: detectInitialTheme(), thirdParty: thirdPartyDefaults(), @@ -318,6 +328,18 @@ export const useLabelStore = create()( selectedIds: [], })), + requestAlignment: (axis) => + set((state) => ({ + alignmentRequest: { + axis, + // Bumping the serial each call lets the canvas effect re-fire + // even when the same axis is requested twice in a row (effects + // skip identical state). The value itself is meaningless beyond + // change detection. + serial: (state.alignmentRequest?.serial ?? 0) + 1, + }, + })), + moveObjectToFront: (id) => set((state) => { const objs = currentObjects(state); From 3ff466ba61aa5ae2fdeba526999430966f674565 Mon Sep 17 00:00:00 2001 From: u8array Date: Sat, 9 May 2026 23:16:38 +0200 Subject: [PATCH 2/6] fix(properties): mark align-button icons aria-hidden Decorative SVGs inside aria-labeled buttons should not be announced separately; matches the DragHandleIcon precedent. --- src/components/Properties/AlignButtons.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/Properties/AlignButtons.tsx b/src/components/Properties/AlignButtons.tsx index fcafee60..937aabc1 100644 --- a/src/components/Properties/AlignButtons.tsx +++ b/src/components/Properties/AlignButtons.tsx @@ -8,7 +8,7 @@ interface IconProps { function CenterHIcon({ className }: IconProps) { return ( - + @@ -17,7 +17,7 @@ function CenterHIcon({ className }: IconProps) { function CenterVIcon({ className }: IconProps) { return ( - + @@ -26,7 +26,7 @@ function CenterVIcon({ className }: IconProps) { function CenterBothIcon({ className }: IconProps) { return ( - +
@@ -271,6 +275,7 @@ export function AppShell() {
setCanvasSettings({ showGrid: !showGrid })} @@ -319,7 +324,7 @@ export function AppShell() {
- {rightTab === "properties" && } + {rightTab === "properties" && } {rightTab === "layers" && } {rightTab === "fonts" && }
diff --git a/src/components/Canvas/LabelCanvas.tsx b/src/components/Canvas/LabelCanvas.tsx index b5770a82..324042c3 100644 --- a/src/components/Canvas/LabelCanvas.tsx +++ b/src/components/Canvas/LabelCanvas.tsx @@ -1,4 +1,7 @@ import { + forwardRef, + useImperativeHandle, + useMemo, useRef, useEffect, useState, @@ -15,6 +18,7 @@ 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"; @@ -51,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(function LabelCanvas({ unit, showGrid, onGridToggle, @@ -63,7 +74,7 @@ export function LabelCanvas({ onZoomChange, viewRotation, onViewRotationChange, -}: Props) { +}, ref) { const containerRef = useRef(null); const stageRef = useRef(null); const transformerRef = useRef(null); @@ -98,7 +109,6 @@ export function LabelCanvas({ selectObjects, } = useLabelStore(); const objects = useCurrentObjects(); - const alignmentRequest = useLabelStore((s) => s.alignmentRequest); useEffect(() => { const el = containerRef.current; @@ -248,63 +258,70 @@ 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, - }; - - // Align-to-label intent: PropertiesPanel bumps `alignmentRequest.serial`, - // we measure the rendered group bbox via stage-clientRect, compute the - // centre-delta in screen px, then map to model dots. Stage measurement is - // the single source of truth — text/barcode/etc. have type-specific - // footprints (text-zone, baseline shift) that the model alone can't - // represent. - useEffect(() => { - if (!alignmentRequest) return; - 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(`#${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 transformerSnapLabelRect = useMemo( + () => ({ + id: "_lbl", + x: visualLabelX, + y: visualLabelY, + width: visualLabelWidthPx, + height: visualLabelHeightPx, + }), + [visualLabelX, visualLabelY, visualLabelWidthPx, visualLabelHeightPx], + ); - const { dx: screenDx, dy: screenDy } = computeGroupCenterDelta( - boxes, - transformerSnapLabelRect, - alignmentRequest.axis, - ); - if (screenDx === 0 && screenDy === 0) return; - - // Stage-screen → model dots: inverse-rotate into the un-rotated layout - // frame, then divide by px-per-dot. Mirrors handleStageDragMove's path. - const [layoutDx, layoutDy] = inverseRotateDelta(screenDx, screenDy, viewRotation); - const pxPerDot = scale / label.dpmm; - const dxDots = layoutDx / pxPerDot; - const dyDots = 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 } }]; - }); - if (updates.length > 0) updateObjects(updates); - // No need to clear alignmentRequest — the next click bumps `serial`. - // Effect intentionally only depends on alignmentRequest: we want to fire - // when the user clicks a button, not every time the canvas pans/zooms. - // The other values are read fresh from the closure on each fire. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [alignmentRequest]); + // 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(`#${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, + ); + const pxPerDot = scale / label.dpmm; + const dxDots = layoutDx / pxPerDot; + const dyDots = 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 } }, + ]; + }); + if (updates.length > 0) updateObjects(updates); + }, + }), + [transformerSnapLabelRect, scale, label.dpmm, viewRotation, updateObjects], + ); const { rotateEnabled, @@ -725,4 +742,4 @@ export function LabelCanvas({ )}
); -} +}); diff --git a/src/components/Properties/AlignButtons.tsx b/src/components/Properties/AlignButtons.tsx index 937aabc1..5e411f14 100644 --- a/src/components/Properties/AlignButtons.tsx +++ b/src/components/Properties/AlignButtons.tsx @@ -1,4 +1,3 @@ -import { useLabelStore } from "../../store/labelStore"; import { useT } from "../../lib/useT"; import type { AlignAxis } from "../../lib/alignment"; @@ -38,13 +37,13 @@ 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 requests an align-to-label centring of the current - * selection. The canvas owns the live render bboxes (Konva), so we just emit - * the intent — see LabelCanvas for the geometry. + * 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() { +export function AlignButtons({ onAlign }: { onAlign: (axis: AlignAxis) => void }) { const t = useT(); - const requestAlignment = useLabelStore((s) => s.requestAlignment); const buttons: { axis: AlignAxis; title: string; Icon: typeof CenterHIcon }[] = [ { axis: "h", title: t.properties.alignCenterH, Icon: CenterHIcon }, @@ -61,7 +60,7 @@ export function AlignButtons() { className={BUTTON_CLS} title={title} aria-label={title} - onClick={() => requestAlignment(axis)} + onClick={() => onAlign(axis)} > diff --git a/src/components/Properties/PropertiesPanel.tsx b/src/components/Properties/PropertiesPanel.tsx index f3806fca..6b82e748 100644 --- a/src/components/Properties/PropertiesPanel.tsx +++ b/src/components/Properties/PropertiesPanel.tsx @@ -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"; @@ -19,7 +22,15 @@ 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.). Optional: when no + * object is selected, the panel falls back to label config and the ref is + * unused. */ + canvasRef?: RefObject; +} + +export function PropertiesPanel({ canvasRef }: PropertiesPanelProps = {}) { const t = useT(); const { selectedIds, @@ -32,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 ( @@ -46,7 +59,7 @@ export function PropertiesPanel() {

{t.properties.x} / {t.properties.y}: {t.properties.multipleSelectedHint}

- + ); @@ -131,7 +144,7 @@ export function PropertiesPanel() { /> - +
diff --git a/src/store/labelStore.ts b/src/store/labelStore.ts index ac34bfcb..121a9e18 100644 --- a/src/store/labelStore.ts +++ b/src/store/labelStore.ts @@ -2,7 +2,6 @@ import { create, useStore } from 'zustand'; import { temporal } from 'zundo'; import { persist, createJSONStorage } from 'zustand/middleware'; import type { LabelConfig, ObjectChanges } from '../types/ObjectType'; -import type { AlignAxis } from '../lib/alignment'; import type { Unit } from '../lib/units'; import type { ViewRotation } from '../components/Canvas/rotationGeometry'; import { ObjectRegistry } from '../registry'; @@ -78,13 +77,6 @@ interface LabelState { clipboard: LabelObject[]; pasteCount: number; - /** Transient intent to centre the current selection on the label. The - * canvas owns the live render bboxes (via Konva), so PropertiesPanel - * bumps `serial` to fire the request and the canvas effect performs the - * geometry. Intentionally not persisted; the field exists only as the - * store-as-event-bus channel. */ - alignmentRequest: { axis: AlignAxis; serial: number } | null; - addObject: (type: string, position?: { x: number; y: number }) => void; updateObject: (id: string, changes: ObjectChanges) => void; updateObjects: (updates: { id: string; changes: ObjectChanges }[]) => void; @@ -97,7 +89,6 @@ interface LabelState { toggleSelectObject: (id: string) => void; selectObjects: (ids: string[]) => void; removeSelectedObjects: () => void; - requestAlignment: (axis: AlignAxis) => void; setLabelConfig: (config: Partial) => void; setLocale: (locale: LocaleCode) => void; setTheme: (theme: ThemePreference) => void; @@ -198,7 +189,6 @@ export const useLabelStore = create()( selectedIds: [], clipboard: [], pasteCount: 0, - alignmentRequest: null, locale: detectLocale(), theme: detectInitialTheme(), thirdParty: thirdPartyDefaults(), @@ -328,18 +318,6 @@ export const useLabelStore = create()( selectedIds: [], })), - requestAlignment: (axis) => - set((state) => ({ - alignmentRequest: { - axis, - // Bumping the serial each call lets the canvas effect re-fire - // even when the same axis is requested twice in a row (effects - // skip identical state). The value itself is meaningless beyond - // change detection. - serial: (state.alignmentRequest?.serial ?? 0) + 1, - }, - })), - moveObjectToFront: (id) => set((state) => { const objs = currentObjects(state); From 732095e1fde5201acbfb26824595d4572ea855c8 Mon Sep 17 00:00:00 2001 From: u8array Date: Sat, 9 May 2026 23:30:40 +0200 Subject: [PATCH 4/6] refactor(properties): require canvasRef on PropertiesPanel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optional+default-{} let a missing ref silently no-op the align buttons. Required prop forces AppShell to wire it; the inner `.current?` guard is enough — it only covers the pre-mount window. --- src/components/Properties/PropertiesPanel.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/Properties/PropertiesPanel.tsx b/src/components/Properties/PropertiesPanel.tsx index 6b82e748..b6fbde2e 100644 --- a/src/components/Properties/PropertiesPanel.tsx +++ b/src/components/Properties/PropertiesPanel.tsx @@ -24,13 +24,13 @@ import type { LabelConfig } from "../../types/ObjectType"; interface PropertiesPanelProps { /** Imperative handle on the canvas — used for actions that need live render - * bboxes (alignment, future zoom-to-selection, etc.). Optional: when no - * object is selected, the panel falls back to label config and the ref is - * unused. */ - canvasRef?: RefObject; + * 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; } -export function PropertiesPanel({ canvasRef }: PropertiesPanelProps = {}) { +export function PropertiesPanel({ canvasRef }: PropertiesPanelProps) { const t = useT(); const { selectedIds, @@ -44,7 +44,7 @@ export function PropertiesPanel({ canvasRef }: PropertiesPanelProps = {}) { const unit = canvasSettings.unit; const obj = objects.find((o) => o.id === selectedIds[0]); const handleAlign = (axis: AlignAxis) => - canvasRef?.current?.alignSelectionToLabel(axis); + canvasRef.current?.alignSelectionToLabel(axis); if (selectedIds.length > 1) { return ( From 0e41272e1cdbca7f4310b88fb9e28e34a7e5a9b1 Mon Sep 17 00:00:00 2001 From: u8array Date: Sat, 9 May 2026 23:32:29 +0200 Subject: [PATCH 5/6] refactor(properties): extract align icons to their own file Splits the three centre-axis SVG icons out of AlignButtons.tsx so the button-row file stays focused on layout/dispatch. Matches the pattern of ui/DragHandleIcon.tsx for single-purpose inline icons. --- src/components/Properties/AlignButtons.tsx | 33 +-------------------- src/components/Properties/AlignIcons.tsx | 34 ++++++++++++++++++++++ 2 files changed, 35 insertions(+), 32 deletions(-) create mode 100644 src/components/Properties/AlignIcons.tsx diff --git a/src/components/Properties/AlignButtons.tsx b/src/components/Properties/AlignButtons.tsx index 5e411f14..db83945f 100644 --- a/src/components/Properties/AlignButtons.tsx +++ b/src/components/Properties/AlignButtons.tsx @@ -1,37 +1,6 @@ import { useT } from "../../lib/useT"; import type { AlignAxis } from "../../lib/alignment"; - -interface IconProps { - className?: string; -} - -function CenterHIcon({ className }: IconProps) { - return ( - - ); -} - -function CenterVIcon({ className }: IconProps) { - return ( - - ); -} - -function CenterBothIcon({ className }: IconProps) { - return ( - - ); -} +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"; diff --git a/src/components/Properties/AlignIcons.tsx b/src/components/Properties/AlignIcons.tsx new file mode 100644 index 00000000..39638c62 --- /dev/null +++ b/src/components/Properties/AlignIcons.tsx @@ -0,0 +1,34 @@ +interface IconProps { + className?: string; +} + +/** Object centred on a vertical dashed line — represents horizontal centring. */ +export function CenterHIcon({ className }: IconProps) { + return ( + + ); +} + +/** Object centred on a horizontal dashed line — represents vertical centring. */ +export function CenterVIcon({ className }: IconProps) { + return ( + + ); +} + +/** Object on the cross-hair of two dashed lines — represents bi-axial centring. */ +export function CenterBothIcon({ className }: IconProps) { + return ( + + ); +} From ecb433483f22691c5c992e044e9bcdde53f706a9 Mon Sep 17 00:00:00 2001 From: u8array Date: Sat, 9 May 2026 23:37:03 +0200 Subject: [PATCH 6/6] fix(canvas): round align-to-label deltas to integer dots PropertiesPanel inputs and `mmToDots` already round dots, so the store invariant is integer x/y. The alignment delta was emitted as a float (layoutDx / pxPerDot) and could leak fractional coordinates into ZPL emit. Match the convention by rounding the delta. --- src/components/Canvas/LabelCanvas.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/Canvas/LabelCanvas.tsx b/src/components/Canvas/LabelCanvas.tsx index 324042c3..7f0fb1e0 100644 --- a/src/components/Canvas/LabelCanvas.tsx +++ b/src/components/Canvas/LabelCanvas.tsx @@ -306,9 +306,12 @@ export const LabelCanvas = forwardRef(function LabelCa 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 = layoutDx / pxPerDot; - const dyDots = layoutDy / pxPerDot; + 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);