Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down
45 changes: 2 additions & 43 deletions src/components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -290,45 +287,7 @@ export function AppShell() {
/>
</main>

<aside className="w-64 shrink-0 border-l border-border bg-surface flex flex-col">
<div className="flex shrink-0 border-b border-border">
<button
onClick={() => setRightTab("properties")}
className={`flex-1 py-2 text-xs font-medium transition-colors ${
rightTab === "properties"
? "text-accent border-b-2 border-accent"
: "text-muted hover:text-text"
}`}
>
{t.layers.propertiesTab}
</button>
<button
onClick={() => setRightTab("layers")}
className={`flex-1 py-2 text-xs font-medium transition-colors ${
rightTab === "layers"
? "text-accent border-b-2 border-accent"
: "text-muted hover:text-text"
}`}
>
{t.layers.layersTab}
</button>
<button
onClick={() => setRightTab("fonts")}
className={`flex-1 py-2 text-xs font-medium transition-colors ${
rightTab === "fonts"
? "text-accent border-b-2 border-accent"
: "text-muted hover:text-text"
}`}
>
{t.layers.fontsTab}
</button>
</div>
<div className="flex-1 overflow-y-auto">
{rightTab === "properties" && <PropertiesPanel canvasRef={canvasRef} />}
{rightTab === "layers" && <LayersPanel />}
{rightTab === "fonts" && <FontManager />}
</div>
</aside>
<RightSidebar canvasRef={canvasRef} />
</div>
</DndContext>

Expand Down
23 changes: 14 additions & 9 deletions src/components/Canvas/KonvaObject.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 <LineObject {...props_} obj={obj} />;
if (obj.type === "image") return <ImageObject {...props_} obj={obj} />;
if (BARCODE_TYPES.has(obj.type)) return <BarcodeObject {...props_} />;
return <KonvaObjectInner {...props_} />;
// 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 <LineObject {...renderProps} obj={obj} />;
if (obj.type === "image") return <ImageObject {...renderProps} obj={obj} />;
if (BARCODE_TYPES.has(obj.type)) return <BarcodeObject {...renderProps} />;
return <KonvaObjectInner {...renderProps} />;
}

/**
Expand Down
3 changes: 2 additions & 1 deletion src/components/Output/ZPLOutput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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';
Expand Down
23 changes: 18 additions & 5 deletions src/components/Output/ZplImportModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -33,14 +34,21 @@ export function ZplImportModal({ onClose }: Props) {
const hasExistingContent =
pages.length > 1 || (pages[0]?.objects.length ?? 0) > 0;

const applyImport = (labelConfig: Partial<LabelConfig>, importedPages: Page[]) => {
const applyImport = (
labelConfig: Partial<LabelConfig>,
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);
}
};

Expand All @@ -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);
};

Expand Down
17 changes: 17 additions & 0 deletions src/components/Properties/LayerRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -185,6 +196,12 @@ export function LayerRow({
<span className="font-mono text-xs text-accent shrink-0 w-4 text-center">
{groupRow ? '⊞' : def?.icon}
</span>
{boundVariable && (
<VariableIcon
className="w-3 h-3 shrink-0 text-accent/70"
title={`Bound to ${boundVariable.name}`}
/>
)}
<div className="flex flex-col flex-1 min-w-0">
{editing ? (
<input
Expand Down
67 changes: 58 additions & 9 deletions src/components/Properties/PropertiesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import { useT } from "../../lib/useT";
import { parseIntOrUndef } from "../../lib/inputParse";
import { CollapsibleSection } from "../ui/CollapsibleSection";
import { AlignButtons } from "./AlignButtons";
import { VariableBindingControl } from "../Variables/VariableBindingControl";
import { applyBindingToObject, lookupBoundVariable } from "../../lib/variableBinding";
import { inputCls, labelCls } from "./styles";
import type { LabelConfig } from "../../types/ObjectType";
import {
Expand All @@ -40,6 +42,8 @@ export function PropertiesPanel({ canvasRef }: PropertiesPanelProps) {
const {
selectedIds,
updateObject,
updateVariable,
variables,
groupSelection,
label,
setLabelConfig,
Expand Down Expand Up @@ -204,20 +208,65 @@ export function PropertiesPanel({ canvasRef }: PropertiesPanelProps) {

<div className="border-t border-border" />

{/* 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 && (
<>
<TypePanel
obj={obj}
onChange={(props) => updateObject(obj.id, { props })}
/>
<CollapsibleSection
id={obj.variableId ? 'properties-variable-bound' : 'properties-variable-unbound'}
title={t.variables.sectionTitle}
defaultOpen={!!obj.variableId}
>
<VariableBindingControl obj={obj} />
</CollapsibleSection>
<div className="border-t border-border" />
</>
)}

{/* 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<string, unknown>) };
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 (
<>
<TypePanel obj={patchedObj} onChange={handleChange} />
<div className="border-t border-border" />
</>
);
})()}

{/* Comment (^FX) — leaves only: groups emit no ZPL of their own
so the comment would never reach the output. */}
{!groupRow && (
Expand Down
31 changes: 31 additions & 0 deletions src/components/RightSidebar/AaIcon.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<svg
viewBox="0 0 16 16"
fill="currentColor"
aria-hidden="true"
className={className}
>
<text
x="50%"
y="55%"
textAnchor="middle"
dominantBaseline="middle"
fontFamily="system-ui, sans-serif"
fontSize="11"
fontWeight="700"
fontStyle="normal"
>
Aa
</text>
</svg>
);
}
Loading