Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
2 changes: 1 addition & 1 deletion docs/zpl-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ What's supported, what's next, what's planned.
- [x] `^FN` — variable placeholder
- [x] `^FV` — variable data
- [x] `^FE` — field-number embed character
- [x] `^FC` — field clock (date / time)
- [x] `^BY` — barcode field default

### Text & fonts
Expand Down Expand Up @@ -143,7 +144,6 @@ Coming with a future native build.

### Real-time data

- [ ] `^FC` — field clock (date / time)
- [ ] `^SO` — RTC offset
- [ ] `^ST` — set date & time

Expand Down
174 changes: 140 additions & 34 deletions src/components/Properties/TemplateContentInput.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState, useLayoutEffect } from "react";
import { useT } from "../../lib/useT";
import { useLabelStore } from "../../store/labelStore";
import { inputCls } from "./styles";
import { CLOCK_TOKEN_LABELS } from "../../lib/fcTemplate";

interface Props {
value: string;
Expand All @@ -14,17 +14,57 @@ interface Props {
maxLength?: number;
}

/** Tokenise content into literal / marker segments so the mirror layer
* can colour markers without breaking literal text. Marker grammar
* matches both variable markers (`«name»`) and clock markers
* (`«clock:T»`) — same `«…»` family. */
type Segment =
| { kind: "text"; text: string }
| { kind: "var" | "clock"; text: string };

const MARKER_RE = /«([^»]+)»/g;

function tokenise(content: string): Segment[] {
const out: Segment[] = [];
let last = 0;
for (const m of content.matchAll(MARKER_RE)) {
const idx = m.index ?? 0;
if (idx > last) out.push({ kind: "text", text: content.slice(last, idx) });
const body = m[1] ?? "";
out.push({ kind: body.startsWith("clock:") ? "clock" : "var", text: m[0] });
last = idx + m[0].length;
}
if (last < content.length) out.push({ kind: "text", text: content.slice(last) });
return out;
}

/**
* 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.
* Multi-line content editor for bindable fields. Renders a textarea
* over a colour-mirror layer that highlights `«…»` markers in their
* type-specific colour (variable = accent, clock = cyan). Cursor and
* selection come from the native textarea; the mirror is purely
* visual.
*
* The textarea auto-grows from 1 to MAX_ROWS as content wraps or
* gains newlines. Newlines round-trip via the existing ^FB / `\&`
* mechanism in the parser/generator — outside a ^FB block they emit
* literally and are ignored by Zebra firmware, which is the spec-
* correct fallback.
*
* 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.
* The `{x}` button opens a dropdown listing every defined Variable
* plus the canonical clock tokens; picking either splices the marker
* into the textarea at the cursor.
*/
const MIN_ROWS = 2;
const MAX_ROWS = 8;
const LINE_HEIGHT_PX = 20; // text-xs leading-5 ⇒ 20px
// Shared geometry between textarea + mirror. Any visual delta here
// causes per-char misalignment of the highlight against the cursor.
// pr-7 reserves room for the absolute `{x}` button in the top-right
// so first-line content doesn't slide under it.
const SHARED_CLS =
"w-full bg-surface-2 border border-border rounded pl-2 pr-7 py-1 text-xs font-mono leading-5 whitespace-pre-wrap break-words";

export function TemplateContentInput({
value,
onChange,
Expand All @@ -34,12 +74,29 @@ export function TemplateContentInput({
}: Props) {
const t = useT();
const variables = useLabelStore((s) => s.variables);
const inputRef = useRef<HTMLInputElement>(null);
const taRef = useRef<HTMLTextAreaElement>(null);
const mirrorRef = useRef<HTMLDivElement>(null);
const rootRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const segments = useMemo(() => tokenise(value), [value]);

// Auto-grow from MIN_ROWS up to MAX_ROWS based on actual rendered
// height (so visual word-wrap counts, not just \n count). Mirror
// matches the textarea exactly so the highlight layer stays
// aligned at every grow step.
useLayoutEffect(() => {
const ta = taRef.current;
const mirror = mirrorRef.current;
if (!ta || !mirror) return;
ta.style.height = "auto"; // reset so scrollHeight reflects content
const minH = MIN_ROWS * LINE_HEIGHT_PX + 8; // +8 = 2× py-1 padding
const maxH = MAX_ROWS * LINE_HEIGHT_PX + 8;
const h = Math.min(maxH, Math.max(minH, ta.scrollHeight));
ta.style.height = `${h}px`;
mirror.style.height = `${h}px`;
}, [value]);

// Click-outside + Esc close. Mounted only while open so the
// listeners don't fire for every other open menu in the panel.
// Click-outside + Esc close. Mounted only while open.
useEffect(() => {
if (!open) return;
const onPointerDown = (e: PointerEvent) => {
Expand All @@ -56,55 +113,104 @@ export function TemplateContentInput({
};
}, [open]);

const insertMarker = (name: string) => {
const input = inputRef.current;
const marker = `«${name}»`;
const cursor = input?.selectionStart ?? value.length;
const end = input?.selectionEnd ?? cursor;
const insertMarker = (markerBody: string) => {
const ta = taRef.current;
const marker = `«${markerBody}»`;
const cursor = ta?.selectionStart ?? value.length;
const end = ta?.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;
if (!ta) return;
const pos = cursor + marker.length;
input.focus();
input.setSelectionRange(pos, pos);
ta.focus();
ta.setSelectionRange(pos, pos);
});
};

const syncScroll = () => {
const ta = taRef.current;
const mirror = mirrorRef.current;
if (!ta || !mirror) return;
mirror.scrollTop = ta.scrollTop;
mirror.scrollLeft = ta.scrollLeft;
};

return (
<div ref={rootRef} className="relative flex gap-1">
<input
ref={inputRef}
className={`${inputCls} flex-1`}
<div ref={rootRef} className="relative">
{/* Visual layer: same geometry as the textarea, coloured marker
spans. Hidden from a11y so the screen-reader gets the
textarea value only. */}
<div
ref={mirrorRef}
className={`${SHARED_CLS} absolute inset-0 overflow-hidden pointer-events-none text-text`}
aria-hidden
>
{segments.map((s, i) =>
s.kind === "text" ? (
<span key={i}>{s.text}</span>
) : s.kind === "var" ? (
<span key={i} className="text-accent">{s.text}</span>
) : (
<span key={i} className="text-[#67c8e0]">{s.text}</span>
Comment thread
u8array marked this conversation as resolved.
Outdated
),
)}
{value.endsWith("\n") ? " " : ""}
</div>
<textarea
ref={taRef}
className={`${SHARED_CLS} relative block resize-none overflow-y-hidden caret-text focus:border-accent focus:outline-none`}
Comment thread
u8array marked this conversation as resolved.
Outdated
style={{ color: "transparent", background: "transparent" }}
value={value}
maxLength={maxLength}
placeholder={placeholder}
onChange={(e) => onChange(sanitise ? sanitise(e.target.value) : e.target.value)}
onScroll={syncScroll}
spellCheck={false}
/>
{/* Button floats inside the textarea's top-right corner so the
input keeps the full panel width. Subtle background so it
stays legible when textarea content runs under it. */}
<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"
className="absolute top-1 right-1 px-1.5 rounded text-[10px] font-mono bg-surface border border-border text-muted hover:text-text hover:border-accent transition-colors"
title={t.app.insertVariable}
disabled={variables.length === 0}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
>
{"{x}"}
</button>
{open && variables.length > 0 && (
{open && (
<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"
className="absolute right-0 top-full mt-1 z-10 min-w-[10rem] max-h-64 overflow-y-auto rounded border border-border bg-surface shadow-lg"
role="menu"
>
{variables.map((v) => (
{variables.length > 0 && (
<>
{variables.map((v) => (
<button
key={v.id}
type="button"
className="block w-full text-left px-2 py-1 text-xs font-mono text-accent hover:bg-surface-2 transition-colors"
onClick={() => insertMarker(v.name)}
>
«{v.name}»
</button>
))}
<div className="border-t border-border my-1" />
</>
)}
{CLOCK_TOKEN_LABELS.map(({ token, labelKey }) => (
<button
key={v.id}
key={token}
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)}
onClick={() => insertMarker(`clock:${token}`)}
>
«{v.name}»
<span className="text-[#67c8e0]">«clock:{token}»</span>{" "}
<span className="text-muted">{t.app[labelKey]}</span>
</button>
))}
</div>
Expand Down
130 changes: 130 additions & 0 deletions src/lib/fcTemplate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { describe, it, expect } from "vitest";
import {
hasClockMarkers,
extractClockTokens,
resolveClockMarkers,
tokensToMarkers,
markersToTokens,
pickClockChars,
isDefaultClockChars,
DEFAULT_CLOCK_CHARS,
} from "./fcTemplate";

// Fixed reference date used across formatter tests so the assertions
// stay stable regardless of when the suite runs. 2026-03-14 13:09:42
// is also a non-zero-padded day/month so the padding assertions bite.
const NOW = new Date(2026, 2, 14, 13, 9, 42);

describe("hasClockMarkers", () => {
it("matches a single «clock:T» marker", () => {
expect(hasClockMarkers("Date: «clock:d»")).toBe(true);
});
it("ignores variable markers", () => {
expect(hasClockMarkers("«var:sku»")).toBe(false);
});
});

describe("extractClockTokens", () => {
it("preserves source order + duplicates", () => {
expect(extractClockTokens("«clock:d»/«clock:m»/«clock:Y» «clock:d»"))
.toEqual(["d", "m", "Y", "d"]);
});
});

describe("resolveClockMarkers", () => {
it("substitutes each known token with the formatted current value", () => {
expect(resolveClockMarkers("«clock:Y»-«clock:m»-«clock:d»", NOW))
.toBe("2026-03-14");
});
it("pads single-digit components", () => {
expect(resolveClockMarkers("«clock:H»:«clock:M»:«clock:S»", NOW))
.toBe("13:09:42");
});
it("leaves unknown tokens literal", () => {
expect(resolveClockMarkers("«clock:Q»", NOW)).toBe("«clock:Q»");
});
});

describe("tokensToMarkers", () => {
it("converts default-char sequences to markers", () => {
expect(tokensToMarkers("Date %d/%m/%Y", DEFAULT_CLOCK_CHARS))
.toBe("Date «clock:d»/«clock:m»/«clock:Y»");
});
it("respects custom clock chars", () => {
expect(tokensToMarkers("@d/@m/@Y", { date: "@", time: "!", tertiary: "$" }))
.toBe("«clock:d»/«clock:m»/«clock:Y»");
});
it("treats all three clock chars equivalently for token recognition", () => {
// Tokens after the time and tertiary chars also get recognised —
// matches Zebra firmware behaviour where all three draw from the
// same RTC pool.
expect(tokensToMarkers("%d{H#Y", DEFAULT_CLOCK_CHARS))
.toBe("«clock:d»«clock:H»«clock:Y»");
});
it("leaves unknown letters after clock chars untouched", () => {
// `%Z` is timezone in strftime but not in our TOKEN_FORMATTERS yet.
expect(tokensToMarkers("%Z", DEFAULT_CLOCK_CHARS)).toBe("%Z");
});
});

describe("markersToTokens", () => {
it("emits the date char + token letter for every marker", () => {
expect(markersToTokens("«clock:d»/«clock:m»/«clock:Y»", DEFAULT_CLOCK_CHARS.date))
.toBe("%d/%m/%Y");
});
it("uses the supplied date char for all tokens", () => {
expect(markersToTokens("«clock:H»:«clock:M»", "@")).toBe("@H:@M");
});
it("leaves unknown token letters as literal markers", () => {
expect(markersToTokens("«clock:Q»", "%")).toBe("«clock:Q»");
});
});

describe("pickClockChars", () => {
it("returns defaults when no payload clashes", () => {
expect(pickClockChars(["plain text"])).toEqual(DEFAULT_CLOCK_CHARS);
});
it("falls back to alternates when any default appears in payloads", () => {
const r = pickClockChars(["100% match"]);
expect(r).not.toBeNull();
expect(r!.date).not.toBe("%");
});
it("returns null when every candidate for one slot clashes", () => {
// Saturate the date-candidate list — should bail.
expect(pickClockChars(["%$*+="])).toBeNull();
});
});

describe("Julian day formatter", () => {
it("treats Jan 1 as day 1", () => {
expect(resolveClockMarkers("«clock:j»", new Date(2026, 0, 1))).toBe("001");
});
it("treats Dec 31 of a non-leap year as 365", () => {
expect(resolveClockMarkers("«clock:j»", new Date(2026, 11, 31))).toBe("365");
});
it("treats Dec 31 of a leap year as 366", () => {
expect(resolveClockMarkers("«clock:j»", new Date(2024, 11, 31))).toBe("366");
});
it("doesn't off-by-one on a DST-spring-forward day", () => {
// 2026-03-29 is DST switch in central Europe. Julian day is the
// 88th day of a non-leap year (31+28+29 = 88). A naive ms-diff
// would give 87 because the day is 23 hours long.
expect(resolveClockMarkers("«clock:j»", new Date(2026, 2, 29))).toBe("088");
});
});

describe("tokensToMarkers safety", () => {
it("returns the payload unchanged when every clock char is empty", () => {
expect(tokensToMarkers("Date %d/%m", { date: "", time: "", tertiary: "" }))
.toBe("Date %d/%m");
});
});

describe("isDefaultClockChars", () => {
it("identifies the default triple", () => {
expect(isDefaultClockChars(DEFAULT_CLOCK_CHARS)).toBe(true);
});
it("rejects any deviation", () => {
expect(isDefaultClockChars({ date: "@", time: "{", tertiary: "#" })).toBe(false);
});
});
Loading