Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
278 changes: 264 additions & 14 deletions src/components/Fonts/FontManager.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,103 @@
import { useRef, useState, useCallback } from 'react';
import { PlusIcon, TrashIcon } from '@heroicons/react/16/solid';
import { getAllFonts, loadFontFile, removeFont } from '../../lib/fontCache';
import { useFontCacheVersion } from '../../hooks/useFontCacheVersion';
import { useLabelStore } from '../../store/labelStore';
import { useT } from '../../lib/useT';
import {
DEFAULT_FONT_DRIVE,
ZPL_DRIVE_PREFIXES,
nextFreeAlias,
normalizeAlias,
uploadedFontPath,
upsertCustomFontMapping,
} from '../../lib/customFonts';
import { inputCls, labelCls } from '../Properties/styles';
import { CollapsibleSection } from '../ui/CollapsibleSection';
import { ConfirmDialog } from '../ui/ConfirmDialog';
import type { CustomFontMapping } from '../../types/ObjectType';

const PATHS_DATALIST_ID = 'zpl-custom-font-paths';

const addBtnCls =
'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';

export function FontManager() {
const t = useT();
useFontCacheVersion();
const customFonts = useLabelStore((s) => s.label.customFonts);
const setLabelConfig = useLabelStore((s) => s.setLabelConfig);

const fonts = getAllFonts();
const [adding, setAdding] = useState(false);
const [pendingDelete, setPendingDelete] = useState<string | null>(null);

const uploadedNames = new Set(fonts.map((f) => f.name));

// Partition customFonts: mappings whose path resolves to an uploaded
// font are reflected inline on that font row; the rest live in the
// printer-resident sub-section. Aliases are namespaced globally per
// label, so duplicate detection runs across both lists.
const aliasByPath = new Map<string, string>();
const manualMappings: CustomFontMapping[] = [];
for (const m of customFonts ?? []) {
aliasByPath.set(m.path, m.alias);
const isUploadedPath =
m.path.startsWith(DEFAULT_FONT_DRIVE) &&
uploadedNames.has(m.path.slice(DEFAULT_FONT_DRIVE.length));
if (!isUploadedPath) manualMappings.push(m);
}

const aliasCounts = new Map<string, number>();
for (const m of customFonts ?? []) {
if (m.alias) aliasCounts.set(m.alias, (aliasCounts.get(m.alias) ?? 0) + 1);
}
const isDuplicateAlias = (alias: string) =>
!!alias && (aliasCounts.get(alias) ?? 0) > 1;

const replaceList = (next: CustomFontMapping[]) => {
setLabelConfig({ customFonts: next.length > 0 ? next : undefined });
};

const setAliasForPath = (path: string, rawAlias: string) => {
replaceList(
upsertCustomFontMapping(customFonts, path, normalizeAlias(rawAlias)),
);
};

const updateManualAt = (path: string, patch: Partial<CustomFontMapping>) => {
const list = customFonts ?? [];
replaceList(
list.map((m) =>
m.path === path
? {
alias:
patch.alias !== undefined
? normalizeAlias(patch.alias)
: m.alias,
path: patch.path ?? m.path,
}
: m,
),
);
};

const removeByPath = (path: string) => {
replaceList((customFonts ?? []).filter((m) => m.path !== path));
};

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
// can still type any letter manually if they want the override.
const taken = (customFonts ?? []).map((m) => m.alias).filter(Boolean);
replaceList([
...(customFonts ?? []),
{ alias: nextFreeAlias(taken), path: '' },
]);
};

const uploadedPaths = fonts.map((f) => uploadedFontPath(f.name));

return (
<div className="p-3 flex flex-col gap-3">
Expand All @@ -22,23 +110,69 @@ export function FontManager() {
)}

<div className="flex flex-col gap-1">
{fonts.map((font) => (
<FontEntry key={font.name} name={font.name} />
))}
{fonts.map((font) => {
const path = uploadedFontPath(font.name);
const alias = aliasByPath.get(path) ?? '';
return (
<FontEntry
key={font.name}
name={font.name}
alias={alias}
duplicate={isDuplicateAlias(alias)}
onAliasChange={(v) => setAliasForPath(path, v)}
onRequestDelete={() => setPendingDelete(font.name)}
/>
);
})}
</div>

