Skip to content

Commit 7a4e068

Browse files
authored
Merge pull request #93 from u8array/feat/zpl-fc-clock
Feat/zpl fc clock
2 parents be0e190 + 41aa12b commit 7a4e068

45 files changed

Lines changed: 938 additions & 75 deletions

Some content is hidden

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

‎docs/zpl-roadmap.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ What's supported, what's next, what's planned.
4040
- [x] `^FN` — variable placeholder
4141
- [x] `^FV` — variable data
4242
- [x] `^FE` — field-number embed character
43+
- [x] `^FC` — field clock (date / time)
4344
- [x] `^BY` — barcode field default
4445

4546
### Text & fonts
@@ -143,7 +144,6 @@ Coming with a future native build.
143144

144145
### Real-time data
145146

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

‎src/components/Properties/TemplateContentInput.tsx‎

Lines changed: 145 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { useEffect, useRef, useState } from "react";
1+
import { useEffect, useMemo, useRef, useState, useLayoutEffect } from "react";
22
import { useT } from "../../lib/useT";
33
import { useLabelStore } from "../../store/labelStore";
4-
import { inputCls } from "./styles";
4+
import { CLOCK_TOKEN_LABELS } from "../../lib/fcTemplate";
55

66
interface Props {
77
value: string;
@@ -14,17 +14,57 @@ interface Props {
1414
maxLength?: number;
1515
}
1616

17+
/** Tokenise content into literal / marker segments so the mirror layer
18+
* can colour markers without breaking literal text. Marker grammar
19+
* matches both variable markers (`«name»`) and clock markers
20+
* (`«clock:T»`) — same `«…»` family. */
21+
type Segment =
22+
| { kind: "text"; text: string }
23+
| { kind: "var" | "clock"; text: string };
24+
25+
const MARKER_RE = /«([^»]+)»/g;
26+
27+
function tokenise(content: string): Segment[] {
28+
const out: Segment[] = [];
29+
let last = 0;
30+
for (const m of content.matchAll(MARKER_RE)) {
31+
const idx = m.index ?? 0;
32+
if (idx > last) out.push({ kind: "text", text: content.slice(last, idx) });
33+
const body = m[1] ?? "";
34+
out.push({ kind: body.startsWith("clock:") ? "clock" : "var", text: m[0] });
35+
last = idx + m[0].length;
36+
}
37+
if (last < content.length) out.push({ kind: "text", text: content.slice(last) });
38+
return out;
39+
}
40+
1741
/**
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.
42+
* Multi-line content editor for bindable fields. Renders a textarea
43+
* over a colour-mirror layer that highlights `«…»` markers in their
44+
* type-specific colour (variable = accent, clock = cyan). Cursor and
45+
* selection come from the native textarea; the mirror is purely
46+
* visual.
47+
*
48+
* The textarea auto-grows from 1 to MAX_ROWS as content wraps or
49+
* gains newlines. Newlines round-trip via the existing ^FB / `\&`
50+
* mechanism in the parser/generator — outside a ^FB block they emit
51+
* literally and are ignored by Zebra firmware, which is the spec-
52+
* correct fallback.
2353
*
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.
54+
* The `{x}` button opens a dropdown listing every defined Variable
55+
* plus the canonical clock tokens; picking either splices the marker
56+
* into the textarea at the cursor.
2757
*/
58+
const MIN_ROWS = 2;
59+
const MAX_ROWS = 8;
60+
const LINE_HEIGHT_PX = 20; // text-xs leading-5 ⇒ 20px
61+
// Shared geometry between textarea + mirror. Any visual delta here
62+
// causes per-char misalignment of the highlight against the cursor.
63+
// pr-7 reserves room for the absolute `{x}` button in the top-right
64+
// so first-line content doesn't slide under it.
65+
const SHARED_CLS =
66+
"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";
67+
2868
export function TemplateContentInput({
2969
value,
3070
onChange,
@@ -34,12 +74,29 @@ export function TemplateContentInput({
3474
}: Props) {
3575
const t = useT();
3676
const variables = useLabelStore((s) => s.variables);
37-
const inputRef = useRef<HTMLInputElement>(null);
77+
const taRef = useRef<HTMLTextAreaElement>(null);
78+
const mirrorRef = useRef<HTMLDivElement>(null);
3879
const rootRef = useRef<HTMLDivElement>(null);
3980
const [open, setOpen] = useState(false);
81+
const segments = useMemo(() => tokenise(value), [value]);
82+
83+
// Auto-grow from MIN_ROWS up to MAX_ROWS based on actual rendered
84+
// height (so visual word-wrap counts, not just \n count). Mirror
85+
// matches the textarea exactly so the highlight layer stays
86+
// aligned at every grow step.
87+
useLayoutEffect(() => {
88+
const ta = taRef.current;
89+
const mirror = mirrorRef.current;
90+
if (!ta || !mirror) return;
91+
ta.style.height = "auto"; // reset so scrollHeight reflects content
92+
const minH = MIN_ROWS * LINE_HEIGHT_PX + 8; // +8 = 2× py-1 padding
93+
const maxH = MAX_ROWS * LINE_HEIGHT_PX + 8;
94+
const h = Math.min(maxH, Math.max(minH, ta.scrollHeight));
95+
ta.style.height = `${h}px`;
96+
mirror.style.height = `${h}px`;
97+
}, [value]);
4098

41-
// Click-outside + Esc close. Mounted only while open so the
42-
// listeners don't fire for every other open menu in the panel.
99+
// Click-outside + Esc close. Mounted only while open.
43100
useEffect(() => {
44101
if (!open) return;
45102
const onPointerDown = (e: PointerEvent) => {
@@ -56,55 +113,109 @@ export function TemplateContentInput({
56113
};
57114
}, [open]);
58115

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;
116+
const insertMarker = (markerBody: string) => {
117+
const ta = taRef.current;
118+
const marker = `«${markerBody}»`;
119+
const cursor = ta?.selectionStart ?? value.length;
120+
const end = ta?.selectionEnd ?? cursor;
64121
const next = value.slice(0, cursor) + marker + value.slice(end);
65122
onChange(next);
66123
setOpen(false);
67-
// Restore focus + place cursor right after the inserted marker.
68124
queueMicrotask(() => {
69-
if (!input) return;
125+
if (!ta) return;
70126
const pos = cursor + marker.length;
71-
input.focus();
72-
input.setSelectionRange(pos, pos);
127+
ta.focus();
128+
ta.setSelectionRange(pos, pos);
73129
});
74130
};
75131

132+
const syncScroll = () => {
133+
const ta = taRef.current;
134+
const mirror = mirrorRef.current;
135+
if (!ta || !mirror) return;
136+
mirror.scrollTop = ta.scrollTop;
137+
mirror.scrollLeft = ta.scrollLeft;
138+
};
139+
76140
return (
77-
<div ref={rootRef} className="relative flex gap-1">
78-
<input
79-
ref={inputRef}
80-
className={`${inputCls} flex-1`}
141+
<div ref={rootRef} className="relative">
142+
{/* Visual layer: same geometry as the textarea, coloured marker
143+
spans. Hidden from a11y so the screen-reader gets the
144+
textarea value only. */}
145+
<div
146+
ref={mirrorRef}
147+
className={`${SHARED_CLS} absolute inset-0 overflow-hidden pointer-events-none text-text`}
148+
aria-hidden
149+
>
150+
{segments.map((s, i) =>
151+
s.kind === "text" ? (
152+
<span key={i}>{s.text}</span>
153+
) : s.kind === "var" ? (
154+
<span key={i} className="text-accent">{s.text}</span>
155+
) : (
156+
<span key={i} className="text-info">{s.text}</span>
157+
),
158+
)}
159+
{value.endsWith("\n") ? " " : ""}
160+
</div>
161+
<textarea
162+
ref={taRef}
163+
// overflow-y-auto so content beyond MAX_ROWS stays reachable;
164+
// scrollbar visually hidden because the mirror layer doesn't
165+
// reserve space for one — a visible scrollbar would shift the
166+
// textarea's text origin and misalign the colour highlight.
167+
// Wheel + keyboard scrolling stay functional via syncScroll.
168+
className={`${SHARED_CLS} relative block resize-none overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden caret-text focus:border-accent focus:outline-none`}
169+
style={{ color: "transparent", background: "transparent" }}
81170
value={value}
82171
maxLength={maxLength}
83172
placeholder={placeholder}
84173
onChange={(e) => onChange(sanitise ? sanitise(e.target.value) : e.target.value)}
174+
onScroll={syncScroll}
175+
spellCheck={false}
85176
/>
177+
{/* Button floats inside the textarea's top-right corner so the
178+
input keeps the full panel width. Subtle background so it
179+
stays legible when textarea content runs under it. */}
86180
<button
87181
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"
182+
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"
89183
title={t.app.insertVariable}
90-
disabled={variables.length === 0}
184+
aria-haspopup="menu"
185+
aria-expanded={open}
91186
onClick={() => setOpen((o) => !o)}
92187
>
93188
{"{x}"}
94189
</button>
95-
{open && variables.length > 0 && (
190+
{open && (
96191
<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"
192+
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"
98193
role="menu"
99194
>
100-
{variables.map((v) => (
195+
{variables.length > 0 && (
196+
<>
197+
{variables.map((v) => (
198+
<button
199+
key={v.id}
200+
type="button"
201+
className="block w-full text-left px-2 py-1 text-xs font-mono text-accent hover:bg-surface-2 transition-colors"
202+
onClick={() => insertMarker(v.name)}
203+
>
204+
«{v.name}»
205+
</button>
206+
))}
207+
<div className="border-t border-border my-1" />
208+
</>
209+
)}
210+
{CLOCK_TOKEN_LABELS.map(({ token, labelKey }) => (
101211
<button
102-
key={v.id}
212+
key={token}
103213
type="button"
104214
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)}
215+
onClick={() => insertMarker(`clock:${token}`)}
106216
>
107-
«{v.name}»
217+
<span className="text-info">«clock:{token}»</span>{" "}
218+
<span className="text-muted">{t.app[labelKey]}</span>
108219
</button>
109220
))}
110221
</div>

‎src/index.css‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
2424
--color-muted: #666666;
2525
--color-accent: #f59e0b;
2626
--color-accent-dim:#78490a;
27+
/* Secondary highlight, e.g. clock-token markers in the content
28+
editor — distinct from accent so the editor can colour-code
29+
marker families without collision. */
30+
--color-info: #67c8e0;
2731

2832
--font-sans: 'IBM Plex Sans', ui-sans-serif, system-ui, sans-serif;
2933
--font-mono: 'IBM Plex Mono', ui-monospace, monospace;
@@ -43,6 +47,7 @@
4347
--color-muted: #52525b;
4448
--color-accent: #d97706;
4549
--color-accent-dim:#fef3c7;
50+
--color-info: #0e7490;
4651
}
4752

4853
*, *::before, *::after {

0 commit comments

Comments
 (0)