Skip to content

Commit be0e190

Browse files
authored
Merge pull request #92 from u8array/feat/zpl-fe-templates
Feat/zpl fe templates
2 parents f41ad42 + 47229ff commit be0e190

50 files changed

Lines changed: 863 additions & 51 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ The **ZPL output** panel at the bottom shows the generated ZPL. It updates in re
6464

6565
File menu → **Import ZPL**: paste ZPL code directly, or open a `.zpl` file.
6666

67-
> 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.
67+
> 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.
6868
6969
### Multiple labels (pages)
7070

@@ -102,7 +102,7 @@ Both `.zpl` and `.json` round-trip cleanly. `.zpl` preserves all printable conte
102102

103103
- Smart alignment and spacing guides
104104
- Layers panel with reordering
105-
- Variables: bind text and barcode fields to named defaults that emit as `^FN`/`^FV` slots, round-tripping with printer-side templates
105+
- 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
106106
- 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)
107107
- 32 UI languages (auto-detected from browser)
108108
- Light / dark mode (follows OS setting)

‎docs/zpl-roadmap.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ What's supported, what's next, what's planned.
3939
- [x] `^TB` — text block
4040
- [x] `^FN` — variable placeholder
4141
- [x] `^FV` — variable data
42+
- [x] `^FE` — field-number embed character
4243
- [x] `^BY` — barcode field default
4344

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

105106
### Fields
106107

107-
- [ ] `^FE` — field concatenation
108108
- [ ] `^FM` — multiple field origins
109109
- [ ] `^FP` — field path (text along path)
110110
- [ ] `^CO` — font cache size
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { useEffect, useRef, useState } from "react";
2+
import { useT } from "../../lib/useT";
3+
import { useLabelStore } from "../../store/labelStore";
4+
import { inputCls } from "./styles";
5+
6+
interface Props {
7+
value: string;
8+
onChange: (next: string) => void;
9+
/** Optional sanitiser for restricted-charset fields (e.g. numeric-only
10+
* barcodes). Applied to typed input only — template markers are
11+
* inserted verbatim regardless. */
12+
sanitise?: (raw: string) => string;
13+
placeholder?: string;
14+
maxLength?: number;
15+
}
16+
17+
/**
18+
* Text input + "Insert variable" button. The button opens a small
19+
* dropdown listing every defined Variable; picking one splices its
20+
* `«name»` marker into the input at the current cursor position.
21+
* Templates resolve at render time via applyBindingToObject — see
22+
* lib/fnTemplate + lib/variableBinding.
23+
*
24+
* Used by Text and 1D-barcode properties panels in place of the
25+
* plain input so non-technical users can compose multi-variable
26+
* fields without typing the marker syntax by hand.
27+
*/
28+
export function TemplateContentInput({
29+
value,
30+
onChange,
31+
sanitise,
32+
placeholder,
33+
maxLength,
34+
}: Props) {
35+
const t = useT();
36+
const variables = useLabelStore((s) => s.variables);
37+
const inputRef = useRef<HTMLInputElement>(null);
38+
const rootRef = useRef<HTMLDivElement>(null);
39+
const [open, setOpen] = useState(false);
40+
41+
// Click-outside + Esc close. Mounted only while open so the
42+
// listeners don't fire for every other open menu in the panel.
43+
useEffect(() => {
44+
if (!open) return;
45+
const onPointerDown = (e: PointerEvent) => {
46+
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
47+
};
48+
const onKey = (e: KeyboardEvent) => {
49+
if (e.key === "Escape") setOpen(false);
50+
};
51+
document.addEventListener("pointerdown", onPointerDown);
52+
document.addEventListener("keydown", onKey);
53+
return () => {
54+
document.removeEventListener("pointerdown", onPointerDown);
55+
document.removeEventListener("keydown", onKey);
56+
};
57+
}, [open]);
58+
59+
const insertMarker = (name: string) => {
60+
const input = inputRef.current;
61+
const marker = `«${name}»`;
62+
const cursor = input?.selectionStart ?? value.length;
63+
const end = input?.selectionEnd ?? cursor;
64+
const next = value.slice(0, cursor) + marker + value.slice(end);
65+
onChange(next);
66+
setOpen(false);
67+
// Restore focus + place cursor right after the inserted marker.
68+
queueMicrotask(() => {
69+
if (!input) return;
70+
const pos = cursor + marker.length;
71+
input.focus();
72+
input.setSelectionRange(pos, pos);
73+
});
74+
};
75+
76+
return (
77+
<div ref={rootRef} className="relative flex gap-1">
78+
<input
79+
ref={inputRef}
80+
className={`${inputCls} flex-1`}
81+
value={value}
82+
maxLength={maxLength}
83+
placeholder={placeholder}
84+
onChange={(e) => onChange(sanitise ? sanitise(e.target.value) : e.target.value)}
85+
/>
86+
<button
87+
type="button"
88+
className="px-2 rounded border border-border bg-surface-2 text-xs font-mono text-muted hover:text-text hover:border-accent transition-colors"
89+
title={t.app.insertVariable}
90+
disabled={variables.length === 0}
91+
onClick={() => setOpen((o) => !o)}
92+
>
93+
{"{x}"}
94+
</button>
95+
{open && variables.length > 0 && (
96+
<div
97+
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"
98+
role="menu"
99+
>
100+
{variables.map((v) => (
101+
<button
102+
key={v.id}
103+
type="button"
104+
className="block w-full text-left px-2 py-1 text-xs font-mono text-text hover:bg-surface-2 transition-colors"
105+
onClick={() => insertMarker(v.name)}
106+
>
107+
«{v.name}»
108+
</button>
109+
))}
110+
</div>
111+
)}
112+
</div>
113+
);
114+
}

