From 6cd23e71bcf0a9857c58f2a5054f81e08cd327c8 Mon Sep 17 00:00:00 2001 From: u8array Date: Fri, 22 May 2026 17:11:53 +0200 Subject: [PATCH 01/11] feat(variables): store slice, schema, design-file persistence (Phase 1 step 1) Introduces the document-level Variables data model that ^FN/^FV round-trip will build on. No UI, no parser/generator changes yet. - new Variable type + zod schema in src/types/Variable.ts - variableId? added to labelObjectBaseSchema - store: variables state, addVariable/updateVariable/removeVariable (removeVariable strips variableId across all pages and groups) - designFile: optional variables array, omitted when empty for back-compat - useDesignFileActions passes variables through save/load --- src/hooks/useDesignFileActions.ts | 5 +- src/lib/designFile.test.ts | 49 ++++++++++ src/lib/designFile.ts | 31 +++++- src/store/labelStore.test.ts | 154 ++++++++++++++++++++++++++++++ src/store/labelStore.ts | 104 +++++++++++++++++++- src/types/Group.ts | 27 ++++++ src/types/ObjectType.ts | 5 + src/types/Variable.ts | 35 +++++++ 8 files changed, 402 insertions(+), 8 deletions(-) create mode 100644 src/types/Variable.ts diff --git a/src/hooks/useDesignFileActions.ts b/src/hooks/useDesignFileActions.ts index dedba464..5b5532ca 100644 --- a/src/hooks/useDesignFileActions.ts +++ b/src/hooks/useDesignFileActions.ts @@ -7,6 +7,7 @@ import { readFileAsText } from "../lib/readFile"; export function useDesignFileActions() { const label = useLabelStore((s) => s.label); const pages = useLabelStore((s) => s.pages); + const variables = useLabelStore((s) => s.variables); const loadDesign = useLabelStore((s) => s.loadDesign); const [loadError, setLoadError] = useState(null); const loadInputRef = useRef(null); @@ -16,7 +17,7 @@ export function useDesignFileActions() { }; const handleSave = () => { - const data = serializeDesign(label, pages); + const data = serializeDesign(label, pages, variables); triggerDownload(new Blob([data], { type: "application/json" }), "label.json"); }; @@ -40,7 +41,7 @@ export function useDesignFileActions() { } setLoadError(null); - loadDesign(result.value.label, result.value.pages); + loadDesign(result.value.label, result.value.pages, result.value.variables); }; return { diff --git a/src/lib/designFile.test.ts b/src/lib/designFile.test.ts index 8bce73c5..002718a2 100644 --- a/src/lib/designFile.test.ts +++ b/src/lib/designFile.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { parseDesignFile, serializeDesign } from './designFile'; import type { LabelObject } from '../types/Group'; +import type { Variable } from '../types/Variable'; const SAMPLE_OBJECTS: LabelObject[] = [ { @@ -156,4 +157,52 @@ describe('parseDesignFile', () => { if (result.ok) return; expect(result.error).toBe('invalid_schema'); }); + + it('roundtrips variables when present', () => { + const variables: Variable[] = [ + { id: 'v1', name: 'sku', fnNumber: 1, defaultValue: 'ABC' }, + { id: 'v2', name: 'qty', fnNumber: 2, defaultValue: '0', comment: 'Quantity' }, + ]; + const json = serializeDesign( + { widthMm: 100, heightMm: 60, dpmm: 8 }, + [{ objects: SAMPLE_OBJECTS }], + variables, + ); + const result = parseDesignFile(json); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.variables).toEqual(variables); + }); + + it('omits the variables key from JSON when empty (back-compat with older app versions)', () => { + const json = serializeDesign( + { widthMm: 100, heightMm: 60, dpmm: 8 }, + [{ objects: SAMPLE_OBJECTS }], + [], + ); + const parsed = JSON.parse(json) as Record; + expect(parsed).not.toHaveProperty('variables'); + }); + + it('defaults to empty variables when the JSON lacks the field', () => { + 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.variables).toEqual([]); + }); + + it('legacy { label, objects } shape loads with empty variables', () => { + 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.variables).toEqual([]); + }); }); diff --git a/src/lib/designFile.ts b/src/lib/designFile.ts index fda43583..cc0fbae1 100644 --- a/src/lib/designFile.ts +++ b/src/lib/designFile.ts @@ -1,11 +1,16 @@ import { z } from "zod"; import { labelConfigSchema, labelObjectBaseSchema, type LabelConfig } from "../types/ObjectType"; +import { variableSchema, type Variable } from "../types/Variable"; import type { LabelObject } from "../types/Group"; import { ok, err, type Result } from "./result"; export type DesignFileError = "parse_error" | "invalid_schema"; export interface DesignFilePage { objects: LabelObject[] } -export interface DesignFile { label: LabelConfig; pages: DesignFilePage[] } +export interface DesignFile { + label: LabelConfig; + pages: DesignFilePage[]; + variables: Variable[]; +} // Two distinct shapes share the base fields: // * leaves carry `props` and have no `children`, @@ -34,6 +39,8 @@ const pageSchema = z.object({ objects: z.array(labelObjectSchema) }); const designFileSchema = z.object({ label: labelConfigSchema, pages: z.array(pageSchema), + // Optional so designs saved before the variables feature still load. + variables: z.array(variableSchema).optional(), }); const legacyDesignFileSchema = z.object({ @@ -54,7 +61,11 @@ export function parseDesignFile(text: string): Result 0) payload.variables = variables; + return JSON.stringify(payload, null, 2); } export const designFileErrors: Record = { diff --git a/src/store/labelStore.test.ts b/src/store/labelStore.test.ts index c6482cce..d13298a1 100644 --- a/src/store/labelStore.test.ts +++ b/src/store/labelStore.test.ts @@ -39,6 +39,7 @@ function reset() { selectedIds: [], clipboard: [], pasteCount: 0, + variables: [], previewMode: { status: 'idle' }, canvasSettings: { showGrid: false, @@ -1147,3 +1148,156 @@ describe('migrateLegacy — v2→v3 circle→ellipse', () => { expect(migrated.pages[0]?.objects[0]).toEqual(persisted.pages[0]?.objects[0]); }); }); + +describe('variables', () => { + beforeEach(reset); + + it('addVariable assigns sequential fnNumbers starting at 1', () => { + const id1 = state().addVariable({ name: 'sku' }); + const id2 = state().addVariable({ name: 'qty' }); + expect(id1).not.toBeNull(); + expect(id2).not.toBeNull(); + const vars = state().variables; + expect(vars).toHaveLength(2); + expect(vars[0]?.fnNumber).toBe(1); + expect(vars[1]?.fnNumber).toBe(2); + }); + + it('addVariable fills the lowest free fnNumber when earlier ones were taken explicitly', () => { + state().addVariable({ name: 'a', fnNumber: 3 }); + state().addVariable({ name: 'b', fnNumber: 1 }); + const id = state().addVariable({ name: 'c' }); + expect(id).not.toBeNull(); + const c = state().variables.find((v) => v.name === 'c'); + expect(c?.fnNumber).toBe(2); + }); + + it('addVariable rejects duplicate names', () => { + state().addVariable({ name: 'sku' }); + const dup = state().addVariable({ name: 'sku' }); + expect(dup).toBeNull(); + expect(state().variables).toHaveLength(1); + }); + + it('addVariable rejects duplicate fnNumbers', () => { + state().addVariable({ name: 'a', fnNumber: 5 }); + const dup = state().addVariable({ name: 'b', fnNumber: 5 }); + expect(dup).toBeNull(); + expect(state().variables).toHaveLength(1); + }); + + it('addVariable rejects fnNumbers outside 1-99', () => { + expect(state().addVariable({ name: 'a', fnNumber: 0 })).toBeNull(); + expect(state().addVariable({ name: 'b', fnNumber: 100 })).toBeNull(); + expect(state().variables).toHaveLength(0); + }); + + it('addVariable rejects empty / whitespace-only names', () => { + expect(state().addVariable({ name: '' })).toBeNull(); + expect(state().addVariable({ name: ' ' })).toBeNull(); + expect(state().variables).toHaveLength(0); + }); + + it('addVariable trims the name', () => { + state().addVariable({ name: ' sku ' }); + expect(state().variables[0]?.name).toBe('sku'); + }); + + it('updateVariable patches fields when valid', () => { + const id = defined(state().addVariable({ name: 'sku', defaultValue: 'x' })); + state().updateVariable(id, { defaultValue: 'y' }); + expect(state().variables[0]?.defaultValue).toBe('y'); + }); + + it('updateVariable rejects renaming to an existing name', () => { + state().addVariable({ name: 'a' }); + const id = defined(state().addVariable({ name: 'b' })); + state().updateVariable(id, { name: 'a' }); + expect(state().variables.find((v) => v.id === id)?.name).toBe('b'); + }); + + it('updateVariable rejects fnNumber collisions', () => { + state().addVariable({ name: 'a', fnNumber: 1 }); + const id = defined(state().addVariable({ name: 'b', fnNumber: 2 })); + state().updateVariable(id, { fnNumber: 1 }); + expect(state().variables.find((v) => v.id === id)?.fnNumber).toBe(2); + }); + + it('removeVariable strips variableId from every bound field across pages', () => { + const varId = defined(state().addVariable({ name: 'sku', defaultValue: 'X' })); + useLabelStore.setState({ + pages: [ + { + objects: [ + { + id: 'obj-1', + type: 'text', + x: 0, + y: 0, + rotation: 0, + variableId: varId, + props: { content: 'X', fontHeight: 30, fontWidth: 30, rotation: 'N' }, + } as LabelObject, + ], + }, + { + objects: [ + { + id: 'grp-1', + type: 'group', + x: 0, + y: 0, + rotation: 0, + children: [ + { + id: 'obj-2', + type: 'text', + x: 0, + y: 0, + rotation: 0, + variableId: varId, + props: { content: 'X', fontHeight: 30, fontWidth: 30, rotation: 'N' }, + } as LabelObject, + ], + } as LabelObject, + ], + }, + ], + }); + + state().removeVariable(varId); + + expect(state().variables).toHaveLength(0); + const page0 = state().pages[0]?.objects[0] as LabelObject & { variableId?: string }; + expect(page0.variableId).toBeUndefined(); + const group = state().pages[1]?.objects[0]; + if (!group || !isGroup(group)) throw new Error('expected group'); + const inner = group.children[0] as LabelObject & { variableId?: string }; + expect(inner.variableId).toBeUndefined(); + }); + + it('removeVariable leaves pages untouched when no field referenced the variable', () => { + const varId = defined(state().addVariable({ name: 'sku' })); + const pagesBefore = state().pages; + state().removeVariable(varId); + expect(state().pages).toBe(pagesBefore); + }); + + it('loadDesign replaces the variable list', () => { + state().addVariable({ name: 'a' }); + state().loadDesign( + { widthMm: 50, heightMm: 30, dpmm: 8 }, + [{ objects: [] }], + [{ id: 'v1', name: 'fresh', fnNumber: 7, defaultValue: '' }], + ); + expect(state().variables).toEqual([ + { id: 'v1', name: 'fresh', fnNumber: 7, defaultValue: '' }, + ]); + }); + + it('loadDesign resets variables to empty when none supplied', () => { + state().addVariable({ name: 'a' }); + state().loadDesign({ widthMm: 50, heightMm: 30, dpmm: 8 }, [{ objects: [] }]); + expect(state().variables).toEqual([]); + }); +}); diff --git a/src/store/labelStore.ts b/src/store/labelStore.ts index 07266d0b..11152f0d 100644 --- a/src/store/labelStore.ts +++ b/src/store/labelStore.ts @@ -12,6 +12,7 @@ import { findObjectById, findAncestors, isSelfOrDescendant, + stripVariableIdFromObjects, type GroupObject, type LabelObject, } from '../types/Group'; @@ -19,8 +20,16 @@ import { locales } from '../locales'; import type { LocaleCode } from '../locales'; import { isDefaultLabelaryHost, fetchPreview, labelaryErrorMessage } from '../lib/labelary'; import { generateZPL } from '../lib/zplGenerator'; +import { + nextFreeFnNumber, + FN_NUMBER_MIN, + FN_NUMBER_MAX, + type Variable, + type VariableInput, +} from '../types/Variable'; export type { ObjectChanges }; +export type { Variable, VariableInput }; export interface Page { objects: LabelObject[]; @@ -142,6 +151,11 @@ interface LabelState { clipboard: LabelObject[]; pasteCount: number; + /** Document-level template variables. Fields reference them via + * `variableId`; export emits `^FN{fnNumber}^FD{defaultValue}^FS`. + * Order is user-controlled and surfaces in the Variables panel. */ + variables: Variable[]; + addObject: ( type: string, position?: { x: number; y: number }, @@ -185,8 +199,22 @@ interface LabelState { setThirdPartyEnabled: (service: 'labelary', enabled: boolean) => void; acknowledgeLabelaryNotice: () => void; setCanvasSettings: (settings: Partial) => void; - loadDesign: (label: LabelConfig, pages: Page[]) => void; + loadDesign: (label: LabelConfig, pages: Page[], variables?: Variable[]) => void; appendPages: (pages: Page[]) => void; + + /** Create a new variable. Returns the new id, or null when all 99 + * `^FN` slots are taken (or the supplied fnNumber is out of range / + * already used). Callers should surface null to the user. */ + addVariable: (input: VariableInput) => string | null; + /** Patch fields on an existing variable. Validates uniqueness of + * `name` and `fnNumber` and clamps `fnNumber` to [1, 99]; rejects + * silently (no-op) on conflict so callers don't have to handle errors + * for every keystroke. */ + updateVariable: (id: string, changes: Partial>) => void; + /** Delete a variable and unbind every field that referenced it across + * every page. The field's own content prop (kept since binding) takes + * over on render/export. */ + removeVariable: (id: string) => void; moveObjectForward: (id: string) => void; moveObjectBackward: (id: string) => void; moveObjectToFront: (id: string) => void; @@ -409,6 +437,7 @@ export const useLabelStore = create()( selectedIds: [], clipboard: [], pasteCount: 0, + variables: [], locale: detectLocale(), theme: detectInitialTheme(), thirdParty: thirdPartyDefaults(), @@ -792,7 +821,7 @@ export const useLabelStore = create()( }); }), - loadDesign: (label, pages) => + loadDesign: (label, pages, variables) => set((state) => { if (selectPreviewLocksEditor(state)) return {}; return { @@ -800,6 +829,7 @@ export const useLabelStore = create()( pages: pages.length > 0 ? pages : [{ objects: [] }], currentPageIndex: 0, selectedIds: [], + variables: variables ?? [], }; }), @@ -915,6 +945,74 @@ export const useLabelStore = create()( return { currentPageIndex: index, selectedIds: [] }; }), + addVariable: (input) => { + const state = get(); + if (selectPreviewLocksEditor(state)) return null; + const trimmedName = input.name.trim(); + if (trimmedName === '') return null; + if (state.variables.some((v) => v.name === trimmedName)) return null; + + let fnNumber: number; + if (input.fnNumber !== undefined) { + if (input.fnNumber < FN_NUMBER_MIN || input.fnNumber > FN_NUMBER_MAX) return null; + if (state.variables.some((v) => v.fnNumber === input.fnNumber)) return null; + fnNumber = input.fnNumber; + } else { + const next = nextFreeFnNumber(state.variables.map((v) => v.fnNumber)); + if (next === null) return null; + fnNumber = next; + } + + const variable: Variable = { + id: crypto.randomUUID(), + name: trimmedName, + fnNumber, + defaultValue: input.defaultValue ?? '', + ...(input.comment !== undefined ? { comment: input.comment } : {}), + }; + set((s) => ({ variables: [...s.variables, variable] })); + return variable.id; + }, + + updateVariable: (id, changes) => + set((state) => { + if (selectPreviewLocksEditor(state)) return {}; + const existing = state.variables.find((v) => v.id === id); + if (!existing) return {}; + + if (changes.name !== undefined) { + const trimmed = changes.name.trim(); + if (trimmed === '') return {}; + if (state.variables.some((v) => v.id !== id && v.name === trimmed)) return {}; + changes = { ...changes, name: trimmed }; + } + if (changes.fnNumber !== undefined) { + if (changes.fnNumber < FN_NUMBER_MIN || changes.fnNumber > FN_NUMBER_MAX) return {}; + if (state.variables.some((v) => v.id !== id && v.fnNumber === changes.fnNumber)) return {}; + } + + return { + variables: state.variables.map((v) => (v.id === id ? { ...v, ...changes } : v)), + }; + }), + + removeVariable: (id) => + set((state) => { + if (selectPreviewLocksEditor(state)) return {}; + if (!state.variables.some((v) => v.id === id)) return {}; + let pagesChanged = false; + const nextPages = state.pages.map((p) => { + const stripped = stripVariableIdFromObjects(p.objects, id); + if (stripped === p.objects) return p; + pagesChanged = true; + return { ...p, objects: stripped }; + }); + return { + variables: state.variables.filter((v) => v.id !== id), + ...(pagesChanged ? { pages: nextPages } : {}), + }; + }), + enterPreviewMode: async () => { const state = get(); if (state.previewMode.status === 'loading' || state.previewMode.status === 'active') { @@ -982,6 +1080,7 @@ export const useLabelStore = create()( // first run's env value and quietly defeat later build flips. labelaryNoticeAcknowledged: state.labelaryNoticeAcknowledged, canvasSettings: state.canvasSettings, + variables: state.variables, }), } ), @@ -990,6 +1089,7 @@ export const useLabelStore = create()( label: state.label, pages: state.pages, currentPageIndex: state.currentPageIndex, + variables: state.variables, }), } ) diff --git a/src/types/Group.ts b/src/types/Group.ts index 48173ee3..d205593c 100644 --- a/src/types/Group.ts +++ b/src/types/Group.ts @@ -131,6 +131,33 @@ export function mapObjectById( return changed ? next : objects; } +/** + * Returns a new tree with every leaf's `variableId` cleared when it matches + * `variableId`. Recurses into groups. Identity-preserving like + * `mapObjectById`: subtrees that hold no matching binding keep their + * original references so the store can short-circuit the page update. + */ +export function stripVariableIdFromObjects( + objects: LabelObject[], + variableId: string, +): LabelObject[] { + let changed = false; + const next = objects.map((o) => { + if (isGroup(o)) { + const newChildren = stripVariableIdFromObjects(o.children, variableId); + if (newChildren === o.children) return o; + changed = true; + return { ...o, children: newChildren }; + } + if (o.variableId !== variableId) return o; + changed = true; + const cleared: LabelObject = { ...o }; + delete cleared.variableId; + return cleared; + }); + return changed ? next : objects; +} + /** * Returns the tree with `id` removed and the removed node, or `null` * for the node when nothing matched. Used by reparenting flows that diff --git a/src/types/ObjectType.ts b/src/types/ObjectType.ts index f969d3e6..4ce84696 100644 --- a/src/types/ObjectType.ts +++ b/src/types/ObjectType.ts @@ -112,6 +112,11 @@ export const labelObjectBaseSchema = z.object({ * Leaves currently fall back to their registry label; the field lives * on the base so naming leaves later is a UI-only change. */ name: z.string().optional(), + /** When set, the field's render/export content comes from the referenced + * Variable's defaultValue (or future data source). The field's own + * content prop is kept as fallback when the binding is removed. + * Exported as `^FN{n}^FD{default}^FS` instead of plain `^FD{content}^FS`. */ + variableId: z.string().optional(), }); export type LabelObjectBase = z.infer; diff --git a/src/types/Variable.ts b/src/types/Variable.ts new file mode 100644 index 00000000..61e7a13b --- /dev/null +++ b/src/types/Variable.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; + +/** Hard bounds on `^FN` numbers in classic ZPL: 1-99. Newer firmware allows + * more, but staying inside the historical range keeps output portable. */ +export const FN_NUMBER_MIN = 1; +export const FN_NUMBER_MAX = 99; + +export const variableSchema = z.object({ + id: z.string(), + name: z.string(), + fnNumber: z.number().int().min(FN_NUMBER_MIN).max(FN_NUMBER_MAX), + defaultValue: z.string(), + comment: z.string().optional(), +}); + +export type Variable = z.infer; + +export interface VariableInput { + name: string; + defaultValue?: string; + /** Explicit slot. When omitted, the store assigns the next free number. */ + fnNumber?: number; + comment?: string; +} + +/** Returns the lowest unused fnNumber in [1, 99], or null when all 99 slots + * are taken. Callers should surface the null case to the UI rather than + * silently dropping the add. */ +export function nextFreeFnNumber(used: readonly number[]): number | null { + const taken = new Set(used); + for (let n = FN_NUMBER_MIN; n <= FN_NUMBER_MAX; n++) { + if (!taken.has(n)) return n; + } + return null; +} From b60599e677a17ec1ec79947f0663137300d733d2 Mon Sep 17 00:00:00 2001 From: u8array Date: Fri, 22 May 2026 17:31:46 +0200 Subject: [PATCH 02/11] feat(variables): ^FN/^FV round-trip in parser, generator and importer (Phase 1 step 2) Text and barcode emitters now consult ctx.variables: when an object carries variableId pointing at a known variable, the field block becomes ^FN{n}^FD{default}^FS so the printer treats it as a template slot. Parser walks the same path in reverse and reconstructs Variables from ^FN slots, deriving names from immediately-preceding ^FX comments. - ZplEmitContext.variables threaded from generator into every emitter - fdFieldFor helper in zplHelpers wraps fdField with FN handling - 9 emitters (text + 8 barcode types) switched to fdFieldFor - Parser: ^FN handler + flushField binding, accumulator returned in ParsedZPL - variableNameFromComment + shared uniqueVariableName helper - zplImportService merges variables across multi-page blocks by fnNumber with id remap so bindings survive the merge - ZplImportModal threads importedVariables into loadDesign; append-mode intentionally drops them (no merge dialog yet) - All callers of generate(Multi)PageZPL and printLabel pass variables - 12 new tests: parser binding, generator emission, round-trip, orphan handling, out-of-range ^FN --- src/components/Output/ZPLOutput.tsx | 3 +- src/components/Output/ZplImportModal.tsx | 23 +++++-- src/hooks/useZplImportExport.ts | 7 ++- src/lib/printPreview.ts | 9 ++- src/lib/zplGenerator.test.ts | 53 +++++++++++++++++ src/lib/zplGenerator.ts | 17 ++++-- src/lib/zplImportService.ts | 49 ++++++++++++++- src/lib/zplParser.test.ts | 39 ++++++++++++ src/lib/zplParser.ts | 76 +++++++++++++++++++++++- src/registry/aztec.tsx | 6 +- src/registry/barcode1d.tsx | 6 +- src/registry/codablock.tsx | 6 +- src/registry/datamatrix.tsx | 6 +- src/registry/gs1databar.tsx | 6 +- src/registry/micropdf417.tsx | 6 +- src/registry/pdf417.tsx | 6 +- src/registry/qrcode.tsx | 10 +++- src/registry/text.tsx | 4 +- src/registry/zplHelpers.ts | 24 ++++++++ src/store/labelStore.test.ts | 6 +- src/store/labelStore.ts | 2 +- src/types/ObjectType.ts | 15 +++-- src/types/Variable.ts | 15 +++++ 23 files changed, 340 insertions(+), 54 deletions(-) diff --git a/src/components/Output/ZPLOutput.tsx b/src/components/Output/ZPLOutput.tsx index 1eb65052..0809f585 100644 --- a/src/components/Output/ZPLOutput.tsx +++ b/src/components/Output/ZPLOutput.tsx @@ -15,6 +15,7 @@ export function ZPLOutput({ collapsed, onCollapse, onExpand }: Props) { const t = useT(); const label = useLabelStore((s) => s.label); const pages = useLabelStore((s) => s.pages); + const variables = useLabelStore((s) => s.variables); const labelaryEnabled = useLabelStore((s) => s.thirdParty.labelary); const noticeRequired = useLabelStore(selectLabelaryNoticeRequired); const previewMode = useLabelStore((s) => s.previewMode); @@ -24,7 +25,7 @@ export function ZPLOutput({ collapsed, onCollapse, onExpand }: Props) { const [showNotice, setShowNotice] = useState(false); const hasObjects = pages.some((p) => p.objects.length > 0); - const zpl = hasObjects ? generateMultiPageZPL(label, pages) : ''; + const zpl = hasObjects ? generateMultiPageZPL(label, pages, variables) : ''; const previewActive = previewMode.status === 'loading' || previewMode.status === 'active'; diff --git a/src/components/Output/ZplImportModal.tsx b/src/components/Output/ZplImportModal.tsx index 277e51bb..a0539032 100644 --- a/src/components/Output/ZplImportModal.tsx +++ b/src/components/Output/ZplImportModal.tsx @@ -4,6 +4,7 @@ import { importZplText } from '../../lib/zplImportService'; import { readFileAsText } from '../../lib/readFile'; import { useLabelStore, type Page } from '../../store/labelStore'; import type { LabelConfig } from '../../types/ObjectType'; +import type { Variable } from '../../types/Variable'; import { formatReportAsText, type ImportReport, type ImportResult } from '../../lib/importReport'; import { ImportSummaryBody } from './ImportSummary'; import { useT } from '../../lib/useT'; @@ -33,14 +34,21 @@ export function ZplImportModal({ onClose }: Props) { const hasExistingContent = pages.length > 1 || (pages[0]?.objects.length ?? 0) > 0; - const applyImport = (labelConfig: Partial, importedPages: Page[]) => { + const applyImport = ( + labelConfig: Partial, + importedPages: Page[], + importedVariables: Variable[], + ) => { if (appendMode && hasExistingContent) { // Keep the current label config: the user opted to keep the // existing design's dimensions, so any imported ^PW/^LL is - // intentionally discarded. + // intentionally discarded. Imported variables are dropped too — + // append-mode preserves the current Variables tab; merging here + // would risk name/fnNumber collisions the user can't see in the + // dialog. Round-trip from a saved design uses Save/Load, not Append. appendPages(importedPages); } else { - loadDesign({ ...label, ...labelConfig }, importedPages); + loadDesign({ ...label, ...labelConfig }, importedPages, importedVariables); } }; @@ -62,13 +70,18 @@ export function ZplImportModal({ onClose }: Props) { // source-specific error they surface; everything past that point is // identical, so it lives here. const processImport = (text: string) => { - const { labelConfig, pages: importedPages, report } = importZplText(text, label.dpmm); + const { + labelConfig, + pages: importedPages, + variables: importedVariables, + report, + } = importZplText(text, label.dpmm); const totalObjects = importedPages.reduce((s, p) => s + p.objects.length, 0); if (totalObjects === 0 && Object.keys(labelConfig).length === 0) { setError('No supported objects found in the ZPL code.'); return; } - applyImport(labelConfig, importedPages); + applyImport(labelConfig, importedPages, importedVariables); finishImport(totalObjects, report); }; diff --git a/src/hooks/useZplImportExport.ts b/src/hooks/useZplImportExport.ts index df7fdb2f..4efb54a2 100644 --- a/src/hooks/useZplImportExport.ts +++ b/src/hooks/useZplImportExport.ts @@ -8,13 +8,14 @@ import { labelaryErrorMessage } from "../lib/labelary"; export function useZplImportExport() { const label = useLabelStore((s) => s.label); const pages = useLabelStore((s) => s.pages); + const variables = useLabelStore((s) => s.variables); const objects = useCurrentObjects(); const [showZplImport, setShowZplImport] = useState(false); const [showZebraPrint, setShowZebraPrint] = useState(false); const [printError, setPrintError] = useState(null); const handleDownload = () => { - const zpl = generateMultiPageZPL(label, pages); + const zpl = generateMultiPageZPL(label, pages, variables); triggerDownload(new Blob([zpl], { type: "text/plain" }), "label.zpl"); }; @@ -22,7 +23,7 @@ export function useZplImportExport() { // only the current page so the preview matches what the user sees. const handlePrint = async () => { try { - await printLabel(label, objects); + await printLabel(label, objects, variables); } catch (e) { setPrintError(labelaryErrorMessage(e)); } @@ -35,7 +36,7 @@ export function useZplImportExport() { showZebraPrint, openZebraPrint: () => setShowZebraPrint(true), closeZebraPrint: () => setShowZebraPrint(false), - currentZpl: () => generateMultiPageZPL(label, pages), + currentZpl: () => generateMultiPageZPL(label, pages, variables), printError, dismissPrintError: () => setPrintError(null), handleDownload, diff --git a/src/lib/printPreview.ts b/src/lib/printPreview.ts index e205c928..2a439a63 100644 --- a/src/lib/printPreview.ts +++ b/src/lib/printPreview.ts @@ -2,6 +2,7 @@ import { generateZPL } from "./zplGenerator"; import { fetchPreview } from "./labelary"; import type { LabelConfig } from "../types/ObjectType"; import type { LabelObject } from "../types/Group"; +import type { Variable } from "../types/Variable"; export function buildLoadingHtml(): string { return `