diff --git a/README.md b/README.md index f0be5e76..7075f9a9 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ The **ZPL output** panel at the bottom shows the generated ZPL. It updates in re File menu → **Import ZPL**: paste ZPL code directly, or open a `.zpl` file. -> Import round-trips text, barcodes, shapes, images (including printer-stored and compressed graphics), and label-header settings. Features the editor has no equivalent for are dropped: variable template fields (`^FN`/`^FV`/`^FE`) and printer-side date/time stamps (`^FC`). Anything the parser doesn't recognize is skipped and listed in the import report. +> Import round-trips text, barcodes, shapes, images (including printer-stored and compressed graphics), label-header settings, and template fields (`^FN`/`^FV` slots land in the **Variables** tab). Printer-side date/time stamps (`^FC`) have no editor equivalent and are dropped. Anything else the parser doesn't recognize is skipped and listed in the import report. ### Multiple labels (pages) @@ -98,6 +98,7 @@ Both `.zpl` and `.json` round-trip cleanly. `.zpl` preserves all printable conte - Smart alignment and spacing guides - Layers panel with reordering +- Variables: bind text and barcode fields to named defaults that emit as `^FN`/`^FV` slots, round-tripping with printer-side templates - 32 UI languages (auto-detected from browser) - Light / dark mode (follows OS setting) diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 07a62fb4..c5eea6be 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -3,9 +3,7 @@ import { DndContext, PointerSensor, useSensor, useSensors } from "@dnd-kit/core" import { ObjectPalette } from "./Palette/ObjectPalette"; import { LabelCanvas } from "./Canvas/LabelCanvas"; import type { LabelCanvasHandle } from "./Canvas/LabelCanvas"; -import { PropertiesPanel } from "./Properties/PropertiesPanel"; -import { LayersPanel } from "./Properties/LayersPanel"; -import { FontManager } from "./Fonts/FontManager"; +import { RightSidebar } from "./RightSidebar/RightSidebar"; import { ZPLOutput } from "./Output/ZPLOutput"; import { ZplImportModal } from "./Output/ZplImportModal"; import { PrintToZebraDialog } from "./Output/PrintToZebraDialog"; @@ -66,7 +64,6 @@ export function AppShell() { const canvasSettings = useLabelStore((s) => s.canvasSettings); const setCanvasSettings = useLabelStore((s) => s.setCanvasSettings); const { showGrid, snapEnabled, snapSizeMm, unit } = canvasSettings; - const [rightTab, setRightTab] = useState<"properties" | "layers" | "fonts">("properties"); const canUndo = pastStates.length > 0; const canRedo = futureStates.length > 0; @@ -290,45 +287,7 @@ export function AppShell() { /> - + diff --git a/src/components/Canvas/KonvaObject.tsx b/src/components/Canvas/KonvaObject.tsx index 19eab610..c0651cd8 100644 --- a/src/components/Canvas/KonvaObject.tsx +++ b/src/components/Canvas/KonvaObject.tsx @@ -9,6 +9,7 @@ import { outlineInset } from "../../lib/shapeGeometry"; import { reverseShapeStyle } from "./reverseShapeStyle"; import { useColorScheme } from "../../lib/useColorScheme"; import { useLabelStore } from "../../store/labelStore"; +import { applyBindingToObject } from "../../lib/variableBinding"; import { ZPL_FONT_HEIGHT_TO_CSS_RATIO } from "./textPositionTransforms"; import { getTextRenderMetrics } from "./textRenderMetrics"; import { selectionHandlers, type KonvaObjectProps } from "./konvaObjectProps"; @@ -105,15 +106,19 @@ const BARCODE_TYPES = new Set([ ]); export function KonvaObject(props_: Props) { - // Pass `obj` explicitly after the spread so each per-type renderer - // receives the narrowed type (LineLabelObject, ImageLabelObject) - // rather than the wide LabelObject. Without the explicit prop the - // spread would re-widen and the renderer would need a runtime cast. - const { obj } = props_; - if (obj.type === "line") return ; - if (obj.type === "image") return ; - if (BARCODE_TYPES.has(obj.type)) return ; - return ; + // Substitute the bound variable's defaultValue into `props.content` + // before any per-type renderer touches the obj. Keeps Konva blissfully + // unaware of the binding mechanism: every shape draws what the printer + // would print absent a runtime ^FV override. `applyBindingToObject` + // is identity-preserving when the obj isn't bound, so memoisation + // downstream isn't affected for the common case. + const variables = useLabelStore((s) => s.variables); + const obj = applyBindingToObject(props_.obj, variables); + const renderProps = obj === props_.obj ? props_ : { ...props_, obj }; + if (obj.type === "line") return ; + if (obj.type === "image") return ; + if (BARCODE_TYPES.has(obj.type)) return ; + return ; } /** 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..5b6d5df7 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/components/Properties/LayerRow.tsx b/src/components/Properties/LayerRow.tsx index b8d58659..fc4c9c91 100644 --- a/src/components/Properties/LayerRow.tsx +++ b/src/components/Properties/LayerRow.tsx @@ -8,10 +8,13 @@ import { ChevronRightIcon, ChevronDownIcon, LinkSlashIcon, + VariableIcon, } from '@heroicons/react/16/solid'; import { ObjectRegistry } from '../../registry'; import { isGroup, type LabelObject } from '../../types/Group'; import { useT } from '../../lib/useT'; +import { useLabelStore } from '../../store/labelStore'; +import { lookupBoundVariable } from '../../lib/variableBinding'; import { DragHandleIcon } from '../ui/DragHandleIcon'; import { INDENT_STEP } from './layerLayout'; @@ -68,6 +71,14 @@ export function LayerRow({ const t = useT(); const def = ObjectRegistry[obj.type]; const groupRow = isGroup(obj); + // Variable badge: small {x} glyph next to the type icon when the leaf + // is bound, with the variable name as a tooltip. Lets the user scan + // the layers list for slot usage without selecting each row. The + // selector subscribes the row to variable changes; cheap because + // each row already re-renders on selection / visibility flips. + const boundVariable = useLabelStore((s) => + lookupBoundVariable(obj, s.variables), + ); // Inline-rename is currently only exposed for groups; leaves render // their registry label as a plain (non-editable) span. The single // groupRow check at the entry-point keeps the rest of the edit path @@ -185,6 +196,12 @@ export function LayerRow({ {groupRow ? '⊞' : def?.icon} + {boundVariable && ( + + )}
{editing ? ( - {/* Per-type panel: only leaves have a registry entry, so TypePanel - is never present for groups. The isGroup guard narrows obj for - TypeScript at the call site since registry panels expect the - leaf shape (props field present). */} - {TypePanel && !groupRow && ( + {/* Variable binding: shown for types that emit a ^FD content + block (text + barcodes minus serial). Collapsed by default + for unbound fields so beginners aren't distracted; opens + automatically when the field is already bound so the + binding state is visible without an extra click. The + CollapsibleSection persists the user's manual toggle + per-state in localStorage (separate ids for bound vs + unbound), so each preference sticks. */} + {definition?.bindable && !groupRow && ( <> - updateObject(obj.id, { props })} - /> + + +
)} + {/* Per-type panel: only leaves have a registry entry, so TypePanel + is never present for groups. The isGroup guard narrows obj for + TypeScript at the call site since registry panels expect the + leaf shape (props field present). + + When a binding is active we hand TypePanel a patched obj where + props.content is the variable's defaultValue (so the CONTENT + input mirrors the canvas) and we re-route content edits into + updateVariable so typing into the per-type input directly + edits the variable's default. Non-content props (fontHeight, + rotation, …) keep flowing to updateObject untouched. */} + {TypePanel && !groupRow && (() => { + const boundVariable = lookupBoundVariable(obj, variables); + const patchedObj = boundVariable + ? applyBindingToObject(obj, variables) + : obj; + const handleChange = boundVariable + ? (props: object) => { + const next = { ...(props as Record) }; + if (typeof next.content === 'string') { + updateVariable(boundVariable.id, { + defaultValue: next.content, + }); + delete next.content; + } + if (Object.keys(next).length > 0) { + updateObject(obj.id, { props: next }); + } + } + : (props: object) => updateObject(obj.id, { props }); + return ( + <> + +
+ + ); + })()} + {/* Comment (^FX) — leaves only: groups emit no ZPL of their own so the comment would never reach the output. */} {!groupRow && ( diff --git a/src/components/RightSidebar/AaIcon.tsx b/src/components/RightSidebar/AaIcon.tsx new file mode 100644 index 00000000..beb8bea4 --- /dev/null +++ b/src/components/RightSidebar/AaIcon.tsx @@ -0,0 +1,31 @@ +/** + * Compact "Aa" glyph used as the Fonts tab icon. Heroicons has no + * typography-themed icon at the right weight, so we inline a 16×16 SVG + * that mirrors their stroke / fill aesthetic (currentColor, solid + * sans glyphs centred on the viewBox). Sized in `em` via the `w-3.5 + * h-3.5` Tailwind utilities applied at the call site, matching every + * other heroicon used in the tab strip. + */ +export function AaIcon({ className }: { className?: string }) { + return ( + + ); +} diff --git a/src/components/RightSidebar/RightSidebar.tsx b/src/components/RightSidebar/RightSidebar.tsx new file mode 100644 index 00000000..81d3dc45 --- /dev/null +++ b/src/components/RightSidebar/RightSidebar.tsx @@ -0,0 +1,73 @@ +import { useState, type ComponentType, type RefObject } from 'react'; +import { + AdjustmentsHorizontalIcon, + RectangleStackIcon, + VariableIcon, +} from '@heroicons/react/16/solid'; +import { PropertiesPanel } from '../Properties/PropertiesPanel'; +import { LayersPanel } from '../Properties/LayersPanel'; +import { FontManager } from '../Fonts/FontManager'; +import { VariablesPanel } from '../Variables/VariablesPanel'; +import { useT } from '../../lib/useT'; +import type { LabelCanvasHandle } from '../Canvas/LabelCanvas'; +import { AaIcon } from './AaIcon'; + +type TabId = 'properties' | 'layers' | 'variables' | 'fonts'; + +interface Props { + canvasRef: RefObject; +} + +interface TabDef { + id: TabId; + /** Tooltip + aria-label. Icons in the strip are otherwise unlabelled. */ + label: string; + Icon: ComponentType<{ className?: string }>; +} + +export function RightSidebar({ canvasRef }: Props) { + const t = useT(); + const [tab, setTab] = useState('properties'); + + // Ordering rule: selection scope first (Properties), then document-wide + // concerns by usage frequency (Layers, Variables, Fonts). + const tabs: TabDef[] = [ + { id: 'properties', label: t.layers.propertiesTab, Icon: AdjustmentsHorizontalIcon }, + { id: 'layers', label: t.layers.layersTab, Icon: RectangleStackIcon }, + { id: 'variables', label: t.layers.variablesTab, Icon: VariableIcon }, + { id: 'fonts', label: t.layers.fontsTab, Icon: AaIcon }, + ]; + + return ( + + ); +} diff --git a/src/components/Variables/VariableBindingControl.tsx b/src/components/Variables/VariableBindingControl.tsx new file mode 100644 index 00000000..8cd69bf6 --- /dev/null +++ b/src/components/Variables/VariableBindingControl.tsx @@ -0,0 +1,184 @@ +import { useState, type ChangeEvent } from 'react'; +import { XMarkIcon } from '@heroicons/react/16/solid'; +import { useLabelStore } from '../../store/labelStore'; +import type { LabelObject } from '../../types/Group'; +import { inputCls } from '../Properties/styles'; +import { FieldLabel } from '../ui/FieldLabel'; +import { useT } from '../../lib/useT'; + +const CREATE_NEW_SENTINEL = '__create_new__'; + +interface Props { + obj: LabelObject; +} + +export function VariableBindingControl({ obj }: Props) { + const t = useT(); + const tv = t.variables; + const variables = useLabelStore((s) => s.variables); + const updateObject = useLabelStore((s) => s.updateObject); + const addVariable = useLabelStore((s) => s.addVariable); + + const [creating, setCreating] = useState(false); + const [newName, setNewName] = useState(''); + const [error, setError] = useState(null); + + const boundId = obj.variableId; + // Bound to a variable that no longer exists (race after delete with + // pending undo, or partial state from a manual edit). Treat as unbound + // so the dropdown doesn't show a phantom selection; the orphan is + // already harmless at emit time (fdFieldFor falls back to literal). + const boundVariable = boundId + ? variables.find((v) => v.id === boundId) + : undefined; + + const handleSelect = (e: ChangeEvent) => { + const value = e.target.value; + setError(null); + if (value === '') { + // 'Not bound' picked: clear any existing binding. + if (boundId) updateObject(obj.id, { variableId: undefined }); + return; + } + if (value === CREATE_NEW_SENTINEL) { + setCreating(true); + setNewName(''); + return; + } + updateObject(obj.id, { variableId: value }); + }; + + const commitCreate = () => { + const trimmed = newName.trim(); + if (trimmed === '') { + setError(tv.nameRequired); + return; + } + // Seed the new variable's default with whatever literal content the + // field is currently carrying, preserving the canvas state across + // the binding transition. Every bindable type's first ^FD emission + // comes from `props.content` (see registry implementations). + const props = (obj as { props?: { content?: unknown } }).props; + const defaultValue = + typeof props?.content === 'string' ? props.content : ''; + const id = addVariable({ name: trimmed, defaultValue }); + if (id === null) { + // Two reasons addVariable returns null: name collision or no free + // ^FN slot. Distinguish via the variables list so the user knows + // which to fix. + setError( + variables.some((v) => v.name === trimmed) + ? tv.nameInUse + : tv.noSlotsLeft, + ); + return; + } + updateObject(obj.id, { variableId: id }); + setCreating(false); + setNewName(''); + setError(null); + }; + + const cancelCreate = () => { + setCreating(false); + setNewName(''); + setError(null); + }; + + return ( +
+ + + {creating ? ( +
+ { + setNewName(e.target.value); + if (error) setError(null); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') commitCreate(); + if (e.key === 'Escape') cancelCreate(); + }} + /> +
+ + +
+
+ ) : ( +
+ + {boundVariable && ( + + )} +
+ )} + + {error && ( +

{error}

+ )} + + {boundVariable && !creating && ( +

+ + {boundVariable.defaultValue === '' + ? tv.emptyDefault + : `"${boundVariable.defaultValue}"`} + {' '} + {tv.boundHint} +

+ )} +
+ ); +} + +/** Render `{name}: "{default}"`, truncating long defaults so the + *