‎src/components/Variables/VariableBindingControl.tsx‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { LabelObject } from '../../types/Group';
55
import { inputCls } from '../Properties/styles';
66
import { FieldLabel } from '../ui/FieldLabel';
77
import { useT } from '../../lib/useT';
8+
import { getObjectStringContent } from '../../lib/variableBinding';
89

910
const CREATE_NEW_SENTINEL = '__create_new__';
1011

@@ -58,9 +59,7 @@ export function VariableBindingControl({ obj }: Props) {
5859
// field is currently carrying, preserving the canvas state across
5960
// the binding transition. Every bindable type's first ^FD emission
6061
// comes from `props.content` (see registry implementations).
61-
const props = (obj as { props?: { content?: unknown } }).props;
62-
const defaultValue =
63-
typeof props?.content === 'string' ? props.content : '';
62+
const defaultValue = getObjectStringContent(obj) ?? '';
6463
const id = addVariable({ name: trimmed, defaultValue });
6564
if (id === null) {
6665
// Two reasons addVariable returns null: name collision or no free

‎src/components/Variables/VariablesPanel.tsx‎

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ import { ConfirmDialog } from '../ui/ConfirmDialog';
2222
import { FieldLabel } from '../ui/FieldLabel';
2323
import { useT } from '../../lib/useT';
2424
import type { Translations } from '../../locales';
25-
import { getVariableSource, type VariableSource } from '../../lib/variableBinding';
25+
import { getObjectStringContent, getVariableSource, type VariableSource } from '../../lib/variableBinding';
26+
import { extractTemplateRefs } from '../../lib/fnTemplate';
2627
import { VariableSourceBadge } from './VariableSourceBadge';
2728

2829
export function VariablesPanel() {
@@ -487,18 +488,36 @@ function formatDeleteMessage(
487488
}
488489

489490
/** Walk every page (groups too) and tally how many fields reference each
490-
* variable. Returns a Map keyed by variable.id. Variables with no
491-
* bindings are absent from the map; callers default to 0. */
491+
* variable, either via single-bind `variableId` OR via inline
492+
* `«name»` template markers in their content. Returns a Map keyed by
493+
* variable.id. Variables with no bindings are absent; callers
494+
* default to 0. */
492495
function countBindings(
493496
pages: { objects: LabelObject[] }[],
494497
variables: readonly Variable[],
495498
): Map<string, number> {
496499
const known = new Set(variables.map((v) => v.id));
500+
const byName = new Map(variables.map((v) => [v.name, v.id]));
497501
const counts = new Map<string, number>();
498502
for (const page of pages) {
499503
for (const obj of walkObjects(page.objects)) {
504+
// De-dupe per OBJECT across both binding styles: a field with
505+
// both `variableId === V` and `«V»` in its content counts as
506+
// one usage of V, not two. Mirrors how the user thinks about
507+
// "where is V used" — one field = one place.
508+
const refsInThisObj = new Set<string>();
500509
if (obj.variableId && known.has(obj.variableId)) {
501-
counts.set(obj.variableId, (counts.get(obj.variableId) ?? 0) + 1);
510+
refsInThisObj.add(obj.variableId);
511+
}
512+
const c = getObjectStringContent(obj);
513+
if (c !== undefined) {
514+
for (const name of extractTemplateRefs(c)) {
515+
const id = byName.get(name);
516+
if (id) refsInThisObj.add(id);
517+
}
518+
}
519+
for (const id of refsInThisObj) {
520+
counts.set(id, (counts.get(id) ?? 0) + 1);
502521
}
503522
}
504523
}

‎src/lib/fnTemplate.test.ts‎

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { describe, it, expect } from "vitest";
2+
import {
3+
hasTemplateMarkers,
4+
extractTemplateRefs,
5+
resolveTemplateMarkers,
6+
embedsToMarkers,
7+
markersToEmbeds,
8+
pickEmbedChar,
9+
} from "./fnTemplate";
10+
import type { Variable } from "../types/Variable";
11+
12+
const vars: Variable[] = [
13+
{ id: "a", name: "sku", fnNumber: 1, defaultValue: "DEFAULT-1" },
14+
{ id: "b", name: "price", fnNumber: 2, defaultValue: "9.99" },
15+
{ id: "c", name: "lot", fnNumber: 5, defaultValue: "X" },
16+
];
17+
18+
describe("hasTemplateMarkers", () => {
19+
it("matches single marker", () => {
20+
expect(hasTemplateMarkers("hello «sku»")).toBe(true);
21+
});
22+
it("matches multiple markers", () => {
23+
expect(hasTemplateMarkers("«a» and «b»")).toBe(true);
24+
});
25+
it("returns false on plain text", () => {
26+
expect(hasTemplateMarkers("plain text")).toBe(false);
27+
});
28+
it("does not match an opening guillemet alone", () => {
29+
expect(hasTemplateMarkers("« no closer")).toBe(false);
30+
});
31+
});
32+
33+
describe("extractTemplateRefs", () => {
34+
it("preserves source order and duplicates", () => {
35+
expect(extractTemplateRefs("«a» and «b» again «a»")).toEqual(["a", "b", "a"]);
36+
});
37+
});
38+
39+
describe("resolveTemplateMarkers", () => {
40+
it("substitutes each marker via the resolver", () => {
41+
const r = resolveTemplateMarkers("«sku»-«price»", (n) =>
42+
n === "sku" ? "ABC" : n === "price" ? "19.99" : undefined,
43+
);
44+
expect(r).toBe("ABC-19.99");
45+
});
46+
it("leaves unknown markers literal", () => {
47+
const r = resolveTemplateMarkers("«known»/«missing»", (n) => (n === "known" ? "v" : undefined));
48+
expect(r).toBe("v/«missing»");
49+
});
50+
});
51+
52+
describe("embedsToMarkers", () => {
53+
const fnToName = new Map([
54+
[1, "sku"],
55+
[2, "price"],
56+
]);
57+
it("converts whole-field embeds with default # delimiter", () => {
58+
expect(embedsToMarkers("Hello #1#-#2#", "#", fnToName)).toBe("Hello «sku»-«price»");
59+
});
60+
it("converts substring embeds (slice args discarded)", () => {
61+
expect(embedsToMarkers("#1,0,3#", "#", fnToName)).toBe("«sku»");
62+
});
63+
it("respects a custom embedChar set via ^FE", () => {
64+
expect(embedsToMarkers("Hello @1@-@2@", "@", fnToName)).toBe("Hello «sku»-«price»");
65+
});
66+
it("leaves embeds for unknown FN numbers literal (loss-less round-trip)", () => {
67+
expect(embedsToMarkers("#9#", "#", fnToName)).toBe("#9#");
68+
});
69+
});
70+
71+
describe("markersToEmbeds", () => {
72+
it("emits embeds for known variable names + reports the fnNumbers used", () => {
73+
const r = markersToEmbeds("«sku»-«price»", vars, "#");
74+
expect(r.payload).toBe("#1#-#2#");
75+
expect([...r.referencedFnNumbers].sort()).toEqual([1, 2]);
76+
});
77+
it("leaves markers literal when the named variable does not exist", () => {
78+
const r = markersToEmbeds("«sku»-«gone»", vars, "#");
79+
expect(r.payload).toBe("#1#-«gone»");
80+
expect([...r.referencedFnNumbers]).toEqual([1]);
81+
});
82+
it("dedupes referencedFnNumbers when the same name appears twice", () => {
83+
const r = markersToEmbeds("«sku»/«sku»", vars, "#");
84+
expect(r.payload).toBe("#1#/#1#");
85+
expect([...r.referencedFnNumbers]).toEqual([1]);
86+
});
87+
it("uses a non-default embedChar passed by the caller", () => {
88+
const r = markersToEmbeds("«sku»-«price»", vars, "@");
89+
expect(r.payload).toBe("@1@-@2@");
90+
});
91+
});
92+
93+
describe("pickEmbedChar", () => {
94+
it("returns # when no payload contains it", () => {
95+
expect(pickEmbedChar(["plain text", "more"])).toBe("#");
96+
});
97+
it("falls back to next candidate when # appears in any payload", () => {
98+
expect(pickEmbedChar(["Item #SKU-1", "other"])).toBe("@");
99+
});
100+
it("returns null when every candidate is taken", () => {
101+
expect(pickEmbedChar(["#@|%&?!"])).toBe(null);
102+
});
103+
});

0 commit comments

Comments
 (0)