Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 32 additions & 14 deletions src/components/Fonts/AddFontDialog.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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. */
Expand All @@ -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<HTMLInputElement>(null);
// Closing abandons a pending read, so its result must not reach the cache.
const aborted = useRef(false);
const [candidate, setCandidate] = useState<FontCandidate | null>(null);
const [name, setName] = useState('');
const [name, setName] = useState(repairPath ?? '');
const titleId = useId();
const listLabelId = useId();
const issueId = useId();
Expand All @@ -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);
};
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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 (
<div aria-hidden="true" className="flex flex-col gap-2 px-2 py-2">
{LOADING_ROW_WIDTHS.map((width) => (
<span key={width} className="h-3 rounded bg-border animate-pulse" style={{ width }} />
))}
</div>
);
}

// ── SystemFontList ─────────────────────────────────────────────────────────────

function SystemFontList({
Expand Down Expand Up @@ -241,15 +257,17 @@ function SystemFontList({
aria-disabled={busy}
autoFocus
/>
{fonts === null && !failed && <p className="text-[10px] text-muted">…</p>}
{fonts !== null && fonts.length === 0 && <p className="text-[10px] text-muted">{t.fonts.noInstalledFonts}</p>}
{fonts !== null && fonts.length > 0 && shown.length === 0 && <p className="text-[10px] text-muted">{t.fonts.noFilterMatch}</p>}
{failed && <p className="text-[10px] font-mono text-red-400">{t.fonts.systemFontsFailed}</p>}
{/* Fixed height, so neither the arriving list nor a filter resizes the dialog under the pointer. */}
<div
role="listbox"
aria-labelledby={labelId}
className="max-h-56 overflow-auto rounded border border-border text-xs"
aria-busy={fonts === null && !failed}
className="h-56 overflow-auto rounded border border-border text-xs"
>
{failed && <p className="px-2 py-1 font-mono text-[10px] text-red-400">{t.fonts.systemFontsFailed}</p>}
{fonts === null && !failed && <LoadingRows />}
{fonts !== null && fonts.length === 0 && <p className="px-2 py-1 text-[10px] text-muted">{t.fonts.noInstalledFonts}</p>}
{fonts !== null && fonts.length > 0 && shown.length === 0 && <p className="px-2 py-1 text-[10px] text-muted">{t.fonts.noFilterMatch}</p>}
{shown.map((font, i) => {
const selected = font.path === selectedPath;
return (
Expand Down
8 changes: 8 additions & 0 deletions src/components/Fonts/FontManager.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(<FontManager />);
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(<FontManager />);
await addFile(r, new File(["x"], "日本.ttf"));
Expand Down
20 changes: 11 additions & 9 deletions src/components/Fonts/FontManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -167,20 +176,13 @@ export function FontManager() {
</button>
{adding && (
<AddFontDialog
onDone={(uploadedPath) => {
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);
}}
/>
Expand Down
9 changes: 8 additions & 1 deletion src/components/Output/PrinterChip.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const dot = (r: ReturnType<typeof render>) => (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", () => {
Expand Down Expand Up @@ -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(<PrinterChip />);
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(<PrinterChip />);
Expand Down
5 changes: 4 additions & 1 deletion src/components/Output/PrinterChip.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -88,6 +88,9 @@ export function PrinterChip() {
<DropdownItem icon={DocumentTextIcon} disabled={reading} onClick={closeThen(openConfiguration)}>
{loc.readConfiguration}
</DropdownItem>
<DropdownItem icon={CircleStackIcon} onClick={closeThen(() => setPrinterSettingsTab("storedFonts"))}>
{t.printerSettings.objects.listHeading}
</DropdownItem>
<DropdownItem icon={Cog6ToothIcon} onClick={closeThen(() => setPrinterSettingsTab("printTarget"))}>
{t.printerSettings.title}
</DropdownItem>
Expand Down
75 changes: 33 additions & 42 deletions src/components/PrinterSettings/StoredFontsTab.test.tsx
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -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<typeof render>, 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(<StoredFontsTab />);
Expand Down Expand Up @@ -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(<StoredFontsTab />);
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(<StoredFontsTab />);
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(<StoredFontsTab />);
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(<StoredFontsTab />);
await act(async () => {
fireEvent.change(getByLabelText(/Upload font/), { target: { files: [new File(["x"], "new.ttf")] } });
});
const r = render(<StoredFontsTab />);
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(<StoredFontsTab />);
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(<StoredFontsTab />);
const { getByLabelText, getByRole, getByText } = render(<StoredFontsTab />);
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(() => {
Expand All @@ -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(<StoredFontsTab />);
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(<StoredFontsTab />);
await act(async () => {
fireEvent.change(getByLabelText(/Upload font/), { target: { files: [new File(["x"], "bad.txt")] } });
});
expect(getByText(/Could not load the font file/)).toBeTruthy();
});
});
Loading
Loading