diff --git a/src/components/Fonts/AddFontDialog.tsx b/src/components/Fonts/AddFontDialog.tsx index 240d5834..633a4814 100644 --- a/src/components/Fonts/AddFontDialog.tsx +++ b/src/components/Fonts/AddFontDialog.tsx @@ -1,6 +1,6 @@ import { useEffect, useId, useRef, useState } from 'react'; import { XMarkIcon } from '@heroicons/react/16/solid'; -import { FONT_FILE_ACCEPT, loadFontBytes } from '@zplab/core/lib/fontCache'; +import { FONT_FILE_ACCEPT } from '@zplab/core/lib/fontCache'; import { prepareFontBytes, prepareFontUpload, printerFontFileName, type FontNameIssue } from '@zplab/core/lib/customFonts'; import { useT } from '../../hooks/useT'; import { useUpload } from '../../hooks/useUpload'; @@ -12,8 +12,11 @@ import { ariaDisabledCls, inputCls, labelCls } from '../ui/formStyles'; import { DialogShell } from '../ui/DialogShell'; interface AddFontDialogProps { - /** `uploadedPath` is the stored printer path on success, undefined when the dialog closes without adding. */ - onDone: (uploadedPath?: string) => void; + /** Undefined when the dialog closes without adding. The caller caches, so it can still refuse a + * name this dialog has no way to know is taken. */ + onDone: (picked?: { path: string; bytes: Uint8Array }) => void; + /** The file a stale profile entry waits for. Its name is fixed, because any other name repairs nothing. */ + repairPath?: string; } /** 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 ${ const secondaryBtnCls = `${lockedBtnCls} whitespace-nowrap border`; /** Modal because the installed-fonts list needs more room than the sidebar gives. */ -export function AddFontDialog({ onDone }: AddFontDialogProps) { +export function AddFontDialog({ onDone, repairPath }: AddFontDialogProps) { const t = useT(); const fileRef = useRef(null); // Closing abandons a pending read, so its result must not reach the cache. const aborted = useRef(false); const [candidate, setCandidate] = useState(null); - const [name, setName] = useState(''); + const [name, setName] = useState(repairPath ?? ''); const titleId = useId(); const listLabelId = useId(); const issueId = useId(); @@ -41,13 +44,13 @@ export function AddFontDialog({ onDone }: AddFontDialogProps) { input instanceof File ? await prepareFontUpload(input, name) : prepareFontBytes(input.file_name, await readSystemFont(input.path), name); if (aborted.current) return null; if (!prepared.ok) return prepared.reason === 'notAFont' ? 'error' : prepared.reason; - await loadFontBytes(prepared.bytes, prepared.path); - onDone(prepared.path); + onDone({ path: prepared.path, bytes: prepared.bytes }); return null; }, 'error'); const select = (next: FontCandidate) => { setCandidate(next); + if (repairPath) return; const fileName = candidateFileName(next); setName(printerFontFileName(fileName) ?? fileName); }; @@ -145,8 +148,8 @@ export function AddFontDialog({ onDone }: AddFontDialogProps) { value={name} placeholder={t.fonts.printerFilenamePlaceholder} onChange={(e) => setName(e.target.value)} - readOnly={busy} - aria-disabled={busy} + readOnly={busy || repairPath !== undefined} + aria-disabled={busy || repairPath !== undefined} aria-invalid={nameRejected || undefined} aria-describedby={issue ? issueId : undefined} autoFocus={!isDesktopShell} @@ -181,6 +184,19 @@ export function AddFontDialog({ onDone }: AddFontDialogProps) { ); } +/** Uneven on purpose, so the placeholder reads as a list of names rather than a block. */ +const LOADING_ROW_WIDTHS = ["70%", "58%", "66%", "44%", "62%", "50%"]; + +function LoadingRows() { + return ( + + ); +} + // ── SystemFontList ───────────────────────────────────────────────────────────── function SystemFontList({ @@ -241,15 +257,17 @@ function SystemFontList({ aria-disabled={busy} autoFocus /> - {fonts === null && !failed &&

…

} - {fonts !== null && fonts.length === 0 &&

{t.fonts.noInstalledFonts}

} - {fonts !== null && fonts.length > 0 && shown.length === 0 &&

{t.fonts.noFilterMatch}

} - {failed &&

{t.fonts.systemFontsFailed}

} + {/* Fixed height, so neither the arriving list nor a filter resizes the dialog under the pointer. */}
+ {failed &&

{t.fonts.systemFontsFailed}

} + {fonts === null && !failed && } + {fonts !== null && fonts.length === 0 &&

{t.fonts.noInstalledFonts}

} + {fonts !== null && fonts.length > 0 && shown.length === 0 &&

{t.fonts.noFilterMatch}

} {shown.map((font, i) => { const selected = font.path === selectedPath; return ( diff --git a/src/components/Fonts/FontManager.test.tsx b/src/components/Fonts/FontManager.test.tsx index 2a20c60b..c1ddb045 100644 --- a/src/components/Fonts/FontManager.test.tsx +++ b/src/components/Fonts/FontManager.test.tsx @@ -7,6 +7,7 @@ import { cachedFontPath, getAllFonts, loadFontBytes, removeFont } from "@zplab/c import { withoutSetupEntry } from "@zplab/core/lib/setupEntries"; import { serializeDesign } from "@zplab/core/lib/designFile"; import type { LabelObject } from "@zplab/core/types/Group"; +import { fallbackTranslations as en } from "../../locales"; const text = (printerFontName: string): LabelObject => ({ 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", () => { expect(getAllFonts()).toHaveLength(1); }); + it("names a file it cannot read as a font at all", async () => { + const r = render(); + await addFile(r, new File(["x"], "bad.txt")); + expect(r.getByText(en.fonts.uploadError)).toBeTruthy(); + expect(getAllFonts()).toHaveLength(1); + }); + it("refuses a file whose name leaves no printer name", async () => { const r = render(); await addFile(r, new File(["x"], "日本.ttf")); diff --git a/src/components/Fonts/FontManager.tsx b/src/components/Fonts/FontManager.tsx index f324b8aa..ea96cb0a 100644 --- a/src/components/Fonts/FontManager.tsx +++ b/src/components/Fonts/FontManager.tsx @@ -2,6 +2,7 @@ import { useState, type FocusEvent } from 'react'; import { PlusIcon, TrashIcon, InformationCircleIcon } from '@heroicons/react/16/solid'; import { hasFontBytes, + loadFontBytes, removeFont, getFontFamily, isEmbedLarge, @@ -116,6 +117,14 @@ export function FontManager() { replaceList((customFonts ?? []).filter((_, i) => i !== index)); }; + // The alias has to meet a cache that already holds the file, or the row lands under the manual mappings. + const addPicked = async ({ path, bytes }: { path: string; bytes: Uint8Array }) => { + await loadFontBytes(bytes, path); + const taken = (customFonts ?? []).map((m) => m.alias).filter(Boolean); + const alias = nextFreeAlias(taken); + if (alias) setAliasForPath(path, alias); + }; + const addManual = () => { // Suggest the next free letter from the I-Z 1-9 range so the user // does not accidentally override a built-in Zebra font letter. They @@ -167,20 +176,13 @@ export function FontManager() { {adding && ( { + onDone={(picked) => { // Auto-assign the next free alias when the upload succeeds. // Closes the "what now?" gap between the upload finishing // and the delivery select becoming usable: the user lands on // a row that is already wired through to ^CW + canvas, with // an editable alias if they want to override the default. - if (uploadedPath) { - const path = uploadedPath; - const taken = (customFonts ?? []) - .map((m) => m.alias) - .filter(Boolean); - const alias = nextFreeAlias(taken); - if (alias) setAliasForPath(path, alias); - } + if (picked) void addPicked(picked); setAdding(false); }} /> diff --git a/src/components/Output/PrinterChip.test.tsx b/src/components/Output/PrinterChip.test.tsx index e1001b44..8b386949 100644 --- a/src/components/Output/PrinterChip.test.tsx +++ b/src/components/Output/PrinterChip.test.tsx @@ -28,7 +28,7 @@ const dot = (r: ReturnType) => (r.container.querySelector("button afterEach(cleanup); beforeEach(() => { readPrinterStatus.mockReset(); - act(() => useLabelStore.setState({ printerState: { phase: "idle" }, printTarget: { ...DEFAULT_PRINT_TARGET, host: "172.17.17.175" } })); + act(() => useLabelStore.setState({ printerState: { phase: "idle" }, printerSettingsTab: null, printTarget: { ...DEFAULT_PRINT_TARGET, host: "172.17.17.175" } })); }); describe("PrinterChip", () => { @@ -74,6 +74,13 @@ describe("PrinterChip", () => { expect(dot(r)).toBe("blocked"); }); + it("opens the printer's own storage, the shortest way there from the design", () => { + const r = render(); + fireEvent.click(r.getByText("172.17.17.175")); + fireEvent.click(r.getByText(en.printerSettings.objects.listHeading)); + expect(useLabelStore.getState().printerSettingsTab).toBe("storedFonts"); + }); + it("opens the configuration echo with the raw replies beside it", async () => { act(() => useLabelStore.setState({ printerState: { phase: "done", key: "net:172.17.17.175:9100", at: 0, report: report({}) } })); const r = render(); diff --git a/src/components/Output/PrinterChip.tsx b/src/components/Output/PrinterChip.tsx index 1dc73522..3513ec75 100644 --- a/src/components/Output/PrinterChip.tsx +++ b/src/components/Output/PrinterChip.tsx @@ -1,5 +1,5 @@ import { useRef, useState } from "react"; -import { ArrowPathIcon, ChevronDownIcon, Cog6ToothIcon, DocumentTextIcon } from "@heroicons/react/16/solid"; +import { ArrowPathIcon, ChevronDownIcon, CircleStackIcon, Cog6ToothIcon, DocumentTextIcon } from "@heroicons/react/16/solid"; import { useT } from "../../hooks/useT"; import { useDismiss } from "../../hooks/useDismiss"; import { formatTemplate } from "../../lib/formatTemplate"; @@ -88,6 +88,9 @@ export function PrinterChip() { {loc.readConfiguration} + setPrinterSettingsTab("storedFonts"))}> + {t.printerSettings.objects.listHeading} + setPrinterSettingsTab("printTarget"))}> {t.printerSettings.title} diff --git a/src/components/PrinterSettings/StoredFontsTab.test.tsx b/src/components/PrinterSettings/StoredFontsTab.test.tsx index bd29bf78..dec19f64 100644 --- a/src/components/PrinterSettings/StoredFontsTab.test.tsx +++ b/src/components/PrinterSettings/StoredFontsTab.test.tsx @@ -1,9 +1,10 @@ // @vitest-environment jsdom import { describe, it, expect, afterEach, beforeEach } from "vitest"; -import { render, cleanup, fireEvent, act } from "@testing-library/react"; +import { render, cleanup, fireEvent, act, within } from "@testing-library/react"; import { StoredFontsTab } from "./StoredFontsTab"; import { useLabelStore } from "../../store/labelStore"; import { getAllFonts, loadFontBytes, removeFont } from "@zplab/core/lib/fontCache"; +import { fallbackTranslations as en } from "../../locales"; beforeEach(async () => { await loadFontBytes(new Uint8Array([0, 1, 0, 0]), "E:ARIAL.TTF"); @@ -12,9 +13,21 @@ beforeEach(async () => { afterEach(() => { cleanup(); - 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); + for (const name of ["E:ARIAL.TTF", "R:ARIAL.TTF", "E:NEW.TTF", "E:OLD.BIN", "R:GONE.TTF", "E:OTHER.TTF"]) removeFont(name); }); +const addFile = async (r: ReturnType, open: string, file: File) => { + fireEvent.click(r.getByText(open)); + // The dialog is portaled, so its hidden input sits outside `container`. + const input = r.baseElement.querySelector('input[type="file"]') as HTMLInputElement; + await act(async () => { + fireEvent.change(input, { target: { files: [file] } }); + }); + await act(async () => { + fireEvent.click(within(r.getByRole("dialog")).getByRole("button", { name: "Add" })); + }); +}; + describe("StoredFontsTab", () => { it("lists a cached font and adds it to the setup script", () => { const { getByLabelText, getByTitle } = render(); @@ -99,43 +112,38 @@ describe("StoredFontsTab", () => { expect(useLabelStore.getState().printerProfile.setupFonts).toBeUndefined(); }); - it("repairs a missing entry under its own path instead of the default drive", async () => { + it("repairs a missing entry under its own path, whatever the picked file is called", async () => { act(() => useLabelStore.setState({ printerProfile: { setupFonts: [{ path: "R:GONE.TTF" }] } })); - const { getByText, getByLabelText, queryByText } = render(); - act(() => { - fireEvent.click(getByText("Upload the file")); - }); - await act(async () => { - fireEvent.change(getByLabelText(/Upload font/), { target: { files: [new File(["x"], "other.ttf")] } }); - }); + const r = render(); + await addFile(r, "Upload the file", new File(["x"], "other.ttf")); expect(getAllFonts().map((f) => f.name)).toContain("R:GONE.TTF"); - expect(queryByText(/Font missing/)).toBeNull(); + expect(r.queryByText(/Font missing/)).toBeNull(); expect(useLabelStore.getState().printerProfile.setupFonts).toEqual([{ path: "R:GONE.TTF" }]); }); + it("keeps an imported upload whose only copy is the profile, rather than replacing it", async () => { + act(() => useLabelStore.setState({ printerProfile: { setupFonts: [{ path: "E:OTHER.TTF", download: "~DYE:OTHER.TTF,A,T,4,,00010000" }] } })); + const r = render(); + await addFile(r, "Upload font…", new File(["x"], "other.ttf")); + expect(useLabelStore.getState().printerProfile.setupFonts).toEqual([{ path: "E:OTHER.TTF", download: "~DYE:OTHER.TTF,A,T,4,,00010000" }]); + // The cache must stay out of it too, or the canvas would draw bytes the setup script never sends. + expect(getAllFonts().map((f) => f.name)).not.toContain("E:OTHER.TTF"); + expect(r.getByRole("alert").textContent).toBe(en.printerSettings.fonts.replayedNameTaken); + }); + it("uploads a font file into the cache and the setup script in one step", async () => { - const { getByLabelText } = render(); - await act(async () => { - fireEvent.change(getByLabelText(/Upload font/), { target: { files: [new File(["x"], "new.ttf")] } }); - }); + const r = render(); + await addFile(r, "Upload font…", new File(["x"], "new.ttf")); expect(getAllFonts().map((f) => f.name)).toContain("E:NEW.TTF"); expect(useLabelStore.getState().printerProfile.setupFonts).toEqual([{ path: "E:NEW.TTF" }]); }); - it("gives a picked file a printer name the ~DY operand can carry", async () => { - const { getByLabelText } = render(); - await act(async () => { - fireEvent.change(getByLabelText(/Upload font/), { target: { files: [new File(["x"], "my logo.ttf")] } }); - }); - expect(useLabelStore.getState().printerProfile.setupFonts).toEqual([{ path: "E:MYLOGO.TTF" }]); - }); - it("locks every profile control while the editor is frozen, instead of snapping back", () => { act(() => useLabelStore.setState({ sourceEdit: { status: "editing", draft: "^XA^XZ", baseline: "^XA^XZ", session: 1 } })); - const { getByLabelText, getByRole } = render(); + const { getByLabelText, getByRole, getByText } = render(); const toggle = getByLabelText(/Send at setup/) as HTMLInputElement; expect(toggle.disabled).toBe(true); - const upload = (getByLabelText(/Upload font/) as HTMLInputElement).closest("div")?.querySelector("button") as HTMLButtonElement; + const upload = getByText("Upload font…") as HTMLButtonElement; expect(upload.disabled).toBe(true); // A disabled control swallows its own events, so the reason sits on the wrapper. act(() => { @@ -145,21 +153,4 @@ describe("StoredFontsTab", () => { act(() => useLabelStore.setState({ sourceEdit: { status: "off" } })); }); - it("refuses a second file that folds onto a cached printer name with different bytes", async () => { - const { getByLabelText, getByText } = render(); - await act(async () => { - fireEvent.change(getByLabelText(/Upload font/), { target: { files: [new File(["other bytes"], "arial.ttf")] } }); - }); - expect(getAllFonts()).toHaveLength(1); - expect(getByText(/different printer filename/)).toBeTruthy(); - expect(useLabelStore.getState().printerProfile.setupFonts).toBeUndefined(); - }); - - it("names a failed upload", async () => { - const { getByLabelText, getByText } = render(); - await act(async () => { - fireEvent.change(getByLabelText(/Upload font/), { target: { files: [new File(["x"], "bad.txt")] } }); - }); - expect(getByText(/Could not load the font file/)).toBeTruthy(); - }); }); diff --git a/src/components/PrinterSettings/StoredFontsTab.tsx b/src/components/PrinterSettings/StoredFontsTab.tsx index 1bbb07b4..1ab75803 100644 --- a/src/components/PrinterSettings/StoredFontsTab.tsx +++ b/src/components/PrinterSettings/StoredFontsTab.tsx @@ -1,21 +1,20 @@ -import { useRef, useState } from "react"; +import { useState } from "react"; import { usePrinterListing } from "../../hooks/usePrinterListing"; import { useT } from "../../hooks/useT"; import { Tooltip } from "../ui/Tooltip"; -import { FONT_FILE_ACCEPT, cachedFontPath, loadFontBytes } from "@zplab/core/lib/fontCache"; +import { cachedFontPath, loadFontBytes } from "@zplab/core/lib/fontCache"; import { withSetupEntry, withoutSetupEntry } from "@zplab/core/lib/setupEntries"; -import { isTrueTypeFileName, prepareFontUpload, printerFontFileName, type FontUploadIssue } from "@zplab/core/lib/customFonts"; +import { isTrueTypeFileName, printerFontFileName } from "@zplab/core/lib/customFonts"; import { listsStoredFont } from "@zplab/core/lib/storedObjects"; import { storedObjectOrigins } from "@zplab/core/lib/storedObjectOrigins"; import { storageKey, storageRefMatchesPath } from "@zplab/core/lib/storagePath"; import { useCachedFonts } from "../../hooks/useCachedFonts"; -import { useUpload } from "../../hooks/useUpload"; import { useLabelStore, selectEditorFrozen } from "../../store/labelStore"; -import { fontNameIssueText } from "../../lib/fontNameIssueText"; import { isDesktopShell } from "../../lib/platform"; import { PrinterObjectActions, PrinterOriginMark, PrinterStorageBar, StoredObjectsHeading } from "./PrinterStorage"; import { buttonCls, disabledCls } from "../ui/formStyles"; import { ConfirmDialog } from "../ui/ConfirmDialog"; +import { AddFontDialog } from "../Fonts/AddFontDialog"; import { useStoredObjectHover } from "../../hooks/useStoredObjectHover"; /** Why a profile row cannot be provisioned. */ @@ -64,45 +63,36 @@ export function StoredFontsTab() { const toggle = (path: string, on: boolean) => patchPrinterProfileWith((p) => ({ setupFonts: on ? withSetupEntry(p.setupFonts, { path }) : withoutSetupEntry(p.setupFonts, path) })); - const fileRef = useRef(null); - const [repairTarget, setRepairTarget] = useState(); + // One upload path for the app, so the tab opens the font panel's dialog instead of keeping its own. + const [adding, setAdding] = useState<{ repairPath?: string }>(); + const [uploadIssue, setUploadIssue] = useState<"replayedNameTaken" | "refused">(); + + /** A replayed entry carries the only copy of its bytes, and a profile write replaces an entry + * whole. Nothing may be written before the refusal. */ + const provision = async ({ path, bytes }: { path: string; bytes: Uint8Array }) => { + if (replayedKeys.has(storageKey(path))) return setUploadIssue("replayedNameTaken"); + // The entry has to meet a cache that already holds the file, or its row reads as missing bytes. + await loadFontBytes(bytes, path); + setUploadIssue(toggle(path, true) ? undefined : "refused"); + }; // A replayed upload lives in the profile only, so removing it is a delete, not a toggle. const [pendingRemove, setPendingRemove] = useState(); - const pick = (target?: string) => { - setRepairTarget(target); - fileRef.current?.click(); - }; - const { busy: uploading, issue: uploadIssue, start: uploadFile } = useUpload<"error" | "refused" | FontUploadIssue, File, [target?: string]>(async (file, target) => { - const prepared = await prepareFontUpload(file, target); - if (!prepared.ok) return prepared.reason; - await loadFontBytes(prepared.bytes, prepared.path); - if (!toggle(prepared.path, true)) return "refused"; - return null; - }, "error"); return (
- { - const file = e.target.files?.[0]; - e.target.value = ""; - if (file) uploadFile(file, repairTarget); - }} - /> - - {uploadIssue && {{ error: loc.uploadError, notAFont: loc.uploadError, ...fontNameIssueText(t), refused: t.printerSettings.frozenHint }[uploadIssue]}} + {uploadIssue && ( + + {uploadIssue === "refused" ? t.printerSettings.frozenHint : loc.replayedNameTaken} + + )}
{isDesktopShell && } {rows.length === 0 ? ( @@ -134,7 +124,7 @@ export function StoredFontsTab() { {hostObject && } {issue === "missingBytes" && repairable(path) && ( - )} @@ -181,6 +171,15 @@ export function StoredFontsTab() { )}
+ {adding && ( + { + if (picked) void provision(picked); + setAdding(undefined); + }} + /> + )} {pendingRemove !== undefined && (