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
4 changes: 2 additions & 2 deletions 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), 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.
> 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; `^FE` inline embeds like `^FD#1#-#2#` import as `«name»` markers in the field content). 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 @@ -102,7 +102,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
- Variables: bind text and barcode fields to named defaults that emit as `^FN`/`^FV` slots (or `^FE` inline embeds when one field references multiple variables), round-tripping with printer-side templates
- CSV batch printing: import a CSV, map columns to Variables, print or export with efficient printer-side data merge (template ships once, each row sends only its overrides)
- 32 UI languages (auto-detected from browser)
- Light / dark mode (follows OS setting)
Expand Down
2 changes: 1 addition & 1 deletion docs/zpl-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ What's supported, what's next, what's planned.
- [x] `^TB` — text block
- [x] `^FN` — variable placeholder
- [x] `^FV` — variable data
- [x] `^FE` — field-number embed character
- [x] `^BY` — barcode field default

### Text & fonts
Expand Down Expand Up @@ -104,7 +105,6 @@ What's supported, what's next, what's planned.

### Fields

- [ ] `^FE` — field concatenation
- [ ] `^FM` — multiple field origins
- [ ] `^FP` — field path (text along path)
- [ ] `^CO` — font cache size
Expand Down
114 changes: 114 additions & 0 deletions src/components/Properties/TemplateContentInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { useEffect, useRef, useState } from "react";
import { useT } from "../../lib/useT";
import { useLabelStore } from "../../store/labelStore";
import { inputCls } from "./styles";

interface Props {
value: string;
onChange: (next: string) => void;
/** Optional sanitiser for restricted-charset fields (e.g. numeric-only
* barcodes). Applied to typed input only — template markers are
* inserted verbatim regardless. */
sanitise?: (raw: string) => string;
placeholder?: string;
maxLength?: number;
}

/**
* Text input + "Insert variable" button. The button opens a small
* dropdown listing every defined Variable; picking one splices its
* `«name»` marker into the input at the current cursor position.
* Templates resolve at render time via applyBindingToObject — see
* lib/fnTemplate + lib/variableBinding.
*
* Used by Text and 1D-barcode properties panels in place of the
* plain input so non-technical users can compose multi-variable
* fields without typing the marker syntax by hand.
*/
export function TemplateContentInput({
value,
onChange,
sanitise,
placeholder,
maxLength,
}: Props) {
const t = useT();
const variables = useLabelStore((s) => s.variables);
const inputRef = useRef<HTMLInputElement>(null);
const rootRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);