{adding ? (
<AddFontForm onDone={() => setAdding(false)} />
) : (
<button
type="button"
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"
onClick={() => setAdding(true)}
>
<button type="button" className={addBtnCls} onClick={() => setAdding(true)}>
<span className="text-accent">+</span>
{t.fonts.addFont}
</button>
)}

<CollapsibleSection
id="fonts-printer-resident"
title={t.fonts.manualMappingsHeading}
defaultOpen={false}
>
<ManualMappingsSection
mappings={manualMappings}
hint={t.fonts.manualMappingsHint}
addLabel={t.fonts.addManualMapping}
isDuplicateAlias={isDuplicateAlias}
onUpdate={updateManualAt}
onRemove={removeByPath}
onAdd={addManual}
/>
</CollapsibleSection>

<datalist id={PATHS_DATALIST_ID}>
{ZPL_DRIVE_PREFIXES.map((p) => (
<option key={p} value={p} />
))}
{uploadedPaths.map((p) => (
<option key={p} value={p} />
))}
</datalist>

{pendingDelete !== null && (
<ConfirmDialog
message={t.fonts.deleteConfirm}
confirmLabel={t.fonts.delete}
cancelLabel={t.app.cancel}
destructive
onConfirm={() => {
removeFont(pendingDelete);
setPendingDelete(null);
}}
onCancel={() => setPendingDelete(null)}
/>
)}
</div>
);
}
Expand All @@ -47,18 +181,48 @@ export function FontManager() {

interface FontEntryProps {
name: string;
alias: string;
duplicate: boolean;
onAliasChange: (next: string) => void;
onRequestDelete: () => void;
}

function FontEntry({ name }: FontEntryProps) {
function FontEntry({
name,
alias,
duplicate,
onAliasChange,
onRequestDelete,
}: FontEntryProps) {
const t = useT();

return (
<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">
<span className="font-mono text-[11px] text-accent w-4 text-center shrink-0">F</span>
<span className="flex-1 font-mono text-xs text-text truncate">{name}</span>
<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">
<span
className="font-mono text-xs text-text truncate"
title={name}
>
{name}
</span>
<input
type="text"
className={`${inputCls} text-center ${duplicate ? 'border-red-500' : ''}`}
maxLength={1}
placeholder="A-Z"
title={
duplicate
? t.label.customFontsDuplicateAlias
: alias
? t.fonts.aliasAssigned
: t.fonts.aliasHint
}
aria-invalid={duplicate || undefined}
value={alias}
onChange={(e) => onAliasChange(e.target.value)}
/>
<button
type="button"
onClick={() => removeFont(name)}
onClick={onRequestDelete}
className="opacity-0 group-hover:opacity-100 font-mono text-[10px] text-muted hover:text-red-400 transition-all px-1"
title={t.fonts.delete}
aria-label={t.fonts.delete}
Expand All @@ -69,6 +233,89 @@ function FontEntry({ name }: FontEntryProps) {
);
}

// ── ManualMappingsSection ──────────────────────────────────────────────────────

interface ManualMappingsSectionProps {
mappings: CustomFontMapping[];
hint: string;
addLabel: string;
isDuplicateAlias: (alias: string) => boolean;
onUpdate: (currentPath: string, patch: Partial<CustomFontMapping>) => void;
onRemove: (path: string) => void;
onAdd: () => void;
}

function ManualMappingsSection({
mappings,
hint,
addLabel,
isDuplicateAlias,
onUpdate,
onRemove,
onAdd,
}: ManualMappingsSectionProps) {
const t = useT();
// Auto-remove rows whose alias AND path are both empty when focus
// leaves them. requestAnimationFrame defers the check so tabbing
// between sibling inputs does not delete the row mid-traversal.
const handleBlur = (path: string, alias: string, value: string) => {
requestAnimationFrame(() => {
if (!alias && !value) onRemove(path);
});
};
Comment thread
u8array marked this conversation as resolved.
Outdated

return (
<div className="flex flex-col gap-2">
<p className="text-xs text-muted px-1 leading-relaxed">{hint}</p>
{mappings.map((m) => {
const dup = isDuplicateAlias(m.alias);
return (
<div
key={m.path || `__new__${mappings.indexOf(m)}`}
Comment thread
u8array marked this conversation as resolved.
Outdated
className="grid grid-cols-[3rem_1fr_auto] gap-2 items-center"
onBlur={() => handleBlur(m.path, m.alias, m.path)}
Comment thread
u8array marked this conversation as resolved.
Outdated
>
<input
type="text"
className={`${inputCls} text-center ${dup ? 'border-red-500' : ''}`}
maxLength={1}
placeholder="A-Z"
title={
dup
? t.label.customFontsDuplicateAlias
: t.label.customFontsAliasHint
}
aria-invalid={dup || undefined}
value={m.alias}
onChange={(e) => onUpdate(m.path, { alias: e.target.value })}
/>
<input
type="text"
className={inputCls}
list={PATHS_DATALIST_ID}
placeholder={t.label.customFontsPath}
value={m.path}
onChange={(e) => onUpdate(m.path, { path: e.target.value })}
/>
<button
type="button"
className="p-1 text-muted hover:text-text"
onClick={() => onRemove(m.path)}
aria-label={t.label.customFontsRemove}
>
<TrashIcon className="w-3.5 h-3.5" />
</button>
</div>
);
})}
<button type="button" className={addBtnCls} onClick={onAdd}>
<PlusIcon className="w-3 h-3 text-accent" />
{addLabel}
</button>
</div>
);
}

// ── AddFontForm ────────────────────────────────────────────────────────────────

interface AddFontFormProps {
Expand All @@ -83,7 +330,10 @@ function AddFontForm({ onDone }: AddFontFormProps) {
const [uploadFailed, setUploadFailed] = useState(false);

const handleFileChange = useCallback(async (file: File) => {
const printerName = name.trim() || file.name;
// Default to the source filename uppercased — Zebra printer storage
// conventionally uses uppercase ALL.TTF style identifiers, and a
// freshly-picked file is almost always the user's intended name.
const printerName = name.trim() || file.name.toUpperCase();
setUploading(true);
setUploadFailed(false);
try {
Expand Down
35 changes: 28 additions & 7 deletions src/components/Properties/PropertiesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,7 @@ import { CollapsibleSection } from "../ui/CollapsibleSection";
import { AlignButtons } from "./AlignButtons";
import { inputCls, labelCls } from "./styles";
import type { LabelConfig } from "../../types/ObjectType";

/** Built-in alphanumeric font IDs the Zebra firmware ships with. Used as
* suggestions for ^CF — the input stays free-text so user-defined ^CW
* aliases (single letters) can still be entered. */
const ZPL_BUILTIN_FONT_IDS = ['0', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'] as const;
import { ZPL_BUILTIN_FONT_IDS } from "../../lib/customFonts";

interface PropertiesPanelProps {
/** Imperative handle on the canvas — used for actions that need live render
Expand Down Expand Up @@ -347,6 +343,30 @@ function LabelConfigPanel({
onUpdate({ widthMm: p.widthMm, heightMm: p.heightMm, dpmm: p.dpmm });
};

// ^CF / ^A suggestions: built-in font letters plus every alias the
// user has registered via ^CW. Set-based dedup keeps user-overridden
// built-ins from appearing twice. The label on custom aliases shows
// the referenced path so a bare letter is not mistaken for a built-in.
const aliasPaths = new Map<string, string>();
for (const m of label.customFonts ?? []) {
if (m.alias) aliasPaths.set(m.alias, m.path);
}
const fontIdOptions = Array.from(
new Set([
...ZPL_BUILTIN_FONT_IDS,
...aliasPaths.keys(),
]),
).map((id) => {
const path = aliasPaths.get(id);
// Strip the drive prefix (E:, R:, ...) from the display label;
// the full path stays in the underlying customFonts entry and is
// emitted verbatim to ZPL.
return {
value: id,
label: path ? path.replace(/^[A-Z]:/, '') : undefined,
};
});

return (
<div className="flex flex-col">
<div className="px-3 py-2.5 border-b border-border">
Expand Down Expand Up @@ -806,10 +826,11 @@ function LabelConfigPanel({
</CollapsibleSection>
</div>
<datalist id="zpl-default-font-ids">
{ZPL_BUILTIN_FONT_IDS.map((id) => (
<option key={id} value={id} />
{fontIdOptions.map((opt) => (
<option key={opt.value} value={opt.value} label={opt.label} />
))}
</datalist>
</div>
);
}

Loading