Skip to content
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ 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.

### Multiple labels (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

| Shortcut | Action |
Expand All @@ -76,6 +80,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 |
Expand All @@ -101,7 +106,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.

---

Expand Down
9 changes: 7 additions & 2 deletions src/components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
ArrowUpTrayIcon,
ArrowDownTrayIcon,
DocumentPlusIcon,
DocumentDuplicateIcon,
FolderOpenIcon,
DocumentArrowDownIcon,
PrinterIcon,
Expand All @@ -40,8 +41,9 @@ 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 pages = useLabelStore((s) => s.pages);
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();
Expand All @@ -52,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 } }),
Expand Down Expand Up @@ -147,6 +149,9 @@ export function AppShell() {
<DropdownItem icon={DocumentPlusIcon} onClick={handleNew}>
{t.app.newDesign}
</DropdownItem>
<DropdownItem icon={DocumentDuplicateIcon} onClick={addPage}>
{t.app.addPage}
</DropdownItem>
Comment thread
u8array marked this conversation as resolved.
<DropdownSeparator />
<DropdownItem icon={ArrowUpTrayIcon} onClick={openZplImport}>
{t.app.importZpl}
Expand Down
17 changes: 12 additions & 5 deletions src/components/Canvas/LabelCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -85,7 +86,6 @@ export function LabelCanvas({

const {
label,
objects,
selectedIds,
addObject,
updateObject,
Expand All @@ -94,6 +94,7 @@ export function LabelCanvas({
toggleSelectObject,
selectObjects,
} = useLabelStore();
const objects = useCurrentObjects();

useEffect(() => {
const el = containerRef.current;
Expand Down Expand Up @@ -131,7 +132,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();

Expand Down Expand Up @@ -269,7 +272,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) &&
Expand Down Expand Up @@ -303,7 +308,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;

Expand Down Expand Up @@ -437,6 +442,8 @@ export function LabelCanvas({
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<PaginationControl />

{/* Bottom-right controls: view options + zoom */}
<div className="absolute bottom-3 right-3 z-10 flex items-center gap-1 bg-surface border border-border rounded px-1 py-0.5">
<button
Expand Down
60 changes: 60 additions & 0 deletions src/components/Canvas/PaginationControl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { useLabelStore } from "../../store/labelStore";

export function PaginationControl() {
const pageCount = useLabelStore((s) => s.pages.length);
const currentPageIndex = useLabelStore((s) => s.currentPageIndex);
const setCurrentPage = useLabelStore((s) => s.setCurrentPage);
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;

return (
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 z-10 flex items-center gap-1 bg-surface border border-border rounded px-1 py-0.5">
<button
onClick={() => canPrev && setCurrentPage(currentPageIndex - 1)}
disabled={!canPrev}
title="Previous page (Page Up)"
aria-label="Previous page"
className="w-6 h-6 flex items-center justify-center text-muted hover:text-text disabled:opacity-25 disabled:cursor-not-allowed font-mono text-sm transition-colors"
>
</button>
<span className="font-mono text-[10px] text-text px-2 select-none whitespace-nowrap">
Page {currentPageIndex + 1} / {pageCount}
</span>
<button
onClick={() => canNext && setCurrentPage(currentPageIndex + 1)}
disabled={!canNext}
title="Next page (Page Down)"
aria-label="Next page"
className="w-6 h-6 flex items-center justify-center text-muted hover:text-text disabled:opacity-25 disabled:cursor-not-allowed font-mono text-sm transition-colors"
>
</button>
<div className="w-px h-3.5 bg-border mx-0.5" />
<button
onClick={addPage}
title="Add page"
aria-label="Add page"
className="w-6 h-6 flex items-center justify-center text-muted hover:text-text font-mono text-sm transition-colors"
>
+
</button>
<button
onClick={() => canRemove && removePage(currentPageIndex)}
disabled={!canRemove}
title="Delete current page"
aria-label="Delete current page"
className="w-6 h-6 flex items-center justify-center text-muted hover:text-text disabled:opacity-25 disabled:cursor-not-allowed font-mono text-sm transition-colors"
>
</button>
</div>
);
}
Comment thread
u8array marked this conversation as resolved.
8 changes: 3 additions & 5 deletions src/components/Canvas/hooks/useCanvasLasso.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<MouseEvent>) => {
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;
Expand Down
4 changes: 2 additions & 2 deletions src/components/Canvas/hooks/useKonvaTransformer.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down
5 changes: 3 additions & 2 deletions src/components/Output/LabelPreview.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
Expand Down
8 changes: 5 additions & 3 deletions src/components/Output/ZPLOutput.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { CheckIcon, ClipboardDocumentIcon, ChevronDownIcon, ChevronUpIcon, EyeIcon } from '@heroicons/react/16/solid';
import { useLabelStore } from '../../store/labelStore';
import { generateZPL } from '../../lib/zplGenerator';
import { generateMultiPageZPL } from '../../lib/zplGenerator';
import { useT } from '../../lib/useT';
import { LabelPreviewModal } from './LabelPreview';

Expand All @@ -13,11 +13,13 @@ interface Props {

export function ZPLOutput({ collapsed, onCollapse, onExpand }: Props) {
const t = useT();
const { label, objects } = useLabelStore();
const label = useLabelStore((s) => s.label);
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;
Expand Down
16 changes: 9 additions & 7 deletions src/components/Output/ZplImportModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }, parsedObjects);
setResult({ objectCount: parsedObjects.length, report });
loadDesign({ ...label, ...labelConfig }, pages);
setResult({ objectCount: totalObjects, report });
};

const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
Expand All @@ -56,9 +57,10 @@ export function ZplImportModal({ onClose }: Props) {
return;
}

const { labelConfig, objects: parsedObjects, report } = importZplText(text, label.dpmm);
loadDesign({ ...label, ...labelConfig }, 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 = () => {
Expand Down
5 changes: 3 additions & 2 deletions src/components/Properties/LayersPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string | null>(null);

const sensors = useSensors(
Expand Down
4 changes: 2 additions & 2 deletions src/components/Properties/PropertiesPanel.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -16,14 +16,14 @@ import type { LabelConfig } from "../../types/ObjectType";
export function PropertiesPanel() {
const t = useT();
const {
objects,
selectedIds,
updateObject,
label,
setLabelConfig,
canvasSettings,
setCanvasSettings,
} = useLabelStore();
const objects = useCurrentObjects();
const unit = canvasSettings.unit;
const obj = objects.find((o) => o.id === selectedIds[0]);

Expand Down
8 changes: 4 additions & 4 deletions src/hooks/useDesignFileActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,17 @@ import { readFileAsText } from "../lib/readFile";

export function useDesignFileActions() {
const label = useLabelStore((s) => s.label);
const objects = useLabelStore((s) => s.objects);
const pages = useLabelStore((s) => s.pages);
const loadDesign = useLabelStore((s) => s.loadDesign);
const [loadError, setLoadError] = useState<string | null>(null);
const loadInputRef = useRef<HTMLInputElement>(null);

const handleNew = () => {
loadDesign({ widthMm: 100, heightMm: 60, dpmm: 8 }, []);
loadDesign({ widthMm: 100, heightMm: 60, dpmm: 8 }, [{ objects: [] }]);
};

const handleSave = () => {
const data = serializeDesign(label, objects);
const data = serializeDesign(label, pages);
triggerDownload(new Blob([data], { type: "application/json" }), "label.json");
};

Expand All @@ -40,7 +40,7 @@ export function useDesignFileActions() {
}

setLoadError(null);
loadDesign(result.value.label, result.value.objects);
loadDesign(result.value.label, result.value.pages);
};

return {
Expand Down
Loading