// Click-outside + Esc close. Mounted only while open so the
// listeners don't fire for every other open menu in the panel.
useEffect(() => {
if (!open) return;
const onPointerDown = (e: PointerEvent) => {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("pointerdown", onPointerDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("pointerdown", onPointerDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);

const insertMarker = (name: string) => {
const input = inputRef.current;
const marker = `«${name}»`;
const cursor = input?.selectionStart ?? value.length;
const end = input?.selectionEnd ?? cursor;
const next = value.slice(0, cursor) + marker + value.slice(end);
onChange(next);
setOpen(false);
// Restore focus + place cursor right after the inserted marker.
queueMicrotask(() => {
if (!input) return;
const pos = cursor + marker.length;
input.focus();
input.setSelectionRange(pos, pos);
});
};

return (
<div ref={rootRef} className="relative flex gap-1">
<input
ref={inputRef}
className={`${inputCls} flex-1`}
value={value}
maxLength={maxLength}
placeholder={placeholder}
onChange={(e) => onChange(sanitise ? sanitise(e.target.value) : e.target.value)}
/>
<button
type="button"
className="px-2 rounded border border-border bg-surface-2 text-xs font-mono text-muted hover:text-text hover:border-accent transition-colors"
title={t.app.insertVariable}
disabled={variables.length === 0}
onClick={() => setOpen((o) => !o)}
>
{"{x}"}
</button>
{open && variables.length > 0 && (
<div
className="absolute right-0 top-full mt-1 z-10 min-w-[8rem] max-h-48 overflow-y-auto rounded border border-border bg-surface shadow-lg"
role="menu"
>
{variables.map((v) => (
<button
Comment thread
u8array marked this conversation as resolved.
key={v.id}
type="button"
className="block w-full text-left px-2 py-1 text-xs font-mono text-text hover:bg-surface-2 transition-colors"
onClick={() => insertMarker(v.name)}
>
«{v.name}»
</button>
))}
</div>
)}
</div>
);
}
5 changes: 2 additions & 3 deletions src/components/Variables/VariableBindingControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { LabelObject } from '../../types/Group';
import { inputCls } from '../Properties/styles';
import { FieldLabel } from '../ui/FieldLabel';
import { useT } from '../../lib/useT';
import { getObjectStringContent } from '../../lib/variableBinding';

const CREATE_NEW_SENTINEL = '__create_new__';

Expand Down Expand Up @@ -58,9 +59,7 @@ export function VariableBindingControl({ obj }: Props) {
// 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 defaultValue = getObjectStringContent(obj) ?? '';
const id = addVariable({ name: trimmed, defaultValue });
if (id === null) {
// Two reasons addVariable returns null: name collision or no free
Expand Down
27 changes: 23 additions & 4 deletions src/components/Variables/VariablesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ import { ConfirmDialog } from '../ui/ConfirmDialog';
import { FieldLabel } from '../ui/FieldLabel';
import { useT } from '../../lib/useT';
import type { Translations } from '../../locales';
import { getVariableSource, type VariableSource } from '../../lib/variableBinding';
import { getObjectStringContent, getVariableSource, type VariableSource } from '../../lib/variableBinding';
import { extractTemplateRefs } from '../../lib/fnTemplate';
import { VariableSourceBadge } from './VariableSourceBadge';

export function VariablesPanel() {
Expand Down Expand Up @@ -487,18 +488,36 @@ function formatDeleteMessage(
}

/** Walk every page (groups too) and tally how many fields reference each
* variable. Returns a Map keyed by variable.id. Variables with no
* bindings are absent from the map; callers default to 0. */
* variable, either via single-bind `variableId` OR via inline
* `«name»` template markers in their content. Returns a Map keyed by
* variable.id. Variables with no bindings are absent; callers
* default to 0. */
function countBindings(
pages: { objects: LabelObject[] }[],
variables: readonly Variable[],
): Map<string, number> {
const known = new Set(variables.map((v) => v.id));
const byName = new Map(variables.map((v) => [v.name, v.id]));
const counts = new Map<string, number>();
for (const page of pages) {
for (const obj of walkObjects(page.objects)) {
// De-dupe per OBJECT across both binding styles: a field with
// both `variableId === V` and `«V»` in its content counts as
// one usage of V, not two. Mirrors how the user thinks about
// "where is V used" — one field = one place.
const refsInThisObj = new Set<string>();
if (obj.variableId && known.has(obj.variableId)) {
counts.set(obj.variableId, (counts.get(obj.variableId) ?? 0) + 1);
refsInThisObj.add(obj.variableId);
}
const c = getObjectStringContent(obj);
if (c !== undefined) {
for (const name of extractTemplateRefs(c)) {
const id = byName.get(name);
if (id) refsInThisObj.add(id);
}
}
for (const id of refsInThisObj) {
counts.set(id, (counts.get(id) ?? 0) + 1);
}
}
}
Expand Down
103 changes: 103 additions & 0 deletions src/lib/fnTemplate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { describe, it, expect } from "vitest";
import {
hasTemplateMarkers,
extractTemplateRefs,
resolveTemplateMarkers,
embedsToMarkers,
markersToEmbeds,
pickEmbedChar,
} from "./fnTemplate";
import type { Variable } from "../types/Variable";

const vars: Variable[] = [
{ id: "a", name: "sku", fnNumber: 1, defaultValue: "DEFAULT-1" },
{ id: "b", name: "price", fnNumber: 2, defaultValue: "9.99" },
{ id: "c", name: "lot", fnNumber: 5, defaultValue: "X" },
];

describe("hasTemplateMarkers", () => {
it("matches single marker", () => {
expect(hasTemplateMarkers("hello «sku»")).toBe(true);
});
it("matches multiple markers", () => {
expect(hasTemplateMarkers("«a» and «b»")).toBe(true);
});
it("returns false on plain text", () => {
expect(hasTemplateMarkers("plain text")).toBe(false);
});
it("does not match an opening guillemet alone", () => {
expect(hasTemplateMarkers("« no closer")).toBe(false);
});
});

describe("extractTemplateRefs", () => {
it("preserves source order and duplicates", () => {
expect(extractTemplateRefs("«a» and «b» again «a»")).toEqual(["a", "b", "a"]);
});
});

describe("resolveTemplateMarkers", () => {
it("substitutes each marker via the resolver", () => {
const r = resolveTemplateMarkers("«sku»-«price»", (n) =>
n === "sku" ? "ABC" : n === "price" ? "19.99" : undefined,
);
expect(r).toBe("ABC-19.99");
});
it("leaves unknown markers literal", () => {
const r = resolveTemplateMarkers("«known»/«missing»", (n) => (n === "known" ? "v" : undefined));
expect(r).toBe("v/«missing»");
});
});

describe("embedsToMarkers", () => {
const fnToName = new Map([
[1, "sku"],
[2, "price"],
]);
it("converts whole-field embeds with default # delimiter", () => {
expect(embedsToMarkers("Hello #1#-#2#", "#", fnToName)).toBe("Hello «sku»-«price»");
});
it("converts substring embeds (slice args discarded)", () => {
expect(embedsToMarkers("#1,0,3#", "#", fnToName)).toBe("«sku»");
});
it("respects a custom embedChar set via ^FE", () => {
expect(embedsToMarkers("Hello @1@-@2@", "@", fnToName)).toBe("Hello «sku»-«price»");
});
it("leaves embeds for unknown FN numbers literal (loss-less round-trip)", () => {
expect(embedsToMarkers("#9#", "#", fnToName)).toBe("#9#");
});
});

describe("markersToEmbeds", () => {
it("emits embeds for known variable names + reports the fnNumbers used", () => {
const r = markersToEmbeds("«sku»-«price»", vars, "#");
expect(r.payload).toBe("#1#-#2#");
expect([...r.referencedFnNumbers].sort()).toEqual([1, 2]);
});
it("leaves markers literal when the named variable does not exist", () => {
const r = markersToEmbeds("«sku»-«gone»", vars, "#");
expect(r.payload).toBe("#1#-«gone»");
expect([...r.referencedFnNumbers]).toEqual([1]);
});
it("dedupes referencedFnNumbers when the same name appears twice", () => {
const r = markersToEmbeds("«sku»/«sku»", vars, "#");
expect(r.payload).toBe("#1#/#1#");
expect([...r.referencedFnNumbers]).toEqual([1]);
});
it("uses a non-default embedChar passed by the caller", () => {
const r = markersToEmbeds("«sku»-«price»", vars, "@");
expect(r.payload).toBe("@1@-@2@");
});
});

describe("pickEmbedChar", () => {
it("returns # when no payload contains it", () => {
expect(pickEmbedChar(["plain text", "more"])).toBe("#");
});
it("falls back to next candidate when # appears in any payload", () => {
expect(pickEmbedChar(["Item #SKU-1", "other"])).toBe("@");
});
it("returns null when every candidate is taken", () => {
expect(pickEmbedChar(["#@|%&?!"])).toBe(null);
});
});
Loading