Skip to content

Commit e109e7a

Browse files
committed
feat(fonts): adding a font opens a dialog that previews each face and names the file before it is cached
1 parent 44af545 commit e109e7a

13 files changed

Lines changed: 551 additions & 213 deletions

File tree

‎packages/core/src/lib/fontCache.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@ export const MAX_FONT_BYTES = 4 * 1024 * 1024;
2525
* grows, and the live ZPL view rebuilds the payload on every edit. */
2626
export const EMBED_WARN_FONT_BYTES = 1024 * 1024;
2727

28-
const FONT_EXT_RE = /\.(ttf|otf|tte)$/i;
28+
const FONT_EXTENSIONS = ["ttf", "otf", "tte"];
29+
const FONT_EXT_RE = new RegExp(`\\.(${FONT_EXTENSIONS.join("|")})$`, "i");
30+
/** The `accept` list of a font picker, so a picker never offers what the gate refuses. */
31+
export const FONT_FILE_ACCEPT = FONT_EXTENSIONS.flatMap((ext) => [`.${ext}`, `.${ext.toUpperCase()}`]).join(",");
2932

3033
/** What `loadFontFile` will take, answerable before a byte is read. */
3134
export const isFontFile = (file: File): boolean => FONT_EXT_RE.test(file.name) && file.size <= MAX_FONT_BYTES;

‎src-tauri/src/system_fonts.rs‎

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,25 @@ const MAX_FONT_BYTES: u64 = 4 * 1024 * 1024;
1414

1515
/// The scan reads every font file, so one session keeps its result.
1616
#[derive(Default)]
17-
pub struct SystemFonts(Mutex<Option<Vec<SystemFont>>>);
17+
pub struct SystemFonts {
18+
listing: Mutex<Option<Vec<SystemFont>>>,
19+
}
20+
21+
impl SystemFonts {
22+
fn listed(&self) -> Result<Option<Vec<SystemFont>>, String> {
23+
Ok(self.listing.lock().map_err(|e| e.to_string())?.clone())
24+
}
25+
26+
fn keep(&self, fonts: Vec<SystemFont>) -> Result<(), String> {
27+
*self.listing.lock().map_err(|e| e.to_string())? = Some(fonts);
28+
Ok(())
29+
}
30+
31+
fn allows(&self, path: &str) -> Result<bool, String> {
32+
let listing = self.listing.lock().map_err(|e| e.to_string())?;
33+
Ok(allows(listing.as_deref().unwrap_or(&[]), path))
34+
}
35+
}
1836

1937
#[derive(Serialize, Clone, Debug, PartialEq)]
2038
pub struct SystemFont {
@@ -31,11 +49,11 @@ pub struct SystemFont {
3149
pub async fn list_system_fonts(
3250
state: tauri::State<'_, SystemFonts>,
3351
) -> Result<Vec<SystemFont>, String> {
34-
if let Some(fonts) = state.0.lock().map_err(|e| e.to_string())?.clone() {
52+
if let Some(fonts) = state.listed()? {
3553
return Ok(fonts);
3654
}
3755
let fonts = blocking(scan).await?;
38-
*state.0.lock().map_err(|e| e.to_string())? = Some(fonts.clone());
56+
state.keep(fonts.clone())?;
3957
Ok(fonts)
4058
}
4159

@@ -44,15 +62,7 @@ pub async fn read_system_font(
4462
state: tauri::State<'_, SystemFonts>,
4563
path: String,
4664
) -> Result<tauri::ipc::Response, String> {
47-
let allowed = allows(
48-
state
49-
.0
50-
.lock()
51-
.map_err(|e| e.to_string())?
52-
.as_deref()
53-
.unwrap_or(&[]),
54-
&path,
55-
);
65+
let allowed = state.allows(&path)?;
5666
let bytes = blocking(move || read_listed(allowed, &path)).await??;
5767
Ok(tauri::ipc::Response::new(bytes))
5868
}
@@ -89,14 +99,12 @@ fn single_face_file(path: &Path) -> bool {
8999
.and_then(|e| e.to_str())
90100
.map(|e| e.to_ascii_lowercase())
91101
.as_deref(),
92-
Some("ttf") | Some("otf")
102+
Some("ttf") | Some("otf") | Some("tte")
93103
)
94104
}
95105

96106
fn font_dirs() -> Vec<PathBuf> {
97-
let home = std::env::var_os("HOME")
98-
.or_else(|| std::env::var_os("USERPROFILE"))
99-
.map(PathBuf::from);
107+
let home = || std::env::var_os("HOME").map(PathBuf::from);
100108
let mut dirs = Vec::new();
101109
if cfg!(target_os = "windows") {
102110
if let Some(root) = std::env::var_os("SYSTEMROOT") {
@@ -114,12 +122,12 @@ fn font_dirs() -> Vec<PathBuf> {
114122
]
115123
.map(PathBuf::from),
116124
);
117-
if let Some(home) = &home {
125+
if let Some(home) = home() {
118126
dirs.push(home.join("Library/Fonts"));
119127
}
120128
} else {
121129
dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts"].map(PathBuf::from));
122-
if let Some(home) = &home {
130+
if let Some(home) = home() {
123131
dirs.push(home.join(".fonts"));
124132
dirs.push(home.join(".local/share/fonts"));
125133
}
@@ -388,6 +396,7 @@ mod tests {
388396
fn lists_single_face_files_only() {
389397
assert!(single_face_file(Path::new("C:/Windows/Fonts/ARIAL.TTF")));
390398
assert!(single_face_file(Path::new("/usr/share/fonts/a.otf")));
399+
assert!(single_face_file(Path::new("/usr/share/fonts/a.tte")));
391400
assert!(!single_face_file(Path::new("/usr/share/fonts/a.ttc")));
392401
}
393402
}
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
import { useEffect, useId, useRef, useState } from 'react';
2+
import { XMarkIcon } from '@heroicons/react/16/solid';
3+
import { FONT_FILE_ACCEPT, loadFontBytes } from '@zplab/core/lib/fontCache';
4+
import { prepareFontBytes, prepareFontUpload, printerFontFileName, type FontNameIssue } from '@zplab/core/lib/customFonts';
5+
import { useT } from '../../hooks/useT';
6+
import { useUpload } from '../../hooks/useUpload';
7+
import { isDesktopShell } from '../../lib/platform';
8+
import { systemFontFaceStyle } from '../../lib/fontFaceStyle';
9+
import { listSystemFonts, readSystemFont, type SystemFont } from '../../lib/systemFonts';
10+
import { ariaDisabledCls, inputCls, labelCls } from '../ui/formStyles';
11+
import { DialogShell } from '../ui/DialogShell';
12+
13+
interface AddFontDialogProps {
14+
/** `uploadedPath` is the stored printer path on success, undefined when the dialog closes without adding. */
15+
onDone: (uploadedPath?: string) => void;
16+
}
17+
18+
/** Picked but not yet cached. The gate runs on Add, so a rejected name stays editable. */
19+
type FontCandidate = File | SystemFont;
20+
21+
const candidateFileName = (candidate: FontCandidate) => (candidate instanceof File ? candidate.name : candidate.file_name);
22+
23+
/** aria-disabled, not disabled: a locked control keeps focus, so the dialog's own key handler still sees Escape. */
24+
const lockedBtnCls = `px-3 py-1.5 rounded text-xs font-mono transition-colors ${ariaDisabledCls}`;
25+
const secondaryBtnCls = `${lockedBtnCls} whitespace-nowrap border`;
26+
27+
/** Modal because the installed-fonts list needs more room than the sidebar gives. */
28+
export function AddFontDialog({ onDone }: AddFontDialogProps) {
29+
const t = useT();
30+
const fileRef = useRef<HTMLInputElement>(null);
31+
// Closing abandons a pending read, so its result must not reach the cache.
32+
const aborted = useRef(false);
33+
const [candidate, setCandidate] = useState<FontCandidate | null>(null);
34+
const [name, setName] = useState('');
35+
const titleId = useId();
36+
const listLabelId = useId();
37+
const issueId = useId();
38+
const { busy, issue, start: addFont } = useUpload<'error' | FontNameIssue, FontCandidate>(async (input) => {
39+
const prepared =
40+
input instanceof File ? await prepareFontUpload(input, name) : prepareFontBytes(input.file_name, await readSystemFont(input.path), name);
41+
if (aborted.current) return null;
42+
if (!prepared.ok) return prepared.reason === 'notAFont' ? 'error' : prepared.reason;
43+
await loadFontBytes(prepared.bytes, prepared.path);
44+
onDone(prepared.path);
45+
return null;
46+
}, 'error');
47+
48+
const select = (next: FontCandidate) => {
49+
setCandidate(next);
50+
const fileName = candidateFileName(next);
51+
setName(printerFontFileName(fileName) ?? fileName);
52+
};
53+
54+
const close = () => {
55+
aborted.current = true;
56+
onDone();
57+
};
58+
59+
const canAdd = !busy && candidate !== null && name.trim() !== '';
60+
const submit = () => {
61+
if (canAdd && candidate) addFont(candidate);
62+
};
63+
const nameRejected = issue !== null && issue !== 'error';
64+
65+
return (
66+
<DialogShell
67+
onClose={close}
68+
labelledBy={titleId}
69+
portal
70+
boxClassName="bg-surface border border-border rounded-lg w-[26rem] max-w-[95vw] max-h-[90vh] flex flex-col shadow-2xl"
71+
>
72+
<div className="flex shrink-0 items-center justify-between gap-2 px-4 py-3 border-b border-border">
73+
<h2 id={titleId} className="text-sm font-medium text-text">
74+
{t.fonts.addFont}
75+
</h2>
76+
<button
77+
type="button"
78+
onClick={close}
79+
aria-label={t.app.close}
80+
className="p-0.5 rounded text-muted hover:text-text hover:bg-surface-2 transition-colors"
81+
>
82+
<XMarkIcon className="w-4 h-4" />
83+
</button>
84+
</div>
85+
86+
<form
87+
className="flex min-h-0 flex-1 flex-col"
88+
onSubmit={(e) => {
89+
e.preventDefault();
90+
submit();
91+
}}
92+
>
93+
<div className="flex min-h-0 flex-col gap-3 px-4 py-4 overflow-y-auto">
94+
{isDesktopShell && (
95+
<div className="flex flex-col gap-1">
96+
<p id={listLabelId} className={labelCls}>
97+
{t.fonts.fromComputer}
98+
</p>
99+
<SystemFontList
100+
labelId={listLabelId}
101+
busy={busy}
102+
selectedPath={candidate instanceof File ? undefined : candidate?.path}
103+
onSelect={select}
104+
/>
105+
</div>
106+
)}
107+
108+
<input
109+
ref={fileRef}
110+
type="file"
111+
accept={FONT_FILE_ACCEPT}
112+
className="hidden"
113+
onChange={(e) => {
114+
const file = e.target.files?.[0];
115+
if (file) select(file);
116+
e.target.value = '';
117+
}}
118+
/>
119+
120+
<div className="flex items-center gap-2">
121+
<button
122+
type="button"
123+
className={`${secondaryBtnCls} shrink-0 ${
124+
candidate instanceof File ? 'border-accent bg-accent-dim text-accent' : 'border-border text-muted hover:text-text hover:bg-surface-2'
125+
}`}
126+
onClick={() => {
127+
if (!busy) fileRef.current?.click();
128+
}}
129+
aria-disabled={busy}
130+
>
131+
{t.fonts.upload}
132+
</button>
133+
{candidate instanceof File && (
134+
<span className="font-mono text-[10px] text-muted truncate" title={candidate.name}>
135+
{candidate.name}
136+
</span>
137+
)}
138+
</div>
139+
140+
<div className="flex flex-col gap-1">
141+
<label className={labelCls}>{t.fonts.printerFilename}</label>
142+
<input
143+
className={inputCls}
144+
value={name}
145+
placeholder={t.fonts.printerFilenamePlaceholder}
146+
onChange={(e) => setName(e.target.value)}
147+
readOnly={busy}
148+
aria-disabled={busy}
149+
aria-invalid={nameRejected || undefined}
150+
aria-describedby={issue ? issueId : undefined}
151+
autoFocus={!isDesktopShell}
152+
/>
153+
{issue && (
154+
<p id={issueId} role="alert" className="text-[10px] font-mono text-red-400 leading-snug">
155+
{{ error: t.fonts.uploadError, nameTaken: t.fonts.nameTaken, nameUnusable: t.fonts.nameUnusable }[issue]}
156+
</p>
157+
)}
158+
</div>
159+
</div>
160+
161+
<div className="flex shrink-0 justify-end gap-2 px-4 py-3 border-t border-border">
162+
<button
163+
type="button"
164+
className={`${lockedBtnCls} text-muted hover:text-text hover:bg-surface-2`}
165+
onClick={close}
166+
>
167+
{t.fonts.cancel}
168+
</button>
169+
<button
170+
type="submit"
171+
className={`${lockedBtnCls} bg-accent text-bg hover:opacity-90`}
172+
aria-disabled={!canAdd}
173+
aria-busy={busy || undefined}
174+
>
175+
{t.fonts.addFont}
176+
</button>
177+
</div>
178+
</form>
179+
</DialogShell>
180+
);
181+
}
182+
183+
// ── SystemFontList ─────────────────────────────────────────────────────────────
184+
185+
function SystemFontList({
186+
labelId,
187+
busy,
188+
selectedPath,
189+
onSelect,
190+
}: {
191+
labelId: string;
192+
busy: boolean;
193+
selectedPath: string | undefined;
194+
onSelect: (font: SystemFont) => void;
195+
}) {
196+
const t = useT();
197+
const [fonts, setFonts] = useState<SystemFont[] | null>(null);
198+
const [failed, setFailed] = useState(false);
199+
const [query, setQuery] = useState('');
200+
useEffect(() => {
201+
let current = true;
202+
listSystemFonts().then(
203+
(list) => current && setFonts(list),
204+
() => current && setFailed(true),
205+
);
206+
return () => {
207+
current = false;
208+
};
209+
}, []);
210+
const shown = (fonts ?? []).filter((f) => `${f.family} ${f.style}`.toLowerCase().includes(query.trim().toLowerCase()));
211+
const selectedIndex = shown.findIndex((f) => f.path === selectedPath);
212+
213+
const moveSelection = (list: HTMLElement, step: number) => {
214+
const from = selectedIndex < 0 ? (step > 0 ? -1 : shown.length) : selectedIndex;
215+
for (let i = from + step; i >= 0 && i < shown.length; i += step) {
216+
const next = shown[i];
217+
if (!next || next.restricted) continue;
218+
onSelect(next);
219+
list.querySelectorAll<HTMLElement>('[role="option"]')[i]?.focus();
220+
return;
221+
}
222+
};
223+
224+
return (
225+
<div
226+
className="flex flex-col gap-1"
227+
onKeyDown={(e) => {
228+
if (busy || (e.key !== 'ArrowDown' && e.key !== 'ArrowUp')) return;
229+
e.preventDefault();
230+
moveSelection(e.currentTarget, e.key === 'ArrowDown' ? 1 : -1);
231+
}}
232+
>
233+
<input
234+
className={inputCls}
235+
value={query}
236+
placeholder={t.fonts.filterFonts}
237+
onChange={(e) => setQuery(e.target.value)}
238+
aria-label={t.fonts.filterFonts}
239+
readOnly={busy}
240+
aria-disabled={busy}
241+
autoFocus
242+
/>
243+
{fonts === null && !failed && <p className="text-[10px] text-muted">…</p>}
244+
{fonts !== null && fonts.length === 0 && <p className="text-[10px] text-muted">{t.fonts.noSystemFonts}</p>}
245+
{fonts !== null && fonts.length > 0 && shown.length === 0 && <p className="text-[10px] text-muted">{t.fonts.noFilterMatch}</p>}
246+
{failed && <p className="text-[10px] font-mono text-red-400">{t.fonts.systemFontsFailed}</p>}
247+
<div
248+
role="listbox"
249+
aria-labelledby={labelId}
250+
className="max-h-56 overflow-auto rounded border border-border text-xs"
251+
>
252+
{shown.map((font, i) => {
253+
const selected = font.path === selectedPath;
254+
return (
255+
// One tab stop for the whole list: Tab reaches the name field without walking every row.
256+
<button
257+
key={font.path}
258+
type="button"
259+
role="option"
260+
tabIndex={selected || (selectedIndex < 0 && i === 0) ? 0 : -1}
261+
className={`flex w-full items-baseline gap-2 px-2 py-1 text-left aria-disabled:opacity-40 ${
262+
selected ? 'bg-accent-dim' : 'hover:bg-border/60 aria-disabled:hover:bg-transparent'
263+
}`}
264+
onClick={() => {
265+
if (!busy && !font.restricted) onSelect(font);
266+
}}
267+
aria-selected={selected}
268+
aria-disabled={busy || font.restricted}
269+
title={font.restricted ? t.fonts.restrictedLicense : font.variable ? t.fonts.variableFont : undefined}
270+
>
271+
<span className="text-text truncate" style={systemFontFaceStyle(font)}>
272+
{font.family}
273+
</span>
274+
<span className={`truncate ${selected ? 'text-text' : 'text-muted'}`}>{font.style}</span>
275+
<span className={`ml-auto font-mono text-[10px] shrink-0 ${selected ? 'text-text' : 'text-muted'}`}>
276+
{Math.ceil(font.bytes / 1024)} KB
277+
</span>
278+
</button>
279+
);
280+
})}
281+
</div>
282+
</div>
283+
);
284+
}

0 commit comments

Comments
 (0)