From b916edaad4b4188b2168030a295fae820ca5a44b Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 3 May 2026 17:23:29 +0200 Subject: [PATCH 1/9] refactor(store): introduce pages array as foundation for multi-label support Replace the single top-level objects array with pages[] + currentPageIndex. Object mutations now operate on pages[currentPageIndex]; consumers read via the new useCurrentObjects() / currentObjects() selectors. Adds page-management actions (addPage, removePage, duplicatePage, setCurrentPage). Persisted state migrates legacy { objects } shape into { pages: [{ objects }], currentPageIndex: 0 } via persist version bump. UI is unchanged (single page, no pagination control yet); follow-up commits add the navigation UI, multi-page ZPL export/import, and JSON format update. --- src/components/AppShell.tsx | 4 +- src/components/Canvas/LabelCanvas.tsx | 14 +- src/components/Canvas/hooks/useCanvasLasso.ts | 8 +- .../Canvas/hooks/useKonvaTransformer.ts | 4 +- src/components/Output/LabelPreview.tsx | 5 +- src/components/Output/ZPLOutput.tsx | 5 +- src/components/Output/ZplImportModal.tsx | 4 +- src/components/Properties/LayersPanel.tsx | 5 +- src/components/Properties/PropertiesPanel.tsx | 4 +- src/hooks/useDesignFileActions.ts | 8 +- src/hooks/useGlobalShortcuts.ts | 4 +- src/hooks/useZplImportExport.ts | 4 +- src/store/labelStore.test.ts | 217 ++++++++++++--- src/store/labelStore.ts | 254 ++++++++++++++---- 14 files changed, 417 insertions(+), 123 deletions(-) diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index ffe12aa8..ff66969e 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -27,7 +27,7 @@ import { GlobeAltIcon, XMarkIcon, } from "@heroicons/react/16/solid"; -import { useLabelStore, useHistory } from "../store/labelStore"; +import { useLabelStore, useHistory, useCurrentObjects } from "../store/labelStore"; import { localeNames } from "../locales"; import type { LocaleCode } from "../locales"; import { mmToUnit } from "../lib/units"; @@ -40,7 +40,7 @@ import { useOutputPanel, OUTPUT_DEFAULT_H } from "../hooks/useOutputPanel"; export function AppShell() { const t = useT(); const label = useLabelStore((s) => s.label); - const objects = useLabelStore((s) => s.objects); + const objects = useCurrentObjects(); const selectObject = useLabelStore((s) => s.selectObject); const locale = useLabelStore((s) => s.locale); const setLocale = useLabelStore((s) => s.setLocale); diff --git a/src/components/Canvas/LabelCanvas.tsx b/src/components/Canvas/LabelCanvas.tsx index 67745d15..d98b0607 100644 --- a/src/components/Canvas/LabelCanvas.tsx +++ b/src/components/Canvas/LabelCanvas.tsx @@ -8,7 +8,7 @@ import { useDroppable, useDndMonitor } from "@dnd-kit/core"; import type { PaletteDragData } from "../../dnd/types"; import { Stage, Layer, Group, Rect, Transformer } from "react-konva"; import type Konva from "konva"; -import { useLabelStore } from "../../store/labelStore"; +import { useLabelStore, useCurrentObjects, currentObjects } from "../../store/labelStore"; import { pxToDots, SCREEN_PX_PER_MM } from "../../lib/coordinates"; import { SNAP_OPTIONS } from "../../lib/units"; import type { Unit } from "../../lib/units"; @@ -85,7 +85,6 @@ export function LabelCanvas({ const { label, - objects, selectedIds, addObject, updateObject, @@ -94,6 +93,7 @@ export function LabelCanvas({ toggleSelectObject, selectObjects, } = useLabelStore(); + const objects = useCurrentObjects(); useEffect(() => { const el = containerRef.current; @@ -131,7 +131,9 @@ export function LabelCanvas({ const tag = (e.target as HTMLElement).tagName; if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return; - const { selectedIds: ids, objects: objs } = useLabelStore.getState(); + const state = useLabelStore.getState(); + const ids = state.selectedIds; + const objs = currentObjects(state); if (ids.length === 0) return; e.preventDefault(); @@ -269,7 +271,9 @@ export function LabelCanvas({ // Multi-select: propagate position delta to all other selected objects. // Read fresh state (getState) to avoid stale closure when multiple DragEnd events // fire simultaneously during a Transformer group drag. - const { selectedIds: selIds, objects: currentObjs } = useLabelStore.getState(); + const state = useLabelStore.getState(); + const selIds = state.selectedIds; + const currentObjs = currentObjects(state); if ( selIds.length > 1 && selIds.includes(id) && @@ -303,7 +307,7 @@ export function LabelCanvas({ const objId = node.id(); if (!objId || !stageRef.current) return; - const { objects: objs } = useLabelStore.getState(); + const objs = currentObjects(useLabelStore.getState()); const obj = objs.find((o) => o.id === objId); if (!obj) return; diff --git a/src/components/Canvas/hooks/useCanvasLasso.ts b/src/components/Canvas/hooks/useCanvasLasso.ts index 2a32fae7..44f403c4 100644 --- a/src/components/Canvas/hooks/useCanvasLasso.ts +++ b/src/components/Canvas/hooks/useCanvasLasso.ts @@ -1,6 +1,6 @@ import { useState, useRef } from "react"; import type Konva from "konva"; -import { useLabelStore } from "../../../store/labelStore"; +import { useLabelStore, currentObjects } from "../../../store/labelStore"; import { getIdsIntersectingRect, type LassoRect } from "../lassoGeometry"; interface Options { @@ -58,16 +58,14 @@ export function useCanvasLasso({ containerRef, stageRef, spaceDown, selectObject lassoRectRef.current = null; setLasso(null); if (!rect || !stageRef.current) return; - const ids = useLabelStore.getState().objects.map((o) => o.id); + const ids = currentObjects(useLabelStore.getState()).map((o) => o.id); selectObjects(getIdsIntersectingRect(stageRef.current, ids, rect)); }; const onStageMouseDown = (e: Konva.KonvaEventObject) => { if (e.evt.button !== 0 || spaceDown) return; const targetId = e.target.id(); - const onObject = useLabelStore - .getState() - .objects.some((o) => o.id === targetId); + const onObject = currentObjects(useLabelStore.getState()).some((o) => o.id === targetId); if (onObject || e.target.getParent()?.className === "Transformer") return; const pos = stageRef.current?.getPointerPosition(); if (!pos) return; diff --git a/src/components/Canvas/hooks/useKonvaTransformer.ts b/src/components/Canvas/hooks/useKonvaTransformer.ts index aa49a032..ac492fe6 100644 --- a/src/components/Canvas/hooks/useKonvaTransformer.ts +++ b/src/components/Canvas/hooks/useKonvaTransformer.ts @@ -1,7 +1,7 @@ import { useRef, useEffect } from "react"; import type Konva from "konva"; import { pxToDots } from "../../../lib/coordinates"; -import { useLabelStore } from "../../../store/labelStore"; +import { useLabelStore, currentObjects } from "../../../store/labelStore"; import { BARCODE_1D_TYPES, STACKED_2D_TYPES, ObjectRegistry } from "../../../registry"; import type { LabelObject } from "../../../registry"; import type { ObjectChanges } from "../../../store/labelStore"; @@ -133,7 +133,7 @@ export function useKonvaTransformer({ const nodeHeight = node.height(); node.scaleX(1); node.scaleY(1); - const obj = useLabelStore.getState().objects.find((o) => o.id === singleId); + const obj = currentObjects(useLabelStore.getState()).find((o) => o.id === singleId); if (!obj) { transformAnchorRef.current = null; return; diff --git a/src/components/Output/LabelPreview.tsx b/src/components/Output/LabelPreview.tsx index 936673b5..e34d5f56 100644 --- a/src/components/Output/LabelPreview.tsx +++ b/src/components/Output/LabelPreview.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { XMarkIcon, ArrowDownTrayIcon } from '@heroicons/react/16/solid'; -import { useLabelStore } from '../../store/labelStore'; +import { useLabelStore, useCurrentObjects } from '../../store/labelStore'; import { generateZPL } from '../../lib/zplGenerator'; import { fetchPreview, labelaryErrorMessage } from '../../lib/labelary'; import { triggerDownload } from '../../lib/triggerDownload'; @@ -13,7 +13,8 @@ interface Props { export function LabelPreviewModal({ onClose }: Props) { const t = useT(); - const { label, objects } = useLabelStore(); + const label = useLabelStore((s) => s.label); + const objects = useCurrentObjects(); const [previewUrl, setPreviewUrl] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); diff --git a/src/components/Output/ZPLOutput.tsx b/src/components/Output/ZPLOutput.tsx index a31e9294..e1095667 100644 --- a/src/components/Output/ZPLOutput.tsx +++ b/src/components/Output/ZPLOutput.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { CheckIcon, ClipboardDocumentIcon, ChevronDownIcon, ChevronUpIcon, EyeIcon } from '@heroicons/react/16/solid'; -import { useLabelStore } from '../../store/labelStore'; +import { useLabelStore, useCurrentObjects } from '../../store/labelStore'; import { generateZPL } from '../../lib/zplGenerator'; import { useT } from '../../lib/useT'; import { LabelPreviewModal } from './LabelPreview'; @@ -13,7 +13,8 @@ interface Props { export function ZPLOutput({ collapsed, onCollapse, onExpand }: Props) { const t = useT(); - const { label, objects } = useLabelStore(); + const label = useLabelStore((s) => s.label); + const objects = useCurrentObjects(); const [copied, setCopied] = useState(false); const [showPreview, setShowPreview] = useState(false); diff --git a/src/components/Output/ZplImportModal.tsx b/src/components/Output/ZplImportModal.tsx index 5d5a4595..45cafaf7 100644 --- a/src/components/Output/ZplImportModal.tsx +++ b/src/components/Output/ZplImportModal.tsx @@ -33,7 +33,7 @@ export function ZplImportModal({ onClose }: Props) { return; } - loadDesign({ ...label, ...labelConfig }, parsedObjects); + loadDesign({ ...label, ...labelConfig }, [{ objects: parsedObjects }]); setResult({ objectCount: parsedObjects.length, report }); }; @@ -57,7 +57,7 @@ export function ZplImportModal({ onClose }: Props) { } const { labelConfig, objects: parsedObjects, report } = importZplText(text, label.dpmm); - loadDesign({ ...label, ...labelConfig }, parsedObjects); + loadDesign({ ...label, ...labelConfig }, [{ objects: parsedObjects }]); setResult({ objectCount: parsedObjects.length, report }); }; diff --git a/src/components/Properties/LayersPanel.tsx b/src/components/Properties/LayersPanel.tsx index 1d3609c9..1f77b39f 100644 --- a/src/components/Properties/LayersPanel.tsx +++ b/src/components/Properties/LayersPanel.tsx @@ -8,7 +8,7 @@ import { } from '@dnd-kit/core'; import type { DragEndEvent, DragOverEvent } from '@dnd-kit/core'; import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'; -import { useLabelStore } from '../../store/labelStore'; +import { useLabelStore, useCurrentObjects } from '../../store/labelStore'; import { ObjectRegistry } from '../../registry'; import type { LabelObject } from '../../registry'; import { useT } from '../../lib/useT'; @@ -63,7 +63,8 @@ function SortableLayerRow({ obj, isSelected, isOver, onSelect, onToggle }: RowPr export function LayersPanel() { const t = useT(); - const { objects, selectedIds, selectObject, toggleSelectObject, reorderObject } = useLabelStore(); + const { selectedIds, selectObject, toggleSelectObject, reorderObject } = useLabelStore(); + const objects = useCurrentObjects(); const [overId, setOverId] = useState(null); const sensors = useSensors( diff --git a/src/components/Properties/PropertiesPanel.tsx b/src/components/Properties/PropertiesPanel.tsx index e2b58a8e..2c23f562 100644 --- a/src/components/Properties/PropertiesPanel.tsx +++ b/src/components/Properties/PropertiesPanel.tsx @@ -1,4 +1,4 @@ -import { useLabelStore } from "../../store/labelStore"; +import { useLabelStore, useCurrentObjects } from "../../store/labelStore"; import { ObjectRegistry } from "../../registry"; import { dotsToMm, mmToDots } from "../../lib/coordinates"; import { @@ -16,7 +16,6 @@ import type { LabelConfig } from "../../types/ObjectType"; export function PropertiesPanel() { const t = useT(); const { - objects, selectedIds, updateObject, label, @@ -24,6 +23,7 @@ export function PropertiesPanel() { canvasSettings, setCanvasSettings, } = useLabelStore(); + const objects = useCurrentObjects(); const unit = canvasSettings.unit; const obj = objects.find((o) => o.id === selectedIds[0]); diff --git a/src/hooks/useDesignFileActions.ts b/src/hooks/useDesignFileActions.ts index 3c85d71a..dc018677 100644 --- a/src/hooks/useDesignFileActions.ts +++ b/src/hooks/useDesignFileActions.ts @@ -1,18 +1,18 @@ import { useRef, useState } from "react"; -import { useLabelStore } from "../store/labelStore"; +import { useLabelStore, useCurrentObjects } from "../store/labelStore"; import { triggerDownload } from "../lib/triggerDownload"; import { parseDesignFile, serializeDesign, designFileErrors } from "../lib/designFile"; import { readFileAsText } from "../lib/readFile"; export function useDesignFileActions() { const label = useLabelStore((s) => s.label); - const objects = useLabelStore((s) => s.objects); + const objects = useCurrentObjects(); const loadDesign = useLabelStore((s) => s.loadDesign); const [loadError, setLoadError] = useState(null); const loadInputRef = useRef(null); const handleNew = () => { - loadDesign({ widthMm: 100, heightMm: 60, dpmm: 8 }, []); + loadDesign({ widthMm: 100, heightMm: 60, dpmm: 8 }, [{ objects: [] }]); }; const handleSave = () => { @@ -40,7 +40,7 @@ export function useDesignFileActions() { } setLoadError(null); - loadDesign(result.value.label, result.value.objects); + loadDesign(result.value.label, [{ objects: result.value.objects }]); }; return { diff --git a/src/hooks/useGlobalShortcuts.ts b/src/hooks/useGlobalShortcuts.ts index 2eedffd5..b50f0250 100644 --- a/src/hooks/useGlobalShortcuts.ts +++ b/src/hooks/useGlobalShortcuts.ts @@ -1,5 +1,5 @@ import { useEffect } from "react"; -import { useLabelStore, useHistory } from "../store/labelStore"; +import { useLabelStore, useHistory, currentObjects } from "../store/labelStore"; import { nextRotation } from "../components/Canvas/rotationGeometry"; export function useGlobalShortcuts() { @@ -25,7 +25,7 @@ export function useGlobalShortcuts() { if (inInput) return; if (mod && e.code === "KeyA") { e.preventDefault(); - selectObjects(useLabelStore.getState().objects.map((o) => o.id)); + selectObjects(currentObjects(useLabelStore.getState()).map((o) => o.id)); return; } if (mod && e.code === "KeyD") { diff --git a/src/hooks/useZplImportExport.ts b/src/hooks/useZplImportExport.ts index 60184118..cfc24ec5 100644 --- a/src/hooks/useZplImportExport.ts +++ b/src/hooks/useZplImportExport.ts @@ -1,5 +1,5 @@ import { useState } from "react"; -import { useLabelStore } from "../store/labelStore"; +import { useLabelStore, useCurrentObjects } from "../store/labelStore"; import { generateZPL } from "../lib/zplGenerator"; import { printLabel } from "../lib/printPreview"; import { triggerDownload } from "../lib/triggerDownload"; @@ -7,7 +7,7 @@ import { labelaryErrorMessage } from "../lib/labelary"; export function useZplImportExport() { const label = useLabelStore((s) => s.label); - const objects = useLabelStore((s) => s.objects); + const objects = useCurrentObjects(); const [showZplImport, setShowZplImport] = useState(false); const [showZebraPrint, setShowZebraPrint] = useState(false); const [printError, setPrintError] = useState(null); diff --git a/src/store/labelStore.test.ts b/src/store/labelStore.test.ts index 7c6abc12..17a4e3a9 100644 --- a/src/store/labelStore.test.ts +++ b/src/store/labelStore.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { useLabelStore } from './labelStore'; +import { useLabelStore, currentObjects } from './labelStore'; import type { LabelObject } from '../registry'; import { defined, props } from '../test/helpers'; @@ -7,7 +7,8 @@ import { defined, props } from '../test/helpers'; function reset() { useLabelStore.setState({ label: { widthMm: 100, heightMm: 60, dpmm: 8 }, - objects: [], + pages: [{ objects: [] }], + currentPageIndex: 0, selectedIds: [], clipboard: [], pasteCount: 0, @@ -27,8 +28,12 @@ function state() { return useLabelStore.getState(); } +function objs(): LabelObject[] { + return currentObjects(state()); +} + function ids() { - return state().objects.map((o) => o.id); + return objs().map((o) => o.id); } // ── setup ───────────────────────────────────────────────────────────────────── @@ -40,8 +45,8 @@ beforeEach(() => reset()); describe('addObject', () => { it('creates object with registry defaults and selects it', () => { state().addObject('text'); - expect(state().objects).toHaveLength(1); - const obj = defined(state().objects[0]); + expect(objs()).toHaveLength(1); + const obj = defined(objs()[0]); expect(obj.type).toBe('text'); expect(obj.x).toBe(50); // default position expect(obj.y).toBe(50); @@ -52,19 +57,19 @@ describe('addObject', () => { it('respects a custom position', () => { state().addObject('box', { x: 200, y: 300 }); - expect(defined(state().objects[0]).x).toBe(200); - expect(defined(state().objects[0]).y).toBe(300); + expect(defined(objs()[0]).x).toBe(200); + expect(defined(objs()[0]).y).toBe(300); }); it('ignores unknown types', () => { state().addObject('nonexistent_type_xyz'); - expect(state().objects).toHaveLength(0); + expect(objs()).toHaveLength(0); }); it('gives each object a unique id', () => { state().addObject('text'); state().addObject('text'); - const [a, b] = state().objects; + const [a, b] = objs(); expect(defined(a).id).not.toBe(defined(b).id); }); }); @@ -74,21 +79,19 @@ describe('addObject', () => { describe('updateObject — props merging', () => { it('merges partial props instead of replacing them', () => { state().addObject('text'); - const obj = defined(state().objects[0]); - // text defaults: content, fontHeight, fontWidth, rotation + const obj = defined(objs()[0]); state().updateObject(obj.id, { props: { fontHeight: 99 } }); - const updated = defined(state().objects[0]); + const updated = defined(objs()[0]); expect(props(updated).fontHeight).toBe(99); - // other props preserved expect(props(updated).content).toBe('Text'); }); it('updates top-level fields (x, y) without touching props', () => { state().addObject('text'); - const obj = defined(state().objects[0]); + const obj = defined(objs()[0]); state().updateObject(obj.id, { x: 999 }); - expect(defined(state().objects[0]).x).toBe(999); - expect(props(defined(state().objects[0])).content).toBe('Text'); + expect(defined(objs()[0]).x).toBe(999); + expect(props(defined(objs()[0])).content).toBe('Text'); }); }); @@ -97,10 +100,10 @@ describe('updateObject — props merging', () => { describe('removeObject', () => { it('removes the object and deselects it', () => { state().addObject('text'); - const id = defined(state().objects[0]).id; + const id = defined(objs()[0]).id; state().selectObject(id); state().removeObject(id); - expect(state().objects).toHaveLength(0); + expect(objs()).toHaveLength(0); expect(state().selectedIds).toEqual([]); }); }); @@ -110,11 +113,11 @@ describe('removeObject', () => { describe('duplicateObject', () => { it('creates a copy offset by +20/+20 with a new id', () => { state().addObject('text', { x: 100, y: 100 }); - const original = defined(state().objects[0]); + const original = defined(objs()[0]); state().duplicateObject(original.id); - expect(state().objects).toHaveLength(2); - const copy = defined(state().objects[1]); + expect(objs()).toHaveLength(2); + const copy = defined(objs()[1]); expect(copy.id).not.toBe(original.id); expect(copy.x).toBe(120); expect(copy.y).toBe(120); @@ -123,15 +126,15 @@ describe('duplicateObject', () => { it('selects only the new copy', () => { state().addObject('text'); - state().duplicateObject(defined(state().objects[0]).id); + state().duplicateObject(defined(objs()[0]).id); expect(state().selectedIds).toHaveLength(1); - expect(state().selectedIds[0]).toBe(defined(state().objects[1]).id); + expect(state().selectedIds[0]).toBe(defined(objs()[1]).id); }); it('does nothing for a nonexistent id', () => { state().addObject('text'); state().duplicateObject('fake-id'); - expect(state().objects).toHaveLength(1); + expect(objs()).toHaveLength(1); }); }); @@ -140,26 +143,26 @@ describe('duplicateObject', () => { describe('copy / paste', () => { it('paste is a no-op when clipboard is empty', () => { state().pasteObjects(); - expect(state().objects).toHaveLength(0); + expect(objs()).toHaveLength(0); }); it('paste increments offset with each call (+20, +40, …)', () => { state().addObject('text', { x: 100, y: 100 }); - state().selectObject(defined(state().objects[0]).id); + state().selectObject(defined(objs()[0]).id); state().copySelectedObjects(); state().pasteObjects(); - expect(state().objects).toHaveLength(2); - expect(defined(state().objects[1]).x).toBe(120); // +20 + expect(objs()).toHaveLength(2); + expect(defined(objs()[1]).x).toBe(120); // +20 state().pasteObjects(); - expect(state().objects).toHaveLength(3); - expect(defined(state().objects[2]).x).toBe(140); // +40 + expect(objs()).toHaveLength(3); + expect(defined(objs()[2]).x).toBe(140); // +40 }); it('paste creates new ids (not reusing clipboard ids)', () => { state().addObject('text'); - state().selectObject(defined(state().objects[0]).id); + state().selectObject(defined(objs()[0]).id); state().copySelectedObjects(); state().pasteObjects(); state().pasteObjects(); @@ -174,7 +177,7 @@ describe('toggleSelectObject', () => { it('adds to selection, then removes on second call', () => { state().addObject('text'); state().addObject('box'); - const [a, b] = state().objects; + const [a, b] = objs(); state().selectObject(null); // clear state().toggleSelectObject(defined(a).id); @@ -196,8 +199,8 @@ describe('removeSelectedObjects', () => { state().selectObjects(ids().slice(0, 2)); state().removeSelectedObjects(); - expect(state().objects).toHaveLength(1); - expect(defined(state().objects[0]).type).toBe('line'); + expect(objs()).toHaveLength(1); + expect(defined(objs()[0]).type).toBe('line'); expect(state().selectedIds).toEqual([]); }); }); @@ -291,20 +294,29 @@ describe('reorderObject', () => { // ── loadDesign ──────────────────────────────────────────────────────────────── describe('loadDesign', () => { - it('replaces label config, objects, and clears selection', () => { + it('replaces label config, pages, and clears selection', () => { state().addObject('text'); - state().selectObject(defined(state().objects[0]).id); + state().selectObject(defined(objs()[0]).id); const newLabel = { widthMm: 50, heightMm: 30, dpmm: 12 }; const newObjects = [ { id: 'x1', type: 'box' as const, x: 10, y: 10, rotation: 0, props: { width: 50, height: 50, thickness: 3, filled: false, color: 'B' as const, rounding: 0 } }, ] satisfies LabelObject[]; - state().loadDesign(newLabel, newObjects); + state().loadDesign(newLabel, [{ objects: newObjects }]); expect(state().label).toEqual(newLabel); - expect(state().objects).toHaveLength(1); + expect(state().pages).toHaveLength(1); + expect(objs()).toHaveLength(1); + expect(state().currentPageIndex).toBe(0); expect(state().selectedIds).toEqual([]); }); + + it('falls back to a single empty page when given an empty pages array', () => { + state().loadDesign({ widthMm: 50, heightMm: 30, dpmm: 8 }, []); + expect(state().pages).toHaveLength(1); + expect(objs()).toHaveLength(0); + expect(state().currentPageIndex).toBe(0); + }); }); // ── setLabelConfig (partial merge) ──────────────────────────────────────────── @@ -317,3 +329,132 @@ describe('setLabelConfig', () => { expect(state().label.dpmm).toBe(8); // unchanged }); }); + +// ── pages ───────────────────────────────────────────────────────────────────── + +describe('addPage', () => { + it('inserts a blank page after the current and switches to it', () => { + expect(state().pages).toHaveLength(1); + state().addPage(); + expect(state().pages).toHaveLength(2); + expect(state().currentPageIndex).toBe(1); + expect(objs()).toHaveLength(0); + }); + + it('clears selection when switching to the new page', () => { + state().addObject('text'); + state().selectObject(defined(objs()[0]).id); + expect(state().selectedIds).toHaveLength(1); + state().addPage(); + expect(state().selectedIds).toEqual([]); + }); + + it('preserves objects on the previous page', () => { + state().addObject('text'); + const firstPageObjId = defined(objs()[0]).id; + state().addPage(); + expect(objs()).toHaveLength(0); + state().setCurrentPage(0); + expect(defined(objs()[0]).id).toBe(firstPageObjId); + }); + + it('inserts in the middle when current page is not the last', () => { + state().addPage(); // pages [0, 1], current=1 + state().addPage(); // pages [0, 1, 2], current=2 + state().setCurrentPage(0); + state().addPage(); // inserts at index 1, current=1 + expect(state().pages).toHaveLength(4); + expect(state().currentPageIndex).toBe(1); + }); +}); + +describe('removePage', () => { + it('refuses to remove the last remaining page', () => { + state().removePage(0); + expect(state().pages).toHaveLength(1); + }); + + it('removes the requested page and adjusts currentPageIndex when removing earlier page', () => { + state().addPage(); // current=1 + state().addPage(); // current=2 + state().removePage(0); + expect(state().pages).toHaveLength(2); + expect(state().currentPageIndex).toBe(1); + }); + + it('clamps currentPageIndex when removing the current (last) page', () => { + state().addPage(); // current=1 + state().removePage(1); + expect(state().pages).toHaveLength(1); + expect(state().currentPageIndex).toBe(0); + }); + + it('keeps currentPageIndex stable when removing a later page', () => { + state().addPage(); // current=1 + state().addPage(); // current=2 + state().setCurrentPage(0); + state().removePage(2); + expect(state().pages).toHaveLength(2); + expect(state().currentPageIndex).toBe(0); + }); + + it('ignores out-of-range indices', () => { + state().addPage(); + const before = state().pages.length; + state().removePage(99); + expect(state().pages).toHaveLength(before); + }); +}); + +describe('duplicatePage', () => { + it('clones the page at index with new object ids', () => { + state().addObject('text'); + const originalId = defined(objs()[0]).id; + state().duplicatePage(0); + expect(state().pages).toHaveLength(2); + expect(state().currentPageIndex).toBe(1); + expect(objs()).toHaveLength(1); + expect(defined(objs()[0]).id).not.toBe(originalId); + }); +}); + +describe('setCurrentPage', () => { + it('switches pages and clears selection', () => { + state().addObject('text'); + state().selectObject(defined(objs()[0]).id); + state().addPage(); + expect(state().currentPageIndex).toBe(1); + state().setCurrentPage(0); + expect(state().currentPageIndex).toBe(0); + expect(state().selectedIds).toEqual([]); + }); + + it('ignores out-of-range indices', () => { + state().setCurrentPage(99); + expect(state().currentPageIndex).toBe(0); + }); +}); + +describe('per-page object isolation', () => { + it('addObject only affects the current page', () => { + state().addObject('text'); + state().addPage(); + state().addObject('box'); + expect(objs()).toHaveLength(1); + expect(defined(objs()[0]).type).toBe('box'); + state().setCurrentPage(0); + expect(objs()).toHaveLength(1); + expect(defined(objs()[0]).type).toBe('text'); + }); + + it('paste targets the current page', () => { + state().addObject('text'); + state().selectObject(defined(objs()[0]).id); + state().copySelectedObjects(); + state().addPage(); + state().pasteObjects(); + expect(objs()).toHaveLength(1); + state().setCurrentPage(0); + expect(objs()).toHaveLength(1); + }); +}); diff --git a/src/store/labelStore.ts b/src/store/labelStore.ts index ac1ae6d6..9f09f637 100644 --- a/src/store/labelStore.ts +++ b/src/store/labelStore.ts @@ -11,6 +11,10 @@ import type { LocaleCode } from '../locales'; export type { ObjectChanges }; +export interface Page { + objects: LabelObject[]; +} + function applyObjectChanges(obj: LabelObject, changes: ObjectChanges): LabelObject { const normalize = ObjectRegistry[obj.type]?.normalizeChanges; const normalized = normalize ? normalize(obj, changes) : changes; @@ -37,7 +41,8 @@ export interface CanvasSettings { interface LabelState { label: LabelConfig; - objects: LabelObject[]; + pages: Page[]; + currentPageIndex: number; selectedIds: string[]; locale: LocaleCode; canvasSettings: CanvasSettings; @@ -61,12 +66,47 @@ interface LabelState { setLabelConfig: (config: Partial) => void; setLocale: (locale: LocaleCode) => void; setCanvasSettings: (settings: Partial) => void; - loadDesign: (label: LabelConfig, objects: LabelObject[]) => void; + loadDesign: (label: LabelConfig, pages: Page[]) => void; moveObjectForward: (id: string) => void; moveObjectBackward: (id: string) => void; moveObjectToFront: (id: string) => void; moveObjectToBack: (id: string) => void; reorderObject: (id: string, toIndex: number) => void; + + addPage: () => void; + removePage: (index: number) => void; + duplicatePage: (index: number) => void; + setCurrentPage: (index: number) => void; +} + +type PageState = Pick; + +export const currentObjects = (state: PageState): LabelObject[] => + state.pages[state.currentPageIndex]?.objects ?? []; + +function updateCurrentObjects( + state: PageState, + fn: (objects: LabelObject[]) => LabelObject[] +): Pick { + return { + pages: state.pages.map((p, i) => + i === state.currentPageIndex ? { ...p, objects: fn(p.objects) } : p + ), + }; +} + +function migrateLegacy(persistedState: unknown): unknown { + if (!persistedState || typeof persistedState !== 'object') return persistedState; + const s = persistedState as Record; + // v0 stored `objects` at top level; wrap it into a single page. + if (Array.isArray(s.objects) && !Array.isArray(s.pages)) { + return { + ...s, + pages: [{ objects: s.objects }], + currentPageIndex: 0, + }; + } + return persistedState; } export const useLabelStore = create()( @@ -74,7 +114,8 @@ export const useLabelStore = create()( persist( (set, get) => ({ label: { widthMm: 100, heightMm: 60, dpmm: 8 }, - objects: [], + pages: [{ objects: [] }], + currentPageIndex: 0, selectedIds: [], clipboard: [], pasteCount: 0, @@ -96,36 +137,39 @@ export const useLabelStore = create()( } as LabelObject; set((state) => ({ - objects: [...state.objects, obj], + ...updateCurrentObjects(state, (objs) => [...objs, obj]), selectedIds: [obj.id], })); }, updateObject: (id, changes) => - set((state) => ({ - objects: state.objects.map((obj) => obj.id === id ? applyObjectChanges(obj, changes) : obj), - })), + set((state) => + updateCurrentObjects(state, (objs) => + objs.map((obj) => obj.id === id ? applyObjectChanges(obj, changes) : obj) + ) + ), updateObjects: (updates) => set((state) => { const updateMap = new Map(updates.map((u) => [u.id, u.changes])); - return { - objects: state.objects.map((obj) => { + return updateCurrentObjects(state, (objs) => + objs.map((obj) => { const changes = updateMap.get(obj.id); return changes ? applyObjectChanges(obj, changes) : obj; - }), - }; + }) + ); }), removeObject: (id) => set((state) => ({ - objects: state.objects.filter((obj) => obj.id !== id), + ...updateCurrentObjects(state, (objs) => objs.filter((obj) => obj.id !== id)), selectedIds: state.selectedIds.filter((s) => s !== id), })), duplicateObject: (id) => set((state) => { - const src = state.objects.find((o) => o.id === id); + const objs = currentObjects(state); + const src = objs.find((o) => o.id === id); if (!src) return {}; const copy: LabelObject = { ...src, @@ -133,26 +177,35 @@ export const useLabelStore = create()( x: src.x + 20, y: src.y + 20, }; - return { objects: [...state.objects, copy], selectedIds: [copy.id] }; + return { + ...updateCurrentObjects(state, (curr) => [...curr, copy]), + selectedIds: [copy.id], + }; }), duplicateSelectedObjects: () => set((state) => { if (state.selectedIds.length === 0) return {}; + const objs = currentObjects(state); const duplicateCount = state.duplicateCount + 1; const offset = duplicateCount * 20; const copies: LabelObject[] = state.selectedIds.flatMap((id) => { - const src = state.objects.find((o) => o.id === id); + const src = objs.find((o) => o.id === id); if (!src) return []; return [{ ...src, id: crypto.randomUUID(), x: src.x + offset, y: src.y + offset } as LabelObject]; }); - return { objects: [...state.objects, ...copies], selectedIds: copies.map((c) => c.id), duplicateCount }; + return { + ...updateCurrentObjects(state, (curr) => [...curr, ...copies]), + selectedIds: copies.map((c) => c.id), + duplicateCount, + }; }), copySelectedObjects: () => { - const { selectedIds, objects } = get(); - const clipboard = selectedIds.flatMap((id) => { - const obj = objects.find((o) => o.id === id); + const state = get(); + const objs = currentObjects(state); + const clipboard = state.selectedIds.flatMap((id) => { + const obj = objs.find((o) => o.id === id); return obj ? [{ ...obj, props: { ...obj.props } } as LabelObject] : []; }); set({ clipboard, pasteCount: 0 }); @@ -169,7 +222,11 @@ export const useLabelStore = create()( x: src.x + offset, y: src.y + offset, } as LabelObject)); - return { objects: [...state.objects, ...copies], selectedIds: copies.map((c) => c.id), pasteCount }; + return { + ...updateCurrentObjects(state, (curr) => [...curr, ...copies]), + selectedIds: copies.map((c) => c.id), + pasteCount, + }; }), selectObject: (id) => @@ -200,63 +257,84 @@ export const useLabelStore = create()( removeSelectedObjects: () => set((state) => ({ - objects: state.objects.filter((o) => !state.selectedIds.includes(o.id)), + ...updateCurrentObjects(state, (objs) => objs.filter((o) => !state.selectedIds.includes(o.id))), selectedIds: [], })), moveObjectToFront: (id) => set((state) => { - const idx = state.objects.findIndex((o) => o.id === id); - if (idx === -1 || idx === state.objects.length - 1) return {}; - const objs = [...state.objects]; - const [moved] = objs.splice(idx, 1); - if (moved) objs.push(moved); - return { objects: objs }; + const objs = currentObjects(state); + const idx = objs.findIndex((o) => o.id === id); + if (idx === -1 || idx === objs.length - 1) return {}; + return updateCurrentObjects(state, (curr) => { + const next = [...curr]; + const [moved] = next.splice(idx, 1); + if (moved) next.push(moved); + return next; + }); }), moveObjectToBack: (id) => set((state) => { - const idx = state.objects.findIndex((o) => o.id === id); + const objs = currentObjects(state); + const idx = objs.findIndex((o) => o.id === id); if (idx <= 0) return {}; - const objs = [...state.objects]; - const [moved] = objs.splice(idx, 1); - if (moved) objs.unshift(moved); - return { objects: objs }; + return updateCurrentObjects(state, (curr) => { + const next = [...curr]; + const [moved] = next.splice(idx, 1); + if (moved) next.unshift(moved); + return next; + }); }), moveObjectForward: (id) => set((state) => { - const idx = state.objects.findIndex((o) => o.id === id); - if (idx === -1 || idx === state.objects.length - 1) return {}; - const objs = [...state.objects]; - const tmp = objs[idx + 1] as LabelObject; - objs[idx + 1] = objs[idx] as LabelObject; - objs[idx] = tmp; - return { objects: objs }; + const objs = currentObjects(state); + const idx = objs.findIndex((o) => o.id === id); + if (idx === -1 || idx === objs.length - 1) return {}; + return updateCurrentObjects(state, (curr) => { + const next = [...curr]; + const tmp = next[idx + 1] as LabelObject; + next[idx + 1] = next[idx] as LabelObject; + next[idx] = tmp; + return next; + }); }), moveObjectBackward: (id) => set((state) => { - const idx = state.objects.findIndex((o) => o.id === id); + const objs = currentObjects(state); + const idx = objs.findIndex((o) => o.id === id); if (idx <= 0) return {}; - const objs = [...state.objects]; - const tmp = objs[idx - 1] as LabelObject; - objs[idx - 1] = objs[idx] as LabelObject; - objs[idx] = tmp; - return { objects: objs }; + return updateCurrentObjects(state, (curr) => { + const next = [...curr]; + const tmp = next[idx - 1] as LabelObject; + next[idx - 1] = next[idx] as LabelObject; + next[idx] = tmp; + return next; + }); }), reorderObject: (id, toIndex) => set((state) => { - const objs = [...state.objects]; + const objs = currentObjects(state); const fromIndex = objs.findIndex((o) => o.id === id); if (fromIndex === -1 || fromIndex === toIndex) return {}; - const [item] = objs.splice(fromIndex, 1); - if (item) objs.splice(toIndex, 0, item); - return { objects: objs }; + return updateCurrentObjects(state, (curr) => { + const next = [...curr]; + const [item] = next.splice(fromIndex, 1); + if (item) next.splice(toIndex, 0, item); + return next; + }); }), - loadDesign: (label, objects) => set({ label, objects, selectedIds: [] }), + loadDesign: (label, pages) => + set({ + label, + pages: pages.length > 0 ? pages : [{ objects: [] }], + currentPageIndex: 0, + selectedIds: [], + }), setLabelConfig: (config) => set((state) => ({ label: { ...state.label, ...config } })), @@ -265,27 +343,97 @@ export const useLabelStore = create()( setCanvasSettings: (settings) => set((state) => ({ canvasSettings: { ...state.canvasSettings, ...settings } })), + + addPage: () => + set((state) => { + const insertAt = state.currentPageIndex + 1; + const newPages = [ + ...state.pages.slice(0, insertAt), + { objects: [] }, + ...state.pages.slice(insertAt), + ]; + return { + pages: newPages, + currentPageIndex: insertAt, + selectedIds: [], + }; + }), + + removePage: (index) => + set((state) => { + if (state.pages.length <= 1) return {}; + if (index < 0 || index >= state.pages.length) return {}; + const newPages = state.pages.filter((_, i) => i !== index); + let newIndex = state.currentPageIndex; + if (index < state.currentPageIndex) { + newIndex = state.currentPageIndex - 1; + } else if (index === state.currentPageIndex) { + newIndex = Math.min(state.currentPageIndex, newPages.length - 1); + } + return { + pages: newPages, + currentPageIndex: newIndex, + selectedIds: [], + }; + }), + + duplicatePage: (index) => + set((state) => { + if (index < 0 || index >= state.pages.length) return {}; + const source = state.pages[index]; + if (!source) return {}; + const cloned: Page = { + objects: source.objects.map((o) => ({ + ...o, + id: crypto.randomUUID(), + props: { ...o.props }, + } as LabelObject)), + }; + const insertAt = index + 1; + const newPages = [ + ...state.pages.slice(0, insertAt), + cloned, + ...state.pages.slice(insertAt), + ]; + return { + pages: newPages, + currentPageIndex: insertAt, + selectedIds: [], + }; + }), + + setCurrentPage: (index) => + set((state) => { + if (index < 0 || index >= state.pages.length) return {}; + if (index === state.currentPageIndex) return {}; + return { currentPageIndex: index, selectedIds: [] }; + }), }), { name: 'zpl-designer-session', + version: 1, + migrate: (persistedState) => migrateLegacy(persistedState) as LabelState, storage: createJSONStorage(() => localStorage), partialize: (state) => ({ label: state.label, - objects: state.objects, + pages: state.pages, + currentPageIndex: state.currentPageIndex, locale: state.locale, canvasSettings: state.canvasSettings, }), } ), { - // exclude selectedId from undo history partialize: (state) => ({ label: state.label, - objects: state.objects, + pages: state.pages, + currentPageIndex: state.currentPageIndex, }), } ) ); +export const useCurrentObjects = () => useLabelStore(currentObjects); + // Undo / redo export const useHistory = () => useStore(useLabelStore.temporal); From a7818f9d86410b11a8079ebd1e375e74185d7d8d Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 3 May 2026 17:25:22 +0200 Subject: [PATCH 2/9] feat(canvas): add pagination control for multi-label navigation Adds a small bottom-center widget to the canvas with page indicator, prev/next, add-page, and remove-page controls. Page Up / Page Down navigate between pages. README keyboard shortcuts table updated. The single ZPL output still concatenates only the current page's objects; multi-page export comes in the next commit. --- README.md | 1 + src/components/Canvas/LabelCanvas.tsx | 3 ++ src/components/Canvas/PaginationControl.tsx | 57 +++++++++++++++++++++ src/hooks/useGlobalShortcuts.ts | 13 ++++- 4 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 src/components/Canvas/PaginationControl.tsx diff --git a/README.md b/README.md index d9e8a98e..6945118b 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ The parser covers the most common ZPL commands. Anything it doesn't recognize is | `G` | Toggle grid | | `S` | Toggle snap | | `R` | Rotate view (0° → 90° → 180° → 270°) | +| `Page Up` / `Page Down` | Previous / Next page | | Middle mouse / Space+drag | Pan canvas | | Scroll | Pan canvas | | Ctrl+Scroll | Zoom | diff --git a/src/components/Canvas/LabelCanvas.tsx b/src/components/Canvas/LabelCanvas.tsx index d98b0607..ce766ec7 100644 --- a/src/components/Canvas/LabelCanvas.tsx +++ b/src/components/Canvas/LabelCanvas.tsx @@ -24,6 +24,7 @@ import { useColorScheme } from "../../lib/useColorScheme"; import { useCanvasPanZoom } from "./hooks/useCanvasPanZoom"; import { useCanvasLasso } from "./hooks/useCanvasLasso"; import { useKonvaTransformer } from "./hooks/useKonvaTransformer"; +import { PaginationControl } from "./PaginationControl"; import { axisReversal, inverseRotateDelta, @@ -441,6 +442,8 @@ export function LabelCanvas({ onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp} > + + {/* Bottom-right controls: view options + zoom */}
+ + Page {currentPageIndex + 1} / {pageCount} + + +
+ + +
+ ); +} diff --git a/src/hooks/useGlobalShortcuts.ts b/src/hooks/useGlobalShortcuts.ts index b50f0250..877fdf42 100644 --- a/src/hooks/useGlobalShortcuts.ts +++ b/src/hooks/useGlobalShortcuts.ts @@ -8,6 +8,7 @@ export function useGlobalShortcuts() { const pasteObjects = useLabelStore((s) => s.pasteObjects); const selectObjects = useLabelStore((s) => s.selectObjects); const setCanvasSettings = useLabelStore((s) => s.setCanvasSettings); + const setCurrentPage = useLabelStore((s) => s.setCurrentPage); const { undo, redo } = useHistory(); useEffect(() => { @@ -57,8 +58,18 @@ export function useGlobalShortcuts() { const current = useLabelStore.getState().canvasSettings.viewRotation; setCanvasSettings({ viewRotation: nextRotation(current) }); } + if (e.code === "PageUp") { + e.preventDefault(); + const { currentPageIndex } = useLabelStore.getState(); + if (currentPageIndex > 0) setCurrentPage(currentPageIndex - 1); + } + if (e.code === "PageDown") { + e.preventDefault(); + const { currentPageIndex, pages } = useLabelStore.getState(); + if (currentPageIndex < pages.length - 1) setCurrentPage(currentPageIndex + 1); + } }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [undo, redo, duplicateSelectedObjects, copySelectedObjects, pasteObjects, selectObjects, setCanvasSettings]); + }, [undo, redo, duplicateSelectedObjects, copySelectedObjects, pasteObjects, selectObjects, setCanvasSettings, setCurrentPage]); } From ea61b4e4265aaa7cb1ca423ef939a5baff936dfe Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 3 May 2026 17:27:20 +0200 Subject: [PATCH 3/9] feat(zpl): export and display all pages as concatenated ZPL blocks Adds generateMultiPageZPL() which emits one ^XA...^XZ block per page. Download and the live ZPL output panel now include every page; the Labelary preview and Print path stay single-page (current page only) since Labelary renders one image at a time. --- src/components/Output/ZPLOutput.tsx | 9 +++++---- src/hooks/useZplImportExport.ts | 9 ++++++--- src/lib/zplGenerator.test.ts | 30 ++++++++++++++++++++++++++++- src/lib/zplGenerator.ts | 9 +++++++++ 4 files changed, 49 insertions(+), 8 deletions(-) diff --git a/src/components/Output/ZPLOutput.tsx b/src/components/Output/ZPLOutput.tsx index e1095667..80adf041 100644 --- a/src/components/Output/ZPLOutput.tsx +++ b/src/components/Output/ZPLOutput.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { CheckIcon, ClipboardDocumentIcon, ChevronDownIcon, ChevronUpIcon, EyeIcon } from '@heroicons/react/16/solid'; -import { useLabelStore, useCurrentObjects } from '../../store/labelStore'; -import { generateZPL } from '../../lib/zplGenerator'; +import { useLabelStore } from '../../store/labelStore'; +import { generateMultiPageZPL } from '../../lib/zplGenerator'; import { useT } from '../../lib/useT'; import { LabelPreviewModal } from './LabelPreview'; @@ -14,11 +14,12 @@ interface Props { export function ZPLOutput({ collapsed, onCollapse, onExpand }: Props) { const t = useT(); const label = useLabelStore((s) => s.label); - const objects = useCurrentObjects(); + const pages = useLabelStore((s) => s.pages); const [copied, setCopied] = useState(false); const [showPreview, setShowPreview] = useState(false); - const zpl = objects.length > 0 ? generateZPL(label, objects) : ''; + const hasObjects = pages.some((p) => p.objects.length > 0); + const zpl = hasObjects ? generateMultiPageZPL(label, pages) : ''; const handleCopy = () => { if (!zpl) return; diff --git a/src/hooks/useZplImportExport.ts b/src/hooks/useZplImportExport.ts index cfc24ec5..df7fdb2f 100644 --- a/src/hooks/useZplImportExport.ts +++ b/src/hooks/useZplImportExport.ts @@ -1,22 +1,25 @@ import { useState } from "react"; import { useLabelStore, useCurrentObjects } from "../store/labelStore"; -import { generateZPL } from "../lib/zplGenerator"; +import { generateMultiPageZPL } from "../lib/zplGenerator"; import { printLabel } from "../lib/printPreview"; import { triggerDownload } from "../lib/triggerDownload"; import { labelaryErrorMessage } from "../lib/labelary"; export function useZplImportExport() { const label = useLabelStore((s) => s.label); + const pages = useLabelStore((s) => s.pages); const objects = useCurrentObjects(); const [showZplImport, setShowZplImport] = useState(false); const [showZebraPrint, setShowZebraPrint] = useState(false); const [printError, setPrintError] = useState(null); const handleDownload = () => { - const zpl = generateZPL(label, objects); + const zpl = generateMultiPageZPL(label, pages); triggerDownload(new Blob([zpl], { type: "text/plain" }), "label.zpl"); }; + // Print previews via Labelary, which renders one image at a time. We send + // only the current page so the preview matches what the user sees. const handlePrint = async () => { try { await printLabel(label, objects); @@ -32,7 +35,7 @@ export function useZplImportExport() { showZebraPrint, openZebraPrint: () => setShowZebraPrint(true), closeZebraPrint: () => setShowZebraPrint(false), - currentZpl: () => generateZPL(label, objects), + currentZpl: () => generateMultiPageZPL(label, pages), printError, dismissPrintError: () => setPrintError(null), handleDownload, diff --git a/src/lib/zplGenerator.test.ts b/src/lib/zplGenerator.test.ts index 4a7aa34d..9a703355 100644 --- a/src/lib/zplGenerator.test.ts +++ b/src/lib/zplGenerator.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { generateZPL } from './zplGenerator'; +import { generateZPL, generateMultiPageZPL } from './zplGenerator'; import { parseZPL } from './zplParser'; import type { LabelConfig } from '../types/ObjectType'; import { defined, props } from '../test/helpers'; @@ -89,6 +89,34 @@ describe('generateZPL — code128 object', () => { }); }); +describe('generateMultiPageZPL', () => { + it('emits one ^XA…^XZ block per page', () => { + const zpl = generateMultiPageZPL(BASE_LABEL, [{ objects: [] }, { objects: [] }]); + const matches = zpl.match(/\^XA/g) ?? []; + expect(matches.length).toBe(2); + expect(zpl.match(/\^XZ/g)?.length).toBe(2); + }); + + it('returns a single ^XA…^XZ block for a single page', () => { + const zpl = generateMultiPageZPL(BASE_LABEL, [{ objects: [] }]); + expect(zpl.match(/\^XA/g)?.length).toBe(1); + expect(zpl.startsWith('^XA')).toBe(true); + expect(zpl.endsWith('^XZ')).toBe(true); + }); + + it('returns an empty string when given an empty page list', () => { + expect(generateMultiPageZPL(BASE_LABEL, [])).toBe(''); + }); + + it('preserves per-page objects', () => { + const { objects: page1 } = parseZPL('^XA^FO10,20^A0N,30,0^FDOne^FS^XZ', 8); + const { objects: page2 } = parseZPL('^XA^FO50,60^A0N,30,0^FDTwo^FS^XZ', 8); + const zpl = generateMultiPageZPL(BASE_LABEL, [{ objects: page1 }, { objects: page2 }]); + expect(zpl).toContain('^FDOne^FS'); + expect(zpl).toContain('^FDTwo^FS'); + }); +}); + describe('generateZPL — parse/generate roundtrip', () => { it('preserves object count through a roundtrip', () => { const original = parseZPL('^XA^FO10,20^A0N,30,0^FDHello^FS^FO10,60^GB200,5,5^FS^XZ', 8); diff --git a/src/lib/zplGenerator.ts b/src/lib/zplGenerator.ts index f0a1486e..d2109840 100644 --- a/src/lib/zplGenerator.ts +++ b/src/lib/zplGenerator.ts @@ -2,6 +2,15 @@ import { mmToDots } from './coordinates'; import { ObjectRegistry } from '../registry'; import type { LabelConfig } from '../types/ObjectType'; import type { LabelObject } from '../registry'; +import type { Page } from '../store/labelStore'; + +/** + * Concatenates `generateZPL` output for every page. Each page becomes its own + * `^XA...^XZ` block; printers process the blocks as separate labels. + */ +export function generateMultiPageZPL(label: LabelConfig, pages: Page[]): string { + return pages.map((p) => generateZPL(label, p.objects)).join('\n'); +} export function generateZPL(label: LabelConfig, objects: LabelObject[]): string { const widthDots = mmToDots(label.widthMm, label.dpmm); From 6697a63e5c681ceaeddd23c408683022f33a0088 Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 3 May 2026 17:29:37 +0200 Subject: [PATCH 4/9] feat(zpl): import multi-label ZPL into pages Splits incoming ZPL on ^XA boundaries and parses each block as its own page. The first block's dimensions become the document's; differing dimensions in later blocks are flagged in the import report. importZplText now returns { labelConfig, pages, report, notice } instead of a flat objects array. Adds tests covering single, multi, mixed-dimension, and malformed input. --- src/components/Output/ZplImportModal.tsx | 16 ++-- src/lib/zplImportService.test.ts | 81 +++++++++++++++++++++ src/lib/zplImportService.ts | 93 +++++++++++++++++++++--- 3 files changed, 173 insertions(+), 17 deletions(-) create mode 100644 src/lib/zplImportService.test.ts diff --git a/src/components/Output/ZplImportModal.tsx b/src/components/Output/ZplImportModal.tsx index 45cafaf7..1dad4d19 100644 --- a/src/components/Output/ZplImportModal.tsx +++ b/src/components/Output/ZplImportModal.tsx @@ -26,15 +26,16 @@ export function ZplImportModal({ onClose }: Props) { return; } - const { labelConfig, objects: parsedObjects, report } = importZplText(zpl, label.dpmm); + const { labelConfig, pages, report } = importZplText(zpl, label.dpmm); + const totalObjects = pages.reduce((s, p) => s + p.objects.length, 0); - if (parsedObjects.length === 0 && Object.keys(labelConfig).length === 0) { + if (totalObjects === 0 && Object.keys(labelConfig).length === 0) { setError('No supported objects found in the ZPL code.'); return; } - loadDesign({ ...label, ...labelConfig }, [{ objects: parsedObjects }]); - setResult({ objectCount: parsedObjects.length, report }); + loadDesign({ ...label, ...labelConfig }, pages); + setResult({ objectCount: totalObjects, report }); }; const handleFileSelect = async (e: React.ChangeEvent) => { @@ -56,9 +57,10 @@ export function ZplImportModal({ onClose }: Props) { return; } - const { labelConfig, objects: parsedObjects, report } = importZplText(text, label.dpmm); - loadDesign({ ...label, ...labelConfig }, [{ objects: parsedObjects }]); - setResult({ objectCount: parsedObjects.length, report }); + const { labelConfig, pages, report } = importZplText(text, label.dpmm); + const totalObjects = pages.reduce((s, p) => s + p.objects.length, 0); + loadDesign({ ...label, ...labelConfig }, pages); + setResult({ objectCount: totalObjects, report }); }; const handleCopy = () => { diff --git a/src/lib/zplImportService.test.ts b/src/lib/zplImportService.test.ts new file mode 100644 index 00000000..e50c72c2 --- /dev/null +++ b/src/lib/zplImportService.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest'; +import { importZplText } from './zplImportService'; + +describe('importZplText — single label', () => { + it('returns one page with the parsed objects', () => { + const zpl = '^XA^FO10,20^A0N,30,0^FDHello^FS^XZ'; + const result = importZplText(zpl, 8); + expect(result.pages).toHaveLength(1); + expect(result.pages[0]?.objects).toHaveLength(1); + }); + + it('reports a single-page notice', () => { + const zpl = '^XA^FO10,20^A0N,30,0^FDHello^FS^XZ'; + const result = importZplText(zpl, 8); + expect(result.notice).toContain('1 object'); + expect(result.notice).not.toContain('pages'); + }); +}); + +describe('importZplText — multi-label', () => { + it('splits into one page per ^XA...^XZ block', () => { + const zpl = [ + '^XA^FO10,20^A0N,30,0^FDOne^FS^XZ', + '^XA^FO50,60^A0N,30,0^FDTwo^FS^XZ', + '^XA^FO80,90^A0N,30,0^FDThree^FS^XZ', + ].join('\n'); + const result = importZplText(zpl, 8); + expect(result.pages).toHaveLength(3); + expect(result.pages[0]?.objects).toHaveLength(1); + expect(result.pages[1]?.objects).toHaveLength(1); + expect(result.pages[2]?.objects).toHaveLength(1); + }); + + it('mentions the page count in the notice', () => { + const zpl = '^XA^FDOne^FS^XZ\n^XA^FDTwo^FS^XZ'; + const result = importZplText(zpl, 8); + expect(result.notice).toContain('across 2 pages'); + }); + + it('uses the first block\'s label dimensions', () => { + const zpl = [ + '^XA^PW800^LL400^FDOne^FS^XZ', + '^XA^PW400^LL200^FDTwo^FS^XZ', + ].join('\n'); + const result = importZplText(zpl, 8); + expect(result.labelConfig.widthMm).toBe(100); // 800 dots / 8 dpmm + expect(result.labelConfig.heightMm).toBe(50); + }); + + it('flags differing dimensions in the notice', () => { + const zpl = [ + '^XA^PW800^LL400^FDOne^FS^XZ', + '^XA^PW400^LL200^FDTwo^FS^XZ', + ].join('\n'); + const result = importZplText(zpl, 8); + expect(result.notice).toContain('different dimensions'); + }); + + it('does not flag dimensions when they match', () => { + const zpl = [ + '^XA^PW800^LL400^FDOne^FS^XZ', + '^XA^PW800^LL400^FDTwo^FS^XZ', + ].join('\n'); + const result = importZplText(zpl, 8); + expect(result.notice).not.toContain('different dimensions'); + }); + + it('discards content before the first ^XA', () => { + const zpl = 'garbage text before\n^XA^FDOne^FS^XZ'; + const result = importZplText(zpl, 8); + expect(result.pages).toHaveLength(1); + }); +}); + +describe('importZplText — empty / malformed', () => { + it('returns no pages when no ^XA is present', () => { + const result = importZplText('not zpl at all', 8); + expect(result.pages).toHaveLength(0); + expect(result.notice).toContain('No labels found'); + }); +}); diff --git a/src/lib/zplImportService.ts b/src/lib/zplImportService.ts index 00b30e97..85f3cbc8 100644 --- a/src/lib/zplImportService.ts +++ b/src/lib/zplImportService.ts @@ -4,24 +4,97 @@ import type { LabelObject } from "../registry"; export interface ZplImportResult { labelConfig: Partial; - objects: LabelObject[]; + pages: { objects: LabelObject[] }[]; report: ImportReport; notice: string; } +/** + * Splits a ZPL stream into one block per `^XA...^XZ` document. Anything before + * the first `^XA` is discarded. + */ +function splitIntoLabelBlocks(zpl: string): string[] { + const parts = zpl.split('^XA').slice(1); + return parts.map((p) => '^XA' + p); +} + export function importZplText(zpl: string, dpmm: number): ZplImportResult { - const { labelConfig, objects, importReport } = parseZPL(zpl, dpmm); + const blocks = splitIntoLabelBlocks(zpl); + + if (blocks.length === 0) { + return { + labelConfig: {}, + pages: [], + report: { partial: [], browserLimit: [], unknown: [] }, + notice: 'No labels found in the ZPL code.', + }; + } + + let labelConfig: Partial = {}; + const pages: { objects: LabelObject[] }[] = []; + const partial: string[] = []; + const browserLimit: string[] = []; + const unknown: string[] = []; + let dimensionsDiffered = false; + + blocks.forEach((block, i) => { + const result = parseZPL(block, dpmm); + pages.push({ objects: result.objects }); + if (i === 0) { + labelConfig = result.labelConfig; + } else { + const cfg = result.labelConfig; + if ( + (cfg.widthMm !== undefined && cfg.widthMm !== labelConfig.widthMm) || + (cfg.heightMm !== undefined && cfg.heightMm !== labelConfig.heightMm) || + (cfg.dpmm !== undefined && cfg.dpmm !== labelConfig.dpmm) + ) { + dimensionsDiffered = true; + } + } + partial.push(...result.importReport.partial); + browserLimit.push(...result.importReport.browserLimit); + unknown.push(...result.importReport.unknown); + }); + + const report: ImportReport = { + partial: [...new Set(partial)], + browserLimit: [...new Set(browserLimit)], + unknown: [...new Set(unknown)], + }; + + const objectCount = pages.reduce((s, p) => s + p.objects.length, 0); + const notice = buildNotice(objectCount, pages.length, report, dimensionsDiffered); + + return { labelConfig, pages, report, notice }; +} + +function buildNotice( + objectCount: number, + pageCount: number, + report: ImportReport, + dimensionsDiffered: boolean, +): string { + const parts: string[] = []; + + const objectsText = `${objectCount} object${objectCount !== 1 ? 's' : ''}`; + if (pageCount > 1) { + parts.push(`Editable reconstruction: ${objectsText} across ${pageCount} pages imported.`); + } else { + parts.push(`Editable reconstruction: ${objectsText} imported.`); + } + + if (dimensionsDiffered) { + parts.push(`Pages have different dimensions; using the first page's size.`); + } - const parts: string[] = [ - `Editable reconstruction — ${objects.length} object${objects.length !== 1 ? "s" : ""} imported.`, - ]; - if (importReport.partial.length > 0) { - parts.push(`Font face not preserved (${importReport.partial.join(", ")}).`); + if (report.partial.length > 0) { + parts.push(`Font face not preserved (${report.partial.join(', ')}).`); } - const skippedCount = importReport.browserLimit.length + importReport.unknown.length; + const skippedCount = report.browserLimit.length + report.unknown.length; if (skippedCount > 0) { - parts.push(`${skippedCount} command${skippedCount !== 1 ? "s" : ""} skipped.`); + parts.push(`${skippedCount} command${skippedCount !== 1 ? 's' : ''} skipped.`); } - return { labelConfig, objects, report: importReport, notice: parts.join(" ") }; + return parts.join(' '); } From ecd5be3ec19f2f7e8c510812f1c737fd87f9f543 Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 3 May 2026 17:31:09 +0200 Subject: [PATCH 5/9] feat(design-file): save/load multi-page JSON with legacy fallback The canonical .json shape becomes { label, pages: [{ objects }, ...] }. parseDesignFile auto-migrates legacy { label, objects } files into a single-page document so older saves keep loading. --- src/hooks/useDesignFileActions.ts | 8 +-- src/lib/designFile.test.ts | 88 +++++++++++++++++++++++++++++++ src/lib/designFile.ts | 47 +++++++++++++---- 3 files changed, 128 insertions(+), 15 deletions(-) create mode 100644 src/lib/designFile.test.ts diff --git a/src/hooks/useDesignFileActions.ts b/src/hooks/useDesignFileActions.ts index dc018677..dedba464 100644 --- a/src/hooks/useDesignFileActions.ts +++ b/src/hooks/useDesignFileActions.ts @@ -1,12 +1,12 @@ import { useRef, useState } from "react"; -import { useLabelStore, useCurrentObjects } from "../store/labelStore"; +import { useLabelStore } from "../store/labelStore"; import { triggerDownload } from "../lib/triggerDownload"; import { parseDesignFile, serializeDesign, designFileErrors } from "../lib/designFile"; import { readFileAsText } from "../lib/readFile"; export function useDesignFileActions() { const label = useLabelStore((s) => s.label); - const objects = useCurrentObjects(); + const pages = useLabelStore((s) => s.pages); const loadDesign = useLabelStore((s) => s.loadDesign); const [loadError, setLoadError] = useState(null); const loadInputRef = useRef(null); @@ -16,7 +16,7 @@ export function useDesignFileActions() { }; const handleSave = () => { - const data = serializeDesign(label, objects); + const data = serializeDesign(label, pages); triggerDownload(new Blob([data], { type: "application/json" }), "label.json"); }; @@ -40,7 +40,7 @@ export function useDesignFileActions() { } setLoadError(null); - loadDesign(result.value.label, [{ objects: result.value.objects }]); + loadDesign(result.value.label, result.value.pages); }; return { diff --git a/src/lib/designFile.test.ts b/src/lib/designFile.test.ts new file mode 100644 index 00000000..6eb44a55 --- /dev/null +++ b/src/lib/designFile.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest'; +import { parseDesignFile, serializeDesign } from './designFile'; +import type { LabelObject } from '../registry'; + +const SAMPLE_OBJECTS: LabelObject[] = [ + { + id: 'obj-1', + type: 'box', + x: 10, + y: 10, + rotation: 0, + props: { width: 50, height: 30, thickness: 2, filled: false, color: 'B', rounding: 0 }, + }, +]; + +describe('serializeDesign', () => { + it('emits the new pages-shaped JSON', () => { + const json = serializeDesign( + { widthMm: 100, heightMm: 60, dpmm: 8 }, + [{ objects: SAMPLE_OBJECTS }], + ); + const parsed = JSON.parse(json) as { pages: { objects: LabelObject[] }[] }; + expect(parsed.pages).toHaveLength(1); + expect(parsed.pages[0]?.objects).toHaveLength(1); + }); + + it('serializes multiple pages', () => { + const json = serializeDesign( + { widthMm: 100, heightMm: 60, dpmm: 8 }, + [{ objects: SAMPLE_OBJECTS }, { objects: [] }], + ); + const parsed = JSON.parse(json) as { pages: unknown[] }; + expect(parsed.pages).toHaveLength(2); + }); +}); + +describe('parseDesignFile', () => { + it('parses the new pages-shaped JSON', () => { + const json = serializeDesign( + { widthMm: 100, heightMm: 60, dpmm: 8 }, + [{ objects: SAMPLE_OBJECTS }], + ); + const result = parseDesignFile(json); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.pages).toHaveLength(1); + expect(result.value.pages[0]?.objects).toHaveLength(1); + }); + + it('migrates legacy { label, objects } shape into a single page', () => { + const legacyJson = JSON.stringify({ + label: { widthMm: 100, heightMm: 60, dpmm: 8 }, + objects: SAMPLE_OBJECTS, + }); + const result = parseDesignFile(legacyJson); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.pages).toHaveLength(1); + expect(result.value.pages[0]?.objects).toHaveLength(1); + }); + + it('returns parse_error for invalid JSON', () => { + const result = parseDesignFile('not json {'); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toBe('parse_error'); + }); + + it('returns invalid_schema for JSON that matches no shape', () => { + const result = parseDesignFile('{"foo": "bar"}'); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toBe('invalid_schema'); + }); + + it('roundtrips through serialize/parse without loss', () => { + const json = serializeDesign( + { widthMm: 100, heightMm: 60, dpmm: 8 }, + [{ objects: SAMPLE_OBJECTS }, { objects: SAMPLE_OBJECTS }], + ); + const result = parseDesignFile(json); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.pages).toHaveLength(2); + expect(result.value.pages[0]?.objects).toHaveLength(1); + expect(result.value.pages[1]?.objects).toHaveLength(1); + }); +}); diff --git a/src/lib/designFile.ts b/src/lib/designFile.ts index ea2be78a..6272e204 100644 --- a/src/lib/designFile.ts +++ b/src/lib/designFile.ts @@ -4,11 +4,23 @@ import type { LabelObject } from "../registry"; import { ok, err, type Result } from "./result"; export type DesignFileError = "parse_error" | "invalid_schema"; -export interface DesignFile { label: LabelConfig; objects: LabelObject[] } +export interface DesignFilePage { objects: LabelObject[] } +export interface DesignFile { label: LabelConfig; pages: DesignFilePage[] } + +const labelObjectSchema = labelObjectBaseSchema.extend({ + props: z.record(z.string(), z.unknown()), +}); + +const pageSchema = z.object({ objects: z.array(labelObjectSchema) }); const designFileSchema = z.object({ label: labelConfigSchema, - objects: z.array(labelObjectBaseSchema.extend({ props: z.record(z.string(), z.unknown()) })), + pages: z.array(pageSchema), +}); + +const legacyDesignFileSchema = z.object({ + label: labelConfigSchema, + objects: z.array(labelObjectSchema), }); export function parseDesignFile(text: string): Result { @@ -18,17 +30,30 @@ export function parseDesignFile(text: string): Result = { From cc2ff1b18032b2eb61affbad41ff604a3f8ac9a4 Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 3 May 2026 17:31:58 +0200 Subject: [PATCH 6/9] docs(readme): document multi-page support Replaces the multi-label limitation with a dedicated Pages section in the usage flow and notes that the Labelary preview shows only the current page even though the export contains every page. --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6945118b..07295ebb 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,14 @@ File menu → **Import ZPL**: paste ZPL code directly, or open a `.zpl` file. The parser covers the most common ZPL commands. Anything it doesn't recognize is skipped and listed in the import report. +Multi-label files (several `^XA...^XZ` blocks in one document) are split into separate pages on import; if the blocks have differing dimensions, the first one wins and the import report flags it. + +### Multiple labels (pages) + +Use the page control at the bottom-center of the canvas to add, switch, and remove pages. Each page is a separate label that shares the same dimensions and dpmm; export and copy emit one `^XA...^XZ` block per page, in order. + +Page Up / Page Down navigates between pages. + ### Keyboard shortcuts | Shortcut | Action | @@ -102,7 +110,7 @@ Use `.json` (File → Save Design) to save your work. It preserves every object - The canvas is a design preview, not a pixel-perfect simulation: fonts and exact rendering may differ from what the printer produces. Shapes, spacing, and positions should match. For an accurate render, use the **Preview** in the bottom-right panel (powered by Labelary). - Label preview requires a connection to `api.labelary.com`. - The Labelary preview doesn't render every ZPL feature. Some less common elements (e.g. Codablock F barcodes) may be missing or wrong in the preview even when the actual print is fine. -- No support for multi-label documents (multiple labels in one file). +- The Labelary preview shows only the current page; the printed/exported ZPL still contains every page. --- From 46c858ebfe8498fe022670212e695ed1a2a04a5b Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 3 May 2026 17:49:34 +0200 Subject: [PATCH 7/9] refactor(canvas): hide pagination control on single-page documents The bottom-center page widget now appears only with 2+ pages so the default single-label canvas stays uncluttered. Adding a page is moved to a new File menu entry so the feature stays discoverable. README updated to match the new entry point. --- README.md | 6 +----- src/components/AppShell.tsx | 5 +++++ src/components/Canvas/PaginationControl.tsx | 3 +++ 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 07295ebb..a6e95562 100644 --- a/README.md +++ b/README.md @@ -64,13 +64,9 @@ File menu → **Import ZPL**: paste ZPL code directly, or open a `.zpl` file. The parser covers the most common ZPL commands. Anything it doesn't recognize is skipped and listed in the import report. -Multi-label files (several `^XA...^XZ` blocks in one document) are split into separate pages on import; if the blocks have differing dimensions, the first one wins and the import report flags it. - ### Multiple labels (pages) -Use the page control at the bottom-center of the canvas to add, switch, and remove pages. Each page is a separate label that shares the same dimensions and dpmm; export and copy emit one `^XA...^XZ` block per page, in order. - -Page Up / Page Down navigates between pages. +File menu → **Add page** creates a new page. With multiple pages, the control at the bottom-center of the canvas switches between them and removes them. All pages share the same dimensions; export and import handle each page as a separate label. ### Keyboard shortcuts diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index ff66969e..b5fc2841 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -20,6 +20,7 @@ import { ArrowUpTrayIcon, ArrowDownTrayIcon, DocumentPlusIcon, + DocumentDuplicateIcon, FolderOpenIcon, DocumentArrowDownIcon, PrinterIcon, @@ -42,6 +43,7 @@ export function AppShell() { const label = useLabelStore((s) => s.label); const objects = useCurrentObjects(); const selectObject = useLabelStore((s) => s.selectObject); + const addPage = useLabelStore((s) => s.addPage); const locale = useLabelStore((s) => s.locale); const setLocale = useLabelStore((s) => s.setLocale); const { undo, redo, pastStates, futureStates } = useHistory(); @@ -147,6 +149,9 @@ export function AppShell() { {t.app.newDesign} + + Add page + {t.app.importZpl} diff --git a/src/components/Canvas/PaginationControl.tsx b/src/components/Canvas/PaginationControl.tsx index 61479a39..622bb625 100644 --- a/src/components/Canvas/PaginationControl.tsx +++ b/src/components/Canvas/PaginationControl.tsx @@ -7,6 +7,9 @@ export function PaginationControl() { const addPage = useLabelStore((s) => s.addPage); const removePage = useLabelStore((s) => s.removePage); + // Hide entirely on single-page documents; "Add page" lives in the File menu. + if (pageCount <= 1) return null; + const canPrev = currentPageIndex > 0; const canNext = currentPageIndex < pageCount - 1; const canRemove = pageCount > 1; From 621c6ced723290b153c7f1083186dbc2f67ec297 Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 3 May 2026 22:15:48 +0200 Subject: [PATCH 8/9] fix(zpl-import): split on case-insensitive ^XA ZPL commands are case-insensitive per spec; the existing splitter matched only uppercase ^XA, so lowercase or mixed-case input fell through and produced no pages. Switches to a regex split with capture group so the original delimiter is preserved when re-attached. --- src/lib/zplImportService.test.ts | 6 ++++++ src/lib/zplImportService.ts | 11 ++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/lib/zplImportService.test.ts b/src/lib/zplImportService.test.ts index e50c72c2..c455b802 100644 --- a/src/lib/zplImportService.test.ts +++ b/src/lib/zplImportService.test.ts @@ -70,6 +70,12 @@ describe('importZplText — multi-label', () => { const result = importZplText(zpl, 8); expect(result.pages).toHaveLength(1); }); + + it('handles mixed-case ^XA delimiters', () => { + const zpl = '^xa^FDone^FS^xz\n^XA^FDtwo^FS^XZ'; + const result = importZplText(zpl, 8); + expect(result.pages).toHaveLength(2); + }); }); describe('importZplText — empty / malformed', () => { diff --git a/src/lib/zplImportService.ts b/src/lib/zplImportService.ts index 85f3cbc8..774a61ca 100644 --- a/src/lib/zplImportService.ts +++ b/src/lib/zplImportService.ts @@ -11,11 +11,16 @@ export interface ZplImportResult { /** * Splits a ZPL stream into one block per `^XA...^XZ` document. Anything before - * the first `^XA` is discarded. + * the first `^XA` is discarded. ZPL commands are case-insensitive per spec. */ function splitIntoLabelBlocks(zpl: string): string[] { - const parts = zpl.split('^XA').slice(1); - return parts.map((p) => '^XA' + p); + // Capture group preserves the matched delimiter so mixed-case (^xa) survives. + const parts = zpl.split(/(\^XA)/i).slice(1); + const blocks: string[] = []; + for (let i = 0; i < parts.length; i += 2) { + blocks.push(parts[i] + (parts[i + 1] ?? '')); + } + return blocks; } export function importZplText(zpl: string, dpmm: number): ZplImportResult { From f2f64be4b93eddfa235859d806c9b603bdc5c925 Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 3 May 2026 23:29:39 +0200 Subject: [PATCH 9/9] fix(app): document-wide hasObjects + i18n for "Add page" Address two PR review findings: 1. hasObjects was computed from the current page's objects only, so Export ZPL / Save Design / Send to Zebra were incorrectly disabled when the current page was empty even if other pages had content. Switched to checking pages.some(p => p.objects.length > 0). 2. The "Add page" menu entry was hardcoded English. Added the t.app.addPage translation key across all 32 locales. --- src/components/AppShell.tsx | 8 ++++---- src/locales/ar.ts | 1 + src/locales/bg.ts | 1 + src/locales/cs.ts | 1 + src/locales/da.ts | 1 + src/locales/de.ts | 1 + src/locales/el.ts | 1 + src/locales/en.ts | 1 + src/locales/es.ts | 1 + src/locales/et.ts | 1 + src/locales/fa.ts | 1 + src/locales/fi.ts | 1 + src/locales/fr.ts | 1 + src/locales/he.ts | 1 + src/locales/hr.ts | 1 + src/locales/hu.ts | 1 + src/locales/it.ts | 1 + src/locales/ja.ts | 1 + src/locales/ko.ts | 1 + src/locales/lt.ts | 1 + src/locales/lv.ts | 1 + src/locales/nl.ts | 1 + src/locales/no.ts | 1 + src/locales/pl.ts | 1 + src/locales/pt.ts | 1 + src/locales/ro.ts | 1 + src/locales/sk.ts | 1 + src/locales/sl.ts | 1 + src/locales/sr.ts | 1 + src/locales/sv.ts | 1 + src/locales/tr.ts | 1 + src/locales/zh-hans.ts | 1 + src/locales/zh-hant.ts | 1 + 33 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index b5fc2841..a21e8cfe 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -28,7 +28,7 @@ import { GlobeAltIcon, XMarkIcon, } from "@heroicons/react/16/solid"; -import { useLabelStore, useHistory, useCurrentObjects } from "../store/labelStore"; +import { useLabelStore, useHistory } from "../store/labelStore"; import { localeNames } from "../locales"; import type { LocaleCode } from "../locales"; import { mmToUnit } from "../lib/units"; @@ -41,7 +41,7 @@ import { useOutputPanel, OUTPUT_DEFAULT_H } from "../hooks/useOutputPanel"; export function AppShell() { const t = useT(); const label = useLabelStore((s) => s.label); - const objects = useCurrentObjects(); + const pages = useLabelStore((s) => s.pages); const selectObject = useLabelStore((s) => s.selectObject); const addPage = useLabelStore((s) => s.addPage); const locale = useLabelStore((s) => s.locale); @@ -54,7 +54,7 @@ export function AppShell() { const canUndo = pastStates.length > 0; const canRedo = futureStates.length > 0; - const hasObjects = objects.length > 0; + const hasObjects = pages.some((p) => p.objects.length > 0); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), @@ -150,7 +150,7 @@ export function AppShell() { {t.app.newDesign} - Add page + {t.app.addPage} diff --git a/src/locales/ar.ts b/src/locales/ar.ts index 0a4c0465..badfa140 100644 --- a/src/locales/ar.ts +++ b/src/locales/ar.ts @@ -75,6 +75,7 @@ const ar = { importZpl: 'استيراد ZPL', exportZpl: 'تصدير ZPL', newDesign: 'تصميم جديد', + addPage: 'إضافة صفحة', openDesign: 'فتح تصميم', saveDesign: 'حفظ تصميم', print: 'طباعة كصورة (المتصفح)', diff --git a/src/locales/bg.ts b/src/locales/bg.ts index 1a0e0e5c..5617cd9d 100644 --- a/src/locales/bg.ts +++ b/src/locales/bg.ts @@ -75,6 +75,7 @@ const bg = { importZpl: 'Import ZPL', exportZpl: 'Export ZPL', newDesign: 'Нов дизайн', + addPage: 'Добавяне на страница', openDesign: 'Отвори дизайн', saveDesign: 'Запази дизайн', print: 'Печат като изображение (браузър)', diff --git a/src/locales/cs.ts b/src/locales/cs.ts index 5718e0fc..b993ccfa 100644 --- a/src/locales/cs.ts +++ b/src/locales/cs.ts @@ -75,6 +75,7 @@ const cs = { importZpl: 'Import ZPL', exportZpl: 'Export ZPL', newDesign: 'Nový návrh', + addPage: 'Přidat stránku', openDesign: 'Otevřít návrh', saveDesign: 'Uložit návrh', print: 'Tisk jako obrázek (prohlížeč)', diff --git a/src/locales/da.ts b/src/locales/da.ts index 0e412e26..04df6379 100644 --- a/src/locales/da.ts +++ b/src/locales/da.ts @@ -75,6 +75,7 @@ const da = { importZpl: 'Import ZPL', exportZpl: 'Export ZPL', newDesign: 'Nyt design', + addPage: 'Tilføj side', openDesign: 'Åbn design', saveDesign: 'Gem design', print: 'Udskriv som billede (browser)', diff --git a/src/locales/de.ts b/src/locales/de.ts index c394a293..9a6d3d3b 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -75,6 +75,7 @@ const de = { importZpl: 'Import ZPL', exportZpl: 'Export ZPL', newDesign: 'Neues Design', + addPage: 'Seite hinzufügen', openDesign: 'Design öffnen', saveDesign: 'Design speichern', print: 'Als Bild drucken (Browser)', diff --git a/src/locales/el.ts b/src/locales/el.ts index 3bfaefe4..f07a3982 100644 --- a/src/locales/el.ts +++ b/src/locales/el.ts @@ -75,6 +75,7 @@ const el = { importZpl: 'Import ZPL', exportZpl: 'Export ZPL', newDesign: 'Νέο σχέδιο', + addPage: 'Προσθήκη σελίδας', openDesign: 'Άνοιγμα σχεδίου', saveDesign: 'Αποθήκευση σχεδίου', print: 'Εκτύπωση ως εικόνα (πρόγραμμα περιήγησης)', diff --git a/src/locales/en.ts b/src/locales/en.ts index 45a6636b..8a6b062e 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -75,6 +75,7 @@ const en = { importZpl: 'Import ZPL', exportZpl: 'Export ZPL', newDesign: 'New design', + addPage: 'Add page', openDesign: 'Open design', saveDesign: 'Save design', print: 'Print as Image (browser)', diff --git a/src/locales/es.ts b/src/locales/es.ts index ae2fe188..a7caccee 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -75,6 +75,7 @@ const es = { importZpl: 'Import ZPL', exportZpl: 'Export ZPL', newDesign: 'Nuevo diseño', + addPage: 'Añadir página', openDesign: 'Abrir diseño', saveDesign: 'Guardar diseño', print: 'Imprimir como imagen (navegador)', diff --git a/src/locales/et.ts b/src/locales/et.ts index fb9f3f99..55bc7e81 100644 --- a/src/locales/et.ts +++ b/src/locales/et.ts @@ -75,6 +75,7 @@ const et = { importZpl: 'Import ZPL', exportZpl: 'Export ZPL', newDesign: 'Uus kujundus', + addPage: 'Lisa leht', openDesign: 'Ava kujundus', saveDesign: 'Salvesta kujundus', print: 'Prindi pildina (brauser)', diff --git a/src/locales/fa.ts b/src/locales/fa.ts index 6773b186..3756cb90 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -75,6 +75,7 @@ const fa = { importZpl: 'وارد کردن ZPL', exportZpl: 'خروجی ZPL', newDesign: 'طرح جدید', + addPage: 'افزودن صفحه', openDesign: 'باز کردن طرح', saveDesign: 'ذخیره طرح', print: 'چاپ به عنوان تصویر (مرورگر)', diff --git a/src/locales/fi.ts b/src/locales/fi.ts index 636d33ff..2b797f9c 100644 --- a/src/locales/fi.ts +++ b/src/locales/fi.ts @@ -75,6 +75,7 @@ const fi = { importZpl: 'Import ZPL', exportZpl: 'Export ZPL', newDesign: 'Uusi rakenne', + addPage: 'Lisää sivu', openDesign: 'Avaa rakenne', saveDesign: 'Tallenna rakenne', print: 'Tulosta kuvana (selain)', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 18658d39..0a2a1aa8 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -75,6 +75,7 @@ const fr = { importZpl: 'Import ZPL', exportZpl: 'Export ZPL', newDesign: 'Nouveau design', + addPage: 'Ajouter une page', openDesign: 'Ouvrir le design', saveDesign: 'Enregistrer le design', print: 'Imprimer en image (navigateur)', diff --git a/src/locales/he.ts b/src/locales/he.ts index a635fefc..01fa5690 100644 --- a/src/locales/he.ts +++ b/src/locales/he.ts @@ -75,6 +75,7 @@ const he = { importZpl: 'ייבוא ZPL', exportZpl: 'ייצוא ZPL', newDesign: 'עיצוב חדש', + addPage: 'הוסף דף', openDesign: 'פתח עיצוב', saveDesign: 'שמור עיצוב', print: 'הדפסה כתמונה (דפדפן)', diff --git a/src/locales/hr.ts b/src/locales/hr.ts index d2c6067a..523bed75 100644 --- a/src/locales/hr.ts +++ b/src/locales/hr.ts @@ -75,6 +75,7 @@ const hr = { importZpl: 'Import ZPL', exportZpl: 'Export ZPL', newDesign: 'Novi dizajn', + addPage: 'Dodaj stranicu', openDesign: 'Otvori dizajn', saveDesign: 'Spremi dizajn', print: 'Ispis kao slika (preglednik)', diff --git a/src/locales/hu.ts b/src/locales/hu.ts index cf811ee8..ee37c2b9 100644 --- a/src/locales/hu.ts +++ b/src/locales/hu.ts @@ -75,6 +75,7 @@ const hu = { importZpl: 'Import ZPL', exportZpl: 'Export ZPL', newDesign: 'Új terv', + addPage: 'Oldal hozzáadása', openDesign: 'Terv megnyitása', saveDesign: 'Terv mentése', print: 'Nyomtatás képként (böngésző)', diff --git a/src/locales/it.ts b/src/locales/it.ts index 13b4f35f..e82bb9d1 100644 --- a/src/locales/it.ts +++ b/src/locales/it.ts @@ -75,6 +75,7 @@ const it = { importZpl: 'Import ZPL', exportZpl: 'Export ZPL', newDesign: 'Nuovo design', + addPage: 'Aggiungi pagina', openDesign: 'Apri design', saveDesign: 'Salva design', print: 'Stampa come immagine (browser)', diff --git a/src/locales/ja.ts b/src/locales/ja.ts index 8ef3dda1..680a8a62 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -75,6 +75,7 @@ const ja = { importZpl: 'ZPL インポート', exportZpl: 'ZPL エクスポート', newDesign: '新しいデザイン', + addPage: 'ページを追加', openDesign: 'デザインを開く', saveDesign: 'デザインを保存', print: '画像として印刷(ブラウザ)', diff --git a/src/locales/ko.ts b/src/locales/ko.ts index 2be85013..838d96bc 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -75,6 +75,7 @@ const ko = { importZpl: 'ZPL 가져오기', exportZpl: 'ZPL 내보내기', newDesign: '새 디자인', + addPage: '페이지 추가', openDesign: '디자인 열기', saveDesign: '디자인 저장', print: '이미지로 인쇄 (브라우저)', diff --git a/src/locales/lt.ts b/src/locales/lt.ts index f2fcea69..fa3ffec2 100644 --- a/src/locales/lt.ts +++ b/src/locales/lt.ts @@ -75,6 +75,7 @@ const lt = { importZpl: 'Import ZPL', exportZpl: 'Export ZPL', newDesign: 'Naujas dizainas', + addPage: 'Pridėti puslapį', openDesign: 'Atidaryti dizainą', saveDesign: 'Išsaugoti dizainą', print: 'Spausdinti kaip vaizdą (naršyklė)', diff --git a/src/locales/lv.ts b/src/locales/lv.ts index e2878f93..0ad3d3c2 100644 --- a/src/locales/lv.ts +++ b/src/locales/lv.ts @@ -75,6 +75,7 @@ const lv = { importZpl: 'Import ZPL', exportZpl: 'Export ZPL', newDesign: 'Jauns dizains', + addPage: 'Pievienot lapu', openDesign: 'Atvērt dizainu', saveDesign: 'Saglabāt dizainu', print: 'Drukāt kā attēlu (pārlūks)', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 4bb65877..f30fa473 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -75,6 +75,7 @@ const nl = { importZpl: 'Import ZPL', exportZpl: 'Export ZPL', newDesign: 'Nieuw ontwerp', + addPage: 'Pagina toevoegen', openDesign: 'Ontwerp openen', saveDesign: 'Ontwerp opslaan', print: 'Afdrukken als afbeelding (browser)', diff --git a/src/locales/no.ts b/src/locales/no.ts index 80a0f0ba..22ba9011 100644 --- a/src/locales/no.ts +++ b/src/locales/no.ts @@ -75,6 +75,7 @@ const no = { importZpl: 'Import ZPL', exportZpl: 'Export ZPL', newDesign: 'Nytt design', + addPage: 'Legg til side', openDesign: 'Åpne design', saveDesign: 'Lagre design', print: 'Skriv ut som bilde (nettleser)', diff --git a/src/locales/pl.ts b/src/locales/pl.ts index 0079482e..b08f47f9 100644 --- a/src/locales/pl.ts +++ b/src/locales/pl.ts @@ -75,6 +75,7 @@ const pl = { importZpl: 'Import ZPL', exportZpl: 'Export ZPL', newDesign: 'Nowy projekt', + addPage: 'Dodaj stronę', openDesign: 'Otwórz projekt', saveDesign: 'Zapisz projekt', print: 'Drukuj jako obraz (przeglądarka)', diff --git a/src/locales/pt.ts b/src/locales/pt.ts index f8b0d608..13d244ae 100644 --- a/src/locales/pt.ts +++ b/src/locales/pt.ts @@ -75,6 +75,7 @@ const pt = { importZpl: 'Import ZPL', exportZpl: 'Export ZPL', newDesign: 'Novo design', + addPage: 'Adicionar página', openDesign: 'Abrir design', saveDesign: 'Salvar design', print: 'Imprimir como imagem (navegador)', diff --git a/src/locales/ro.ts b/src/locales/ro.ts index 733e0701..3d8657c1 100644 --- a/src/locales/ro.ts +++ b/src/locales/ro.ts @@ -75,6 +75,7 @@ const ro = { importZpl: 'Import ZPL', exportZpl: 'Export ZPL', newDesign: 'Design nou', + addPage: 'Adaugă pagină', openDesign: 'Deschide design', saveDesign: 'Salvează design', print: 'Tipărire ca imagine (browser)', diff --git a/src/locales/sk.ts b/src/locales/sk.ts index bfdd5ad8..171baccf 100644 --- a/src/locales/sk.ts +++ b/src/locales/sk.ts @@ -75,6 +75,7 @@ const sk = { importZpl: 'Import ZPL', exportZpl: 'Export ZPL', newDesign: 'Nový návrh', + addPage: 'Pridať stránku', openDesign: 'Otvoriť návrh', saveDesign: 'Uložiť návrh', print: 'Tlač ako obrázok (prehliadač)', diff --git a/src/locales/sl.ts b/src/locales/sl.ts index 3f03df25..4dd0fb6d 100644 --- a/src/locales/sl.ts +++ b/src/locales/sl.ts @@ -75,6 +75,7 @@ const sl = { importZpl: 'Import ZPL', exportZpl: 'Export ZPL', newDesign: 'Nov dizajn', + addPage: 'Dodaj stran', openDesign: 'Odpri dizajn', saveDesign: 'Shrani dizajn', print: 'Natisni kot sliko (brskalnik)', diff --git a/src/locales/sr.ts b/src/locales/sr.ts index 36331852..16d58492 100644 --- a/src/locales/sr.ts +++ b/src/locales/sr.ts @@ -75,6 +75,7 @@ const sr = { importZpl: 'Import ZPL', exportZpl: 'Export ZPL', newDesign: 'Нови дизајн', + addPage: 'Додај страницу', openDesign: 'Отвори дизајн', saveDesign: 'Сачувај дизајн', print: 'Штампање као слика (прегледач)', diff --git a/src/locales/sv.ts b/src/locales/sv.ts index 0b8eb69d..98268ea5 100644 --- a/src/locales/sv.ts +++ b/src/locales/sv.ts @@ -75,6 +75,7 @@ const sv = { importZpl: 'Import ZPL', exportZpl: 'Export ZPL', newDesign: 'Nytt design', + addPage: 'Lägg till sida', openDesign: 'Öppna design', saveDesign: 'Spara design', print: 'Skriv ut som bild (webbläsare)', diff --git a/src/locales/tr.ts b/src/locales/tr.ts index 55bd5cdb..26ab530f 100644 --- a/src/locales/tr.ts +++ b/src/locales/tr.ts @@ -75,6 +75,7 @@ const tr = { importZpl: 'ZPL İçe Aktar', exportZpl: 'ZPL Dışa Aktar', newDesign: 'Yeni Tasarım', + addPage: 'Sayfa ekle', openDesign: 'Tasarım Aç', saveDesign: 'Tasarım Kaydet', print: 'Görüntü olarak yazdır (tarayıcı)', diff --git a/src/locales/zh-hans.ts b/src/locales/zh-hans.ts index 4dd5bc53..13a69d77 100644 --- a/src/locales/zh-hans.ts +++ b/src/locales/zh-hans.ts @@ -75,6 +75,7 @@ const zhHans = { importZpl: '导入 ZPL', exportZpl: '导出 ZPL', newDesign: '新建设计', + addPage: '添加页面', openDesign: '打开设计', saveDesign: '保存设计', print: '打印为图片(浏览器)', diff --git a/src/locales/zh-hant.ts b/src/locales/zh-hant.ts index 30a43808..80cf79ce 100644 --- a/src/locales/zh-hant.ts +++ b/src/locales/zh-hant.ts @@ -75,6 +75,7 @@ const zhHant = { importZpl: '匯入 ZPL', exportZpl: '匯出 ZPL', newDesign: '新增設計', + addPage: '新增頁面', openDesign: '開啟設計', saveDesign: '儲存設計', print: '列印為圖片(瀏覽器)',