1- import { useEffect , useRef , useState } from "react" ;
1+ import { useEffect , useMemo , useRef , useState , useLayoutEffect } from "react" ;
22import { useT } from "../../lib/useT" ;
33import { useLabelStore } from "../../store/labelStore" ;
4- import { inputCls } from "./styles " ;
4+ import { CLOCK_TOKEN_LABELS } from "../../lib/fcTemplate " ;
55
66interface 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+
2868export 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 >
0 commit comments