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
11 changes: 11 additions & 0 deletions src/components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
DocumentArrowDownIcon,
TableCellsIcon,
PrinterIcon,
Cog6ToothIcon,
PaperAirplaneIcon,
GlobeAltIcon,
XMarkIcon,
Expand All @@ -34,6 +35,7 @@ import {
} from "@heroicons/react/16/solid";
import { useLabelStore, useHistory, selectLabelaryNoticeRequired } from "../store/labelStore";
import { LabelaryNoticeModal } from "./Output/LabelaryNoticeModal";
import { PrinterSettingsModal } from "./PrinterSettings/PrinterSettingsModal";
import { localeNames } from "../locales";
import type { LocaleCode } from "../locales";
import { mmToUnit } from "../lib/units";
Expand All @@ -51,6 +53,7 @@ export function AppShell() {
const pages = useLabelStore((s) => s.pages);
const selectObject = useLabelStore((s) => s.selectObject);
const addPage = useLabelStore((s) => s.addPage);
const setPrinterSettingsTab = useLabelStore((s) => s.setPrinterSettingsTab);
const locale = useLabelStore((s) => s.locale);
const setLocale = useLabelStore((s) => s.setLocale);
const theme = useLabelStore((s) => s.theme);
Expand Down Expand Up @@ -205,6 +208,13 @@ export function AppShell() {
<DropdownItem icon={ArrowUpTrayIcon} onClick={openZplImport}>
{t.app.importZpl}
</DropdownItem>
<DropdownSeparator />
<DropdownItem
icon={Cog6ToothIcon}
onClick={() => setPrinterSettingsTab("mediaFeed")}
>
{t.printerSettings.open}
</DropdownItem>
<DropdownItem
icon={ArrowDownTrayIcon}
onClick={handleDownload}
Expand Down Expand Up @@ -372,6 +382,7 @@ export function AppShell() {
}}
/>
)}
<PrinterSettingsModal />
</div>
);
}
176 changes: 176 additions & 0 deletions src/components/PrinterSettings/MediaFeedTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { useId } from "react";
import { useT } from "../../lib/useT";
import { useLabelStore } from "../../store/labelStore";
import { inputCls, labelCls } from "../Properties/styles";
import {
MEDIA_FEED_VALUES,
MEDIA_TRACKING_VALUES,
isMediaFeedMode,
isMediaTracking,
type MediaFeedMode,
type MediaTracking,
} from "../../types/ObjectType";
import { ZplCheckbox, ZplCommandLabel, ZplField } from "./zplFieldPrimitives";

type LocMediaFeed = ReturnType<typeof useT>["printerSettings"]["mediaFeed"];

/** Static lookup table from enum value to the locale-key that
* describes it. Compile-time check via `satisfies` guarantees
* every enum value has a matching key and every key actually
* exists on the locale block, so a missing translation surfaces
* as a TS error rather than `undefined` at runtime. */
const TRACKING_LABEL_KEYS = {
N: "mediaTrackingN",
Y: "mediaTrackingY",
W: "mediaTrackingW",
M: "mediaTrackingM",
A: "mediaTrackingA",
} as const satisfies Record<MediaTracking, keyof LocMediaFeed>;

const FEED_LABEL_KEYS = {
F: "mediaFeedF",
C: "mediaFeedC",
L: "mediaFeedL",
N: "mediaFeedN",
S: "mediaFeedS",
} as const satisfies Record<MediaFeedMode, keyof LocMediaFeed>;

/** Width preset for short-number inputs in the modal (max printer
* ^ML value is ~32000 dots, so 5 digits cover every case). Same
* visual size that every numeric setting in the modal will reuse. */
const SHORT_NUMERIC_INPUT_WIDTH = "w-32";

/** Tab 1 of the Printer Settings Modal. All four fields write to
* the shared `labelConfig` store; the ZPL generator emits the
* corresponding ^MN / ^ML / ^MF / ^XB commands in the per-label
* header section. */
export function MediaFeedTab() {
const t = useT();
const label = useLabelStore((s) => s.label);
const setLabelConfig = useLabelStore((s) => s.setLabelConfig);
const loc = t.printerSettings.mediaFeed;
const trackingId = useId();
const lengthId = useId();

return (
<div className="flex flex-col gap-4">
<ZplField>
<ZplCommandLabel text={loc.mediaTracking} command="^MN" htmlFor={trackingId} />
<select
id={trackingId}
className={inputCls}
value={label.mediaTracking ?? ""}
onChange={(e) => {
const v = e.target.value;
setLabelConfig({ mediaTracking: isMediaTracking(v) ? v : undefined });
}}
>
<option value="">{t.printerSettings.defaultOption}</option>
{MEDIA_TRACKING_VALUES.map((m) => (
<option key={m} value={m}>
{m} {loc[TRACKING_LABEL_KEYS[m]]}
</option>
))}
</select>
</ZplField>

<ZplField>
<ZplCommandLabel text={loc.maxLabelLength} command="^ML" htmlFor={lengthId} />
{/* Compact input plus unit suffix: a short number in a wide
field reads as overengineered. The suffix fills the
remaining row width semantically rather than leaving
dead space. */}
<div className="flex items-center gap-2">
<div className={SHORT_NUMERIC_INPUT_WIDTH}>
<input
id={lengthId}
type="number"
className={inputCls}
value={label.maxLabelLength ?? ""}
min={1}
onChange={(e) => {
const n = Number.parseInt(e.target.value, 10);
setLabelConfig({
maxLabelLength: Number.isFinite(n) && n > 0 ? n : undefined,
});
}}
/>
</div>
<span className={labelCls}>{t.printerSettings.dotsUnit}</span>
</div>
</ZplField>

{/* ^MF carries two positional params, so the row's "control"
slot is a 2-col grid instead of a single input. Same field
shape as the other commands (label + control), not an
indented sub-section. The indent convention is reserved
for checkbox-gated sub-settings (see FELDBLOCK), not for
commands with multiple params. */}
<ZplField>
<ZplCommandLabel text={loc.mediaFeedHeading} command="^MF" />
<div className="grid grid-cols-2 gap-2">
<FeedSelect
label={loc.mediaFeedPowerUp}
value={label.mediaFeedPowerUp}
onChange={(v) => setLabelConfig({ mediaFeedPowerUp: v })}
placeholder={t.printerSettings.defaultOption}
optionLabel={(m) => loc[FEED_LABEL_KEYS[m]]}
/>
<FeedSelect
label={loc.mediaFeedHeadClose}
value={label.mediaFeedHeadClose}
onChange={(v) => setLabelConfig({ mediaFeedHeadClose: v })}
placeholder={t.printerSettings.defaultOption}
optionLabel={(m) => loc[FEED_LABEL_KEYS[m]]}
/>
</div>
</ZplField>

<ZplCheckbox
text={loc.suppressBackfeed}
command="^XB"
checked={!!label.suppressBackfeed}
onChange={(v) => setLabelConfig({ suppressBackfeed: v ? true : undefined })}
/>
</div>
);
}

interface FeedSelectProps {
label: string;
value: MediaFeedMode | undefined;
onChange: (v: MediaFeedMode | undefined) => void;
placeholder: string;
optionLabel: (m: MediaFeedMode) => string;
}

/** Inner select-with-ZPL-label helper. Extracted so the two ^MF
* positional params don't repeat the same select scaffolding.
* Uses `useId` instead of caller-supplied IDs so multi-instance
* mounting can't collide. */
function FeedSelect({ label, value, onChange, placeholder, optionLabel }: FeedSelectProps) {
const id = useId();
return (
<div className="flex flex-col gap-1">
<label htmlFor={id} className={labelCls}>
{label}
</label>
<select
id={id}
className={inputCls}
value={value ?? ""}
onChange={(e) => {
const v = e.target.value;
onChange(isMediaFeedMode(v) ? v : undefined);
}}
>
<option value="">{placeholder}</option>
{MEDIA_FEED_VALUES.map((m) => (
<option key={m} value={m}>
{m} {optionLabel(m)}
</option>
))}
</select>
</div>
);
}
140 changes: 140 additions & 0 deletions src/components/PrinterSettings/PrinterSettingsModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { useId, type FC } from "react";
import { XMarkIcon } from "@heroicons/react/16/solid";
import { useT } from "../../lib/useT";
import { useLabelStore, type PrinterSettingsTab } from "../../store/labelStore";
import { DialogShell } from "../ui/DialogShell";
import { MediaFeedTab } from "./MediaFeedTab";

/** Tab IDs in display order. The "is this tab shipped?" state is
* derived from TAB_COMPONENTS membership below, so adding a tab
* is a one-line registry entry plus the locale strings — no dual
* source of truth to keep in sync. */
const TABS: readonly PrinterSettingsTab[] = [
"mediaFeed",
"printQuality",
"clockTime",
"encodingLanguage",
"identity",
];

/** Per-tab content registry. Tabs absent here render as disabled
* WIP rail entries; the rail prevents selection so the modal
* never has to handle a missing-component branch. Annotated
* (not `satisfies`) so `TAB_COMPONENTS[tab]` returns `FC |
* undefined` for any tab id without a narrowing cast. */
const TAB_COMPONENTS: Partial<Record<PrinterSettingsTab, FC>> = {
mediaFeed: MediaFeedTab,
};

/** Modal-box class kept as a constant rather than inline so the
* width / shadow tuning is named and stays consistent if other
* modals adopt the same shell. */
const MODAL_BOX_CLS =
"bg-surface border border-border rounded-lg shadow-2xl " +
"w-[720px] max-w-[95vw] max-h-[85vh] flex flex-col overflow-hidden";

/** Printer Settings modal — five tabs of pure-ZPL printer config.
* Vertical tab rail scales to N tabs and mirrors the
* Properties-Panel column-of-sections feel. Instant-apply
* semantics: edits commit live to labelConfig (no Save/Cancel),
* matching the rest of the editor. Each field's ZPL command sits
* docked rightwards via `ZplCommandLabel`. */
export function PrinterSettingsModal() {
const t = useT();
const tab = useLabelStore((s) => s.printerSettingsTab);
const setTab = useLabelStore((s) => s.setPrinterSettingsTab);
const titleId = useId();
const subtitleId = useId();

if (!tab) return null;

const ActiveTab = TAB_COMPONENTS[tab];

return (
<DialogShell
portal
labelledBy={titleId}
describedBy={subtitleId}
onClose={() => setTab(null)}
boxClassName={MODAL_BOX_CLS}
>
<header className="px-5 py-4 border-b border-border flex items-start justify-between gap-4">
<div className="flex flex-col gap-0.5">
<h2 id={titleId} className="text-sm font-medium text-text">
{t.printerSettings.title}
</h2>
<p id={subtitleId} className="text-[11px] text-muted">
{t.printerSettings.subtitle}
</p>
</div>
<button
type="button"
onClick={() => setTab(null)}
aria-label={t.printerSettings.close}
className="text-muted hover:text-text transition-colors p-1 -m-1"
>
<XMarkIcon className="w-4 h-4" />
</button>
</header>

<div className="flex-1 flex min-h-0">
<nav
// Tab-rail width fits the longest localised tab title at
// 10px-uppercase tracking-wider density across all 32
// locales (worst case: "Kodierung & Sprache" / "Encoding
// & Language").
className="w-44 shrink-0 border-r border-border bg-surface-2/40 py-3"
aria-label={t.printerSettings.title}
>
{TABS.map((id) => {
const active = id === tab;
// WIP is derived: a tab is WIP iff no content
// component is registered for it. Single source of
// truth, no flag to keep in sync.
const wip = !(id in TAB_COMPONENTS);
Comment thread
u8array marked this conversation as resolved.
const railRowCls = active
? "text-accent border-accent -ml-px bg-accent/5"
: wip
? "text-muted/40 border-transparent cursor-not-allowed"
: "text-muted border-transparent hover:text-text hover:bg-surface-2";
return (
<button
key={id}
type="button"
onClick={() => setTab(id)}
disabled={wip}
aria-current={active ? "page" : undefined}
className={
"w-full text-left flex items-center justify-between gap-2 px-3 py-1.5 " +
"font-mono text-[10px] uppercase tracking-wider " +
"border-l-2 transition-colors " +
railRowCls
}
>
<span>{t.printerSettings.tabs[id]}</span>
{wip && <span className="text-[9px] text-muted/50">{t.printerSettings.wip}</span>}
</button>
);
})}
</nav>

<section className="flex-1 min-h-0 overflow-y-auto px-5 py-4">
{ActiveTab && <ActiveTab />}
</section>
</div>

<footer className="px-5 py-3 border-t border-border flex items-center justify-between gap-4">
<span className="text-[11px] text-muted">
{t.printerSettings.instantApply}
</span>
<button
type="button"
onClick={() => setTab(null)}
className="px-4 py-1.5 rounded text-xs bg-surface-2 text-text border border-border hover:bg-surface transition-colors"
>
{t.printerSettings.close}
</button>
</footer>
</DialogShell>
);
}
Loading
Loading