Skip to content

Commit 6a5c8c6

Browse files
authored
Merge pull request #75 from u8array/feat/zpl-cw-custom-fonts
Feat/zpl cw custom fonts
2 parents c925a25 + 6bab337 commit 6a5c8c6

41 files changed

Lines changed: 1156 additions & 25 deletions

Some content is hidden

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

‎src/components/Fonts/FontManager.tsx‎

Lines changed: 279 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,103 @@
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';
23
import { getAllFonts, loadFontFile, removeFont } from '../../lib/fontCache';
34
import { useFontCacheVersion } from '../../hooks/useFontCacheVersion';
5+
import { useLabelStore } from '../../store/labelStore';
46
import { 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';
515
import { 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

725
export 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

48182
interface 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

74335
interface 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

Comments
 (0)