1- import { useRef , useState , useCallback } from 'react' ;
1+ import { useRef , useState , useCallback , type FocusEvent } from 'react' ;
2+ import { PlusIcon , TrashIcon } from '@heroicons/react/16/solid' ;
23import { getAllFonts , loadFontFile , removeFont } from '../../lib/fontCache' ;
34import { useFontCacheVersion } from '../../hooks/useFontCacheVersion' ;
5+ import { useLabelStore } from '../../store/labelStore' ;
46import { useT } from '../../lib/useT' ;
7+ import {
8+ DEFAULT_FONT_DRIVE ,
9+ ZPL_DRIVE_PREFIXES ,
10+ nextFreeAlias ,
11+ normalizeAlias ,
12+ uploadedFontPath ,
13+ upsertCustomFontMapping ,
14+ } from '../../lib/customFonts' ;
515import { inputCls , labelCls } from '../Properties/styles' ;
16+ import { CollapsibleSection } from '../ui/CollapsibleSection' ;
17+ import { ConfirmDialog } from '../ui/ConfirmDialog' ;
18+ import type { CustomFontMapping } from '../../types/ObjectType' ;
19+
20+ const PATHS_DATALIST_ID = 'zpl-custom-font-paths' ;
21+
22+ const addBtnCls =
23+ 'flex items-center gap-1.5 px-2 py-1.5 rounded text-xs font-mono border border-dashed border-border text-muted hover:text-text hover:border-border-2 transition-colors' ;
624
725export function FontManager ( ) {
826 const t = useT ( ) ;
927 useFontCacheVersion ( ) ;
28+ const customFonts = useLabelStore ( ( s ) => s . label . customFonts ) ;
29+ const setLabelConfig = useLabelStore ( ( s ) => s . setLabelConfig ) ;
1030
1131 const fonts = getAllFonts ( ) ;
1232 const [ adding , setAdding ] = useState ( false ) ;
33+ const [ pendingDelete , setPendingDelete ] = useState < string | null > ( null ) ;
34+
35+ const uploadedNames = new Set ( fonts . map ( ( f ) => f . name ) ) ;
36+
37+ // Partition customFonts: mappings whose path resolves to an uploaded
38+ // font are reflected inline on that font row; the rest live in the
39+ // printer-resident sub-section. Aliases are namespaced globally per
40+ // label, so duplicate detection runs across both lists.
41+ const aliasByPath = new Map < string , string > ( ) ;
42+ const manualMappings : CustomFontMapping [ ] = [ ] ;
43+ for ( const m of customFonts ?? [ ] ) {
44+ aliasByPath . set ( m . path , m . alias ) ;
45+ const isUploadedPath =
46+ m . path . startsWith ( DEFAULT_FONT_DRIVE ) &&
47+ uploadedNames . has ( m . path . slice ( DEFAULT_FONT_DRIVE . length ) ) ;
48+ if ( ! isUploadedPath ) manualMappings . push ( m ) ;
49+ }
50+
51+ const aliasCounts = new Map < string , number > ( ) ;
52+ for ( const m of customFonts ?? [ ] ) {
53+ if ( m . alias ) aliasCounts . set ( m . alias , ( aliasCounts . get ( m . alias ) ?? 0 ) + 1 ) ;
54+ }
55+ const isDuplicateAlias = ( alias : string ) =>
56+ ! ! alias && ( aliasCounts . get ( alias ) ?? 0 ) > 1 ;
57+
58+ const replaceList = ( next : CustomFontMapping [ ] ) => {
59+ setLabelConfig ( { customFonts : next . length > 0 ? next : undefined } ) ;
60+ } ;
61+
62+ const setAliasForPath = ( path : string , rawAlias : string ) => {
63+ replaceList (
64+ upsertCustomFontMapping ( customFonts , path , normalizeAlias ( rawAlias ) ) ,
65+ ) ;
66+ } ;
67+
68+ const updateManualAt = ( path : string , patch : Partial < CustomFontMapping > ) => {
69+ const list = customFonts ?? [ ] ;
70+ replaceList (
71+ list . map ( ( m ) =>
72+ m . path === path
73+ ? {
74+ alias :
75+ patch . alias !== undefined
76+ ? normalizeAlias ( patch . alias )
77+ : m . alias ,
78+ path : patch . path ?? m . path ,
79+ }
80+ : m ,
81+ ) ,
82+ ) ;
83+ } ;
84+
85+ const removeByPath = ( path : string ) => {
86+ replaceList ( ( customFonts ?? [ ] ) . filter ( ( m ) => m . path !== path ) ) ;
87+ } ;
88+
89+ const addManual = ( ) => {
90+ // Suggest the next free letter from the I-Z 1-9 range so the user
91+ // does not accidentally override a built-in Zebra font letter. They
92+ // can still type any letter manually if they want the override.
93+ const taken = ( customFonts ?? [ ] ) . map ( ( m ) => m . alias ) . filter ( Boolean ) ;
94+ replaceList ( [
95+ ...( customFonts ?? [ ] ) ,
96+ { alias : nextFreeAlias ( taken ) , path : '' } ,
97+ ] ) ;
98+ } ;
99+
100+ const uploadedPaths = fonts . map ( ( f ) => uploadedFontPath ( f . name ) ) ;
13101
14102 return (
15103 < div className = "p-3 flex flex-col gap-3" >
@@ -22,23 +110,69 @@ export function FontManager() {
22110 ) }
23111
24112 < div className = "flex flex-col gap-1" >
25- { fonts . map ( ( font ) => (
26- < FontEntry key = { font . name } name = { font . name } />
27- ) ) }
113+ { fonts . map ( ( font ) => {
114+ const path = uploadedFontPath ( font . name ) ;
115+ const alias = aliasByPath . get ( path ) ?? '' ;
116+ return (
117+ < FontEntry
118+ key = { font . name }
119+ name = { font . name }
120+ alias = { alias }
121+ duplicate = { isDuplicateAlias ( alias ) }
122+ onAliasChange = { ( v ) => setAliasForPath ( path , v ) }
123+ onRequestDelete = { ( ) => setPendingDelete ( font . name ) }
124+ />
125+ ) ;
126+ } ) }
28127 </ div >
29128
30129 { adding ? (
31130 < AddFontForm onDone = { ( ) => setAdding ( false ) } />
32131 ) : (
33- < button
34- type = "button"
35- className = "flex items-center gap-1.5 px-2 py-1.5 rounded text-xs font-mono border border-dashed border-border text-muted hover:text-text hover:border-border-2 transition-colors"
36- onClick = { ( ) => setAdding ( true ) }
37- >
132+ < button type = "button" className = { addBtnCls } onClick = { ( ) => setAdding ( true ) } >
38133 < span className = "text-accent" > +</ span >
39134 { t . fonts . addFont }
40135 </ button >
41136 ) }
137+
138+ < CollapsibleSection
139+ id = "fonts-printer-resident"
140+ title = { t . fonts . manualMappingsHeading }
141+ defaultOpen = { false }
142+ >
143+ < ManualMappingsSection
144+ mappings = { manualMappings }
145+ hint = { t . fonts . manualMappingsHint }
146+ addLabel = { t . fonts . addManualMapping }
147+ isDuplicateAlias = { isDuplicateAlias }
148+ onUpdate = { updateManualAt }
149+ onRemove = { removeByPath }
150+ onAdd = { addManual }
151+ />
152+ </ CollapsibleSection >
153+
154+ < datalist id = { PATHS_DATALIST_ID } >
155+ { ZPL_DRIVE_PREFIXES . map ( ( p ) => (
156+ < option key = { p } value = { p } />
157+ ) ) }
158+ { uploadedPaths . map ( ( p ) => (
159+ < option key = { p } value = { p } />
160+ ) ) }
161+ </ datalist >
162+
163+ { pendingDelete !== null && (
164+ < ConfirmDialog
165+ message = { t . fonts . deleteConfirm }
166+ confirmLabel = { t . fonts . delete }
167+ cancelLabel = { t . app . cancel }
168+ destructive
169+ onConfirm = { ( ) => {
170+ removeFont ( pendingDelete ) ;
171+ setPendingDelete ( null ) ;
172+ } }
173+ onCancel = { ( ) => setPendingDelete ( null ) }
174+ />
175+ ) }
42176 </ div >
43177 ) ;
44178}
@@ -47,18 +181,48 @@ export function FontManager() {
47181
48182interface FontEntryProps {
49183 name : string ;
184+ alias : string ;
185+ duplicate : boolean ;
186+ onAliasChange : ( next : string ) => void ;
187+ onRequestDelete : ( ) => void ;
50188}
51189
52- function FontEntry ( { name } : FontEntryProps ) {
190+ function FontEntry ( {
191+ name,
192+ alias,
193+ duplicate,
194+ onAliasChange,
195+ onRequestDelete,
196+ } : FontEntryProps ) {
53197 const t = useT ( ) ;
54198
55199 return (
56- < div className = "group flex items-center gap-2 px-2 py-1.5 rounded border border-transparent hover:border-border-2 hover:bg-surface-2 transition-colors" >
57- < span className = "font-mono text-[11px] text-accent w-4 text-center shrink-0" > F</ span >
58- < span className = "flex-1 font-mono text-xs text-text truncate" > { name } </ span >
200+ < div className = "group grid grid-cols-[1fr_3rem_auto] items-center gap-2 px-2 py-1.5 rounded border border-transparent hover:border-border-2 hover:bg-surface-2 transition-colors" >
201+ < span
202+ className = "font-mono text-xs text-text truncate"
203+ title = { name }
204+ >
205+ { name }
206+ </ span >
207+ < input
208+ type = "text"
209+ className = { `${ inputCls } text-center ${ duplicate ? 'border-red-500' : '' } ` }
210+ maxLength = { 1 }
211+ placeholder = "A-Z"
212+ title = {
213+ duplicate
214+ ? t . label . customFontsDuplicateAlias
215+ : alias
216+ ? t . fonts . aliasAssigned
217+ : t . fonts . aliasHint
218+ }
219+ aria-invalid = { duplicate || undefined }
220+ value = { alias }
221+ onChange = { ( e ) => onAliasChange ( e . target . value ) }
222+ />
59223 < button
60224 type = "button"
61- onClick = { ( ) => removeFont ( name ) }
225+ onClick = { onRequestDelete }
62226 className = "opacity-0 group-hover:opacity-100 font-mono text-[10px] text-muted hover:text-red-400 transition-all px-1"
63227 title = { t . fonts . delete }
64228 aria-label = { t . fonts . delete }
@@ -69,6 +233,103 @@ function FontEntry({ name }: FontEntryProps) {
69233 ) ;
70234}
71235
236+ // ── ManualMappingsSection ──────────────────────────────────────────────────────
237+
238+ interface ManualMappingsSectionProps {
239+ mappings : CustomFontMapping [ ] ;
240+ hint : string ;
241+ addLabel : string ;
242+ isDuplicateAlias : ( alias : string ) => boolean ;
243+ onUpdate : ( currentPath : string , patch : Partial < CustomFontMapping > ) => void ;
244+ onRemove : ( path : string ) => void ;
245+ onAdd : ( ) => void ;
246+ }
247+
248+ function ManualMappingsSection ( {
249+ mappings,
250+ hint,
251+ addLabel,
252+ isDuplicateAlias,
253+ onUpdate,
254+ onRemove,
255+ onAdd,
256+ } : ManualMappingsSectionProps ) {
257+ const t = useT ( ) ;
258+ // Auto-remove rows whose alias AND path are both empty when focus
259+ // actually leaves the row container. requestAnimationFrame defers
260+ // the check until the new focus has landed, then we confirm the row
261+ // no longer contains it — tabbing between the row's own inputs does
262+ // not count as "leaving".
263+ const handleBlur = (
264+ e : FocusEvent < HTMLDivElement > ,
265+ path : string ,
266+ alias : string ,
267+ ) => {
268+ const row = e . currentTarget ;
269+ requestAnimationFrame ( ( ) => {
270+ if ( ! alias && ! path && ! row . contains ( document . activeElement ) ) {
271+ onRemove ( path ) ;
272+ }
273+ } ) ;
274+ } ;
275+
276+ return (
277+ < div className = "flex flex-col gap-2" >
278+ < p className = "text-xs text-muted px-1 leading-relaxed" > { hint } </ p >
279+ { mappings . map ( ( m ) => {
280+ const dup = isDuplicateAlias ( m . alias ) ;
281+ // Key: stable across edits as long as alias and path don't
282+ // change at the same time. For fresh empty rows the auto-
283+ // assigned alias from nextFreeAlias is unique per row, which
284+ // gives a stable identity until the user types a path.
285+ const rowKey = m . path || `__alias__${ m . alias } ` ;
286+ return (
287+ < div
288+ key = { rowKey }
289+ className = "grid grid-cols-[3rem_1fr_auto] gap-2 items-center"
290+ onBlur = { ( e ) => handleBlur ( e , m . path , m . alias ) }
291+ >
292+ < input
293+ type = "text"
294+ className = { `${ inputCls } text-center ${ dup ? 'border-red-500' : '' } ` }
295+ maxLength = { 1 }
296+ placeholder = "A-Z"
297+ title = {
298+ dup
299+ ? t . label . customFontsDuplicateAlias
300+ : t . label . customFontsAliasHint
301+ }
302+ aria-invalid = { dup || undefined }
303+ value = { m . alias }
304+ onChange = { ( e ) => onUpdate ( m . path , { alias : e . target . value } ) }
305+ />
306+ < input
307+ type = "text"
308+ className = { inputCls }
309+ list = { PATHS_DATALIST_ID }
310+ placeholder = { t . label . customFontsPath }
311+ value = { m . path }
312+ onChange = { ( e ) => onUpdate ( m . path , { path : e . target . value } ) }
313+ />
314+ < button
315+ type = "button"
316+ className = "p-1 text-muted hover:text-text"
317+ onClick = { ( ) => onRemove ( m . path ) }
318+ aria-label = { t . label . customFontsRemove }
319+ >
320+ < TrashIcon className = "w-3.5 h-3.5" />
321+ </ button >
322+ </ div >
323+ ) ;
324+ } ) }
325+ < button type = "button" className = { addBtnCls } onClick = { onAdd } >
326+ < PlusIcon className = "w-3 h-3 text-accent" />
327+ { addLabel }
328+ </ button >
329+ </ div >
330+ ) ;
331+ }
332+
72333// ── AddFontForm ────────────────────────────────────────────────────────────────
73334
74335interface AddFontFormProps {
@@ -83,7 +344,10 @@ function AddFontForm({ onDone }: AddFontFormProps) {
83344 const [ uploadFailed , setUploadFailed ] = useState ( false ) ;
84345
85346 const handleFileChange = useCallback ( async ( file : File ) => {
86- const printerName = name . trim ( ) || file . name ;
347+ // Default to the source filename uppercased — Zebra printer storage
348+ // conventionally uses uppercase ALL.TTF style identifiers, and a
349+ // freshly-picked file is almost always the user's intended name.
350+ const printerName = name . trim ( ) || file . name . toUpperCase ( ) ;
87351 setUploading ( true ) ;
88352 setUploadFailed ( false ) ;
89353 try {
0 commit comments