Skip to content

Commit b1507d0

Browse files
committed
feat(printer): the stored fonts tab opens the shared font dialog
The chip reaches the printer's storage, and the installed-fonts list keeps its height while it loads.
1 parent faac661 commit b1507d0

39 files changed

Lines changed: 160 additions & 132 deletions

‎src/components/Fonts/AddFontDialog.tsx‎

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useEffect, useId, useRef, useState } from 'react';
22
import { XMarkIcon } from '@heroicons/react/16/solid';
3-
import { FONT_FILE_ACCEPT, loadFontBytes } from '@zplab/core/lib/fontCache';
3+
import { FONT_FILE_ACCEPT } from '@zplab/core/lib/fontCache';
44
import { prepareFontBytes, prepareFontUpload, printerFontFileName, type FontNameIssue } from '@zplab/core/lib/customFonts';
55
import { useT } from '../../hooks/useT';
66
import { useUpload } from '../../hooks/useUpload';
@@ -12,8 +12,11 @@ import { ariaDisabledCls, inputCls, labelCls } from '../ui/formStyles';
1212
import { DialogShell } from '../ui/DialogShell';
1313

1414
interface AddFontDialogProps {
15-
/** `uploadedPath` is the stored printer path on success, undefined when the dialog closes without adding. */
16-
onDone: (uploadedPath?: string) => void;
15+
/** Undefined when the dialog closes without adding. The caller caches, so it can still refuse a
16+
* name this dialog has no way to know is taken. */
17+
onDone: (picked?: { path: string; bytes: Uint8Array }) => void;
18+
/** The file a stale profile entry waits for. Its name is fixed, because any other name repairs nothing. */
19+
repairPath?: string;
1720
}
1821

1922
/** Picked but not yet cached. The gate runs on Add, so a rejected name stays editable. */
@@ -26,13 +29,13 @@ const lockedBtnCls = `px-3 py-1.5 rounded text-xs font-mono transition-colors ${
2629
const secondaryBtnCls = `${lockedBtnCls} whitespace-nowrap border`;
2730

2831
/** Modal because the installed-fonts list needs more room than the sidebar gives. */
29-
export function AddFontDialog({ onDone }: AddFontDialogProps) {
32+
export function AddFontDialog({ onDone, repairPath }: AddFontDialogProps) {
3033
const t = useT();
3134
const fileRef = useRef<HTMLInputElement>(null);
3235
// Closing abandons a pending read, so its result must not reach the cache.
3336
const aborted = useRef(false);
3437
const [candidate, setCandidate] = useState<FontCandidate | null>(null);
35-
const [name, setName] = useState('');
38+
const [name, setName] = useState(repairPath ?? '');
3639
const titleId = useId();
3740
const listLabelId = useId();
3841
const issueId = useId();
@@ -41,13 +44,13 @@ export function AddFontDialog({ onDone }: AddFontDialogProps) {
4144
input instanceof File ? await prepareFontUpload(input, name) : prepareFontBytes(input.file_name, await readSystemFont(input.path), name);
4245
if (aborted.current) return null;
4346
if (!prepared.ok) return prepared.reason === 'notAFont' ? 'error' : prepared.reason;
44-
await loadFontBytes(prepared.bytes, prepared.path);
45-
onDone(prepared.path);
47+
onDone({ path: prepared.path, bytes: prepared.bytes });
4648
return null;
4749
}, 'error');
4850

4951
const select = (next: FontCandidate) => {
5052
setCandidate(next);
53+
if (repairPath) return;
5154
const fileName = candidateFileName(next);
5255
setName(printerFontFileName(fileName) ?? fileName);
5356
};
@@ -145,8 +148,8 @@ export function AddFontDialog({ onDone }: AddFontDialogProps) {
145148
value={name}
146149
placeholder={t.fonts.printerFilenamePlaceholder}
147150
onChange={(e) => setName(e.target.value)}
148-
readOnly={busy}
149-
aria-disabled={busy}
151+
readOnly={busy || repairPath !== undefined}
152+
aria-disabled={busy || repairPath !== undefined}
150153
aria-invalid={nameRejected || undefined}
151154
aria-describedby={issue ? issueId : undefined}
152155
autoFocus={!isDesktopShell}
@@ -181,6 +184,19 @@ export function AddFontDialog({ onDone }: AddFontDialogProps) {
181184
);
182185
}
183186

187+
/** Uneven on purpose, so the placeholder reads as a list of names rather than a block. */
188+
const LOADING_ROW_WIDTHS = ["70%", "58%", "66%", "44%", "62%", "50%"];
189+
190+
function LoadingRows() {
191+
return (
192+
<div aria-hidden="true" className="flex flex-col gap-2 px-2 py-2">
193+
{LOADING_ROW_WIDTHS.map((width) => (
194+
<span key={width} className="h-3 rounded bg-border animate-pulse" style={{ width }} />
195+
))}
196+
</div>
197+
);
198+
}
199+
184200
// ── SystemFontList ─────────────────────────────────────────────────────────────
185201

186202
function SystemFontList({
@@ -241,15 +257,17 @@ function SystemFontList({
241257
aria-disabled={busy}
242258
autoFocus
243259
/>
244-
{fonts === null && !failed && <p className="text-[10px] text-muted">…</p>}
245-
{fonts !== null && fonts.length === 0 && <p className="text-[10px] text-muted">{t.fonts.noInstalledFonts}</p>}
246-
{fonts !== null && fonts.length > 0 && shown.length === 0 && <p className="text-[10px] text-muted">{t.fonts.noFilterMatch}</p>}
247-
{failed && <p className="text-[10px] font-mono text-red-400">{t.fonts.systemFontsFailed}</p>}
260+
{/* Fixed height, so neither the arriving list nor a filter resizes the dialog under the pointer. */}
248261
<div
249262
role="listbox"
250263
aria-labelledby={labelId}
251-
className="max-h-56 overflow-auto rounded border border-border text-xs"
264+
aria-busy={fonts === null && !failed}
265+
className="h-56 overflow-auto rounded border border-border text-xs"
252266
>
267+
{failed && <p className="px-2 py-1 font-mono text-[10px] text-red-400">{t.fonts.systemFontsFailed}</p>}
268+
{fonts === null && !failed && <LoadingRows />}
269+
{fonts !== null && fonts.length === 0 && <p className="px-2 py-1 text-[10px] text-muted">{t.fonts.noInstalledFonts}</p>}
270+
{fonts !== null && fonts.length > 0 && shown.length === 0 && <p className="px-2 py-1 text-[10px] text-muted">{t.fonts.noFilterMatch}</p>}
253271
{shown.map((font, i) => {
254272
const selected = font.path === selectedPath;
255273
return (

‎src/components/Fonts/FontManager.test.tsx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { cachedFontPath, getAllFonts, loadFontBytes, removeFont } from "@zplab/c
77
import { withoutSetupEntry } from "@zplab/core/lib/setupEntries";
88
import { serializeDesign } from "@zplab/core/lib/designFile";
99
import type { LabelObject } from "@zplab/core/types/Group";
10+
import { fallbackTranslations as en } from "../../locales";
1011

1112
const text = (printerFontName: string): LabelObject =>
1213
({ id: "t1", type: "text", x: 0, y: 0, rotation: 0, props: { content: "x", fontHeight: 30, fontWidth: 0, rotation: "N", printerFontName } }) as unknown as LabelObject;
@@ -263,6 +264,13 @@ describe("FontManager upload", () => {
263264
expect(getAllFonts()).toHaveLength(1);
264265
});
265266

267+
it("names a file it cannot read as a font at all", async () => {
268+
const r = render(<FontManager />);
269+
await addFile(r, new File(["x"], "bad.txt"));
270+
expect(r.getByText(en.fonts.uploadError)).toBeTruthy();
271+
expect(getAllFonts()).toHaveLength(1);
272+
});
273+
266274
it("refuses a file whose name leaves no printer name", async () => {
267275
const r = render(<FontManager />);
268276
await addFile(r, new File(["x"], "日本.ttf"));

‎src/components/Fonts/FontManager.tsx‎

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useState, type FocusEvent } from 'react';
22
import { PlusIcon, TrashIcon, InformationCircleIcon } from '@heroicons/react/16/solid';
33
import {
44
hasFontBytes,
5+
loadFontBytes,
56
removeFont,
67
getFontFamily,
78
isEmbedLarge,
@@ -116,6 +117,14 @@ export function FontManager() {
116117
replaceList((customFonts ?? []).filter((_, i) => i !== index));
117118
};
118119

120+
// The alias has to meet a cache that already holds the file, or the row lands under the manual mappings.
121+
const addPicked = async ({ path, bytes }: { path: string; bytes: Uint8Array }) => {
122+
await loadFontBytes(bytes, path);
123+
const taken = (customFonts ?? []).map((m) => m.alias).filter(Boolean);
124+
const alias = nextFreeAlias(taken);
125+
if (alias) setAliasForPath(path, alias);
126+
};
127+
119128
const addManual = () => {
120129
// Suggest the next free letter from the I-Z 1-9 range so the user
121130
// does not accidentally override a built-in Zebra font letter. They
@@ -167,20 +176,13 @@ export function FontManager() {
167176
</button>
168177
{adding && (
169178
<AddFontDialog
170-
onDone={(uploadedPath) => {
179+
onDone={(picked) => {
171180
// Auto-assign the next free alias when the upload succeeds.
172181
// Closes the "what now?" gap between the upload finishing
173182
// and the delivery select becoming usable: the user lands on
174183
// a row that is already wired through to ^CW + canvas, with
175184
// an editable alias if they want to override the default.
176-
if (uploadedPath) {
177-
const path = uploadedPath;
178-
const taken = (customFonts ?? [])
179-
.map((m) => m.alias)
180-
.filter(Boolean);
181-
const alias = nextFreeAlias(taken);
182-
if (alias) setAliasForPath(path, alias);
183-
}
185+
if (picked) void addPicked(picked);
184186
setAdding(false);
185187
}}
186188
/>

‎src/components/Output/PrinterChip.test.tsx‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const dot = (r: ReturnType<typeof render>) => (r.container.querySelector("button
2828
afterEach(cleanup);
2929
beforeEach(() => {
3030
readPrinterStatus.mockReset();
31-
act(() => useLabelStore.setState({ printerState: { phase: "idle" }, printTarget: { ...DEFAULT_PRINT_TARGET, host: "172.17.17.175" } }));
31+
act(() => useLabelStore.setState({ printerState: { phase: "idle" }, printerSettingsTab: null, printTarget: { ...DEFAULT_PRINT_TARGET, host: "172.17.17.175" } }));
3232
});
3333

3434
describe("PrinterChip", () => {
@@ -74,6 +74,13 @@ describe("PrinterChip", () => {
7474
expect(dot(r)).toBe("blocked");
7575
});
7676

77+
it("opens the printer's own storage, the shortest way there from the design", () => {
78+
const r = render(<PrinterChip />);
79+
fireEvent.click(r.getByText("172.17.17.175"));
80+
fireEvent.click(r.getByText(en.printerSettings.objects.listHeading));
81+
expect(useLabelStore.getState().printerSettingsTab).toBe("storedFonts");
82+
});
83+
7784
it("opens the configuration echo with the raw replies beside it", async () => {
7885
act(() => useLabelStore.setState({ printerState: { phase: "done", key: "net:172.17.17.175:9100", at: 0, report: report({}) } }));
7986
const r = render(<PrinterChip />);

‎src/components/Output/PrinterChip.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useRef, useState } from "react";
2-
import { ArrowPathIcon, ChevronDownIcon, Cog6ToothIcon, DocumentTextIcon } from "@heroicons/react/16/solid";
2+
import { ArrowPathIcon, ChevronDownIcon, CircleStackIcon, Cog6ToothIcon, DocumentTextIcon } from "@heroicons/react/16/solid";
33
import { useT } from "../../hooks/useT";
44
import { useDismiss } from "../../hooks/useDismiss";
55
import { formatTemplate } from "../../lib/formatTemplate";
@@ -88,6 +88,9 @@ export function PrinterChip() {
8888
<DropdownItem icon={DocumentTextIcon} disabled={reading} onClick={closeThen(openConfiguration)}>
8989
{loc.readConfiguration}
9090
</DropdownItem>
91+
<DropdownItem icon={CircleStackIcon} onClick={closeThen(() => setPrinterSettingsTab("storedFonts"))}>
92+
{t.printerSettings.objects.listHeading}
93+
</DropdownItem>
9194
<DropdownItem icon={Cog6ToothIcon} onClick={closeThen(() => setPrinterSettingsTab("printTarget"))}>
9295
{t.printerSettings.title}
9396
</DropdownItem>

‎src/components/PrinterSettings/StoredFontsTab.test.tsx‎

Lines changed: 33 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
// @vitest-environment jsdom
22
import { describe, it, expect, afterEach, beforeEach } from "vitest";
3-
import { render, cleanup, fireEvent, act } from "@testing-library/react";
3+
import { render, cleanup, fireEvent, act, within } from "@testing-library/react";
44
import { StoredFontsTab } from "./StoredFontsTab";
55
import { useLabelStore } from "../../store/labelStore";
66
import { getAllFonts, loadFontBytes, removeFont } from "@zplab/core/lib/fontCache";
7+
import { fallbackTranslations as en } from "../../locales";
78

89
beforeEach(async () => {
910
await loadFontBytes(new Uint8Array([0, 1, 0, 0]), "E:ARIAL.TTF");
@@ -12,9 +13,21 @@ beforeEach(async () => {
1213

1314
afterEach(() => {
1415
cleanup();
15-
for (const name of ["E:ARIAL.TTF", "R:ARIAL.TTF", "E:NEW.TTF", "E:OLD.BIN", "E:MYLOGO.TTF", "R:GONE.TTF", "E:OTHER.TTF"]) removeFont(name);
16+
for (const name of ["E:ARIAL.TTF", "R:ARIAL.TTF", "E:NEW.TTF", "E:OLD.BIN", "R:GONE.TTF", "E:OTHER.TTF"]) removeFont(name);
1617
});
1718

19+
const addFile = async (r: ReturnType<typeof render>, open: string, file: File) => {
20+
fireEvent.click(r.getByText(open));
21+
// The dialog is portaled, so its hidden input sits outside `container`.
22+
const input = r.baseElement.querySelector('input[type="file"]') as HTMLInputElement;
23+
await act(async () => {
24+
fireEvent.change(input, { target: { files: [file] } });
25+
});
26+
await act(async () => {
27+
fireEvent.click(within(r.getByRole("dialog")).getByRole("button", { name: "Add" }));
28+
});
29+
};
30+
1831
describe("StoredFontsTab", () => {
1932
it("lists a cached font and adds it to the setup script", () => {
2033
const { getByLabelText, getByTitle } = render(<StoredFontsTab />);
@@ -99,43 +112,38 @@ describe("StoredFontsTab", () => {
99112
expect(useLabelStore.getState().printerProfile.setupFonts).toBeUndefined();
100113
});
101114

102-
it("repairs a missing entry under its own path instead of the default drive", async () => {
115+
it("repairs a missing entry under its own path, whatever the picked file is called", async () => {
103116
act(() => useLabelStore.setState({ printerProfile: { setupFonts: [{ path: "R:GONE.TTF" }] } }));
104-
const { getByText, getByLabelText, queryByText } = render(<StoredFontsTab />);
105-
act(() => {
106-
fireEvent.click(getByText("Upload the file"));
107-
});
108-
await act(async () => {
109-
fireEvent.change(getByLabelText(/Upload font/), { target: { files: [new File(["x"], "other.ttf")] } });
110-
});
117+
const r = render(<StoredFontsTab />);
118+
await addFile(r, "Upload the file", new File(["x"], "other.ttf"));
111119
expect(getAllFonts().map((f) => f.name)).toContain("R:GONE.TTF");
112-
expect(queryByText(/Font missing/)).toBeNull();
120+
expect(r.queryByText(/Font missing/)).toBeNull();
113121
expect(useLabelStore.getState().printerProfile.setupFonts).toEqual([{ path: "R:GONE.TTF" }]);
114122
});
115123

124+
it("keeps an imported upload whose only copy is the profile, rather than replacing it", async () => {
125+
act(() => useLabelStore.setState({ printerProfile: { setupFonts: [{ path: "E:OTHER.TTF", download: "~DYE:OTHER.TTF,A,T,4,,00010000" }] } }));
126+
const r = render(<StoredFontsTab />);
127+
await addFile(r, "Upload font…", new File(["x"], "other.ttf"));
128+
expect(useLabelStore.getState().printerProfile.setupFonts).toEqual([{ path: "E:OTHER.TTF", download: "~DYE:OTHER.TTF,A,T,4,,00010000" }]);
129+
// The cache must stay out of it too, or the canvas would draw bytes the setup script never sends.
130+
expect(getAllFonts().map((f) => f.name)).not.toContain("E:OTHER.TTF");
131+
expect(r.getByRole("alert").textContent).toBe(en.printerSettings.fonts.replayedNameTaken);
132+
});
133+
116134
it("uploads a font file into the cache and the setup script in one step", async () => {
117-
const { getByLabelText } = render(<StoredFontsTab />);
118-
await act(async () => {
119-
fireEvent.change(getByLabelText(/Upload font/), { target: { files: [new File(["x"], "new.ttf")] } });
120-
});
135+
const r = render(<StoredFontsTab />);
136+
await addFile(r, "Upload font…", new File(["x"], "new.ttf"));
121137
expect(getAllFonts().map((f) => f.name)).toContain("E:NEW.TTF");
122138
expect(useLabelStore.getState().printerProfile.setupFonts).toEqual([{ path: "E:NEW.TTF" }]);
123139
});
124140

125-
it("gives a picked file a printer name the ~DY operand can carry", async () => {
126-
const { getByLabelText } = render(<StoredFontsTab />);
127-
await act(async () => {
128-
fireEvent.change(getByLabelText(/Upload font/), { target: { files: [new File(["x"], "my logo.ttf")] } });
129-
});
130-
expect(useLabelStore.getState().printerProfile.setupFonts).toEqual([{ path: "E:MYLOGO.TTF" }]);
131-
});
132-
133141
it("locks every profile control while the editor is frozen, instead of snapping back", () => {
134142
act(() => useLabelStore.setState({ sourceEdit: { status: "editing", draft: "^XA^XZ", baseline: "^XA^XZ", session: 1 } }));
135-
const { getByLabelText, getByRole } = render(<StoredFontsTab />);
143+
const { getByLabelText, getByRole, getByText } = render(<StoredFontsTab />);
136144
const toggle = getByLabelText(/Send at setup/) as HTMLInputElement;
137145
expect(toggle.disabled).toBe(true);
138-
const upload = (getByLabelText(/Upload font/) as HTMLInputElement).closest("div")?.querySelector("button") as HTMLButtonElement;
146+
const upload = getByText("Upload font…") as HTMLButtonElement;
139147
expect(upload.disabled).toBe(true);
140148
// A disabled control swallows its own events, so the reason sits on the wrapper.
141149
act(() => {
@@ -145,21 +153,4 @@ describe("StoredFontsTab", () => {
145153
act(() => useLabelStore.setState({ sourceEdit: { status: "off" } }));
146154
});
147155

148-
it("refuses a second file that folds onto a cached printer name with different bytes", async () => {
149-
const { getByLabelText, getByText } = render(<StoredFontsTab />);
150-
await act(async () => {
151-
fireEvent.change(getByLabelText(/Upload font/), { target: { files: [new File(["other bytes"], "arial.ttf")] } });
152-
});
153-
expect(getAllFonts()).toHaveLength(1);
154-
expect(getByText(/different printer filename/)).toBeTruthy();
155-
expect(useLabelStore.getState().printerProfile.setupFonts).toBeUndefined();
156-
});
157-
158-
it("names a failed upload", async () => {
159-
const { getByLabelText, getByText } = render(<StoredFontsTab />);
160-
await act(async () => {
161-
fireEvent.change(getByLabelText(/Upload font/), { target: { files: [new File(["x"], "bad.txt")] } });
162-
});
163-
expect(getByText(/Could not load the font file/)).toBeTruthy();
164-
});
165156
});

0 commit comments

Comments
 (0)