From dc73a222d673093e172caff8fa9ea05962d9ca18 Mon Sep 17 00:00:00 2001 From: u8array Date: Fri, 9 Oct 2026 16:50:53 +0200 Subject: [PATCH 1/2] feat(printer): one output dialog for every way the label leaves the app --- README.md | 3 +- src-tauri/capabilities/default.json | 1 + src/components/AppShell.tsx | 46 +-- src/components/Output/OutputDialog.test.tsx | 188 ++++++++++++ src/components/Output/OutputDialog.tsx | 156 ++++++++++ src/components/Output/PrinterChip.test.tsx | 20 +- src/components/Output/PrinterChip.tsx | 4 + ....tsx => ZplSendPanel.batchNotice.test.tsx} | 20 +- ...k.test.tsx => ZplSendPanel.check.test.tsx} | 6 +- ....test.tsx => ZplSendPanel.target.test.tsx} | 12 +- ...rintToZebraDialog.tsx => ZplSendPanel.tsx} | 70 ++--- ...usb.test.tsx => ZplSendPanel.usb.test.tsx} | 10 +- .../PrinterSettings/PrintTargetTab.test.tsx | 7 + .../PrinterSettings/PrintTargetTab.tsx | 5 +- src/components/ui/formStyles.ts | 2 + src/hooks/useNativeMenu.rebuild.test.tsx | 11 +- src/hooks/useZplImportExport.ts | 18 +- src/index.css | 40 +++ src/lib/menuModel.test.ts | 27 +- src/lib/menuModel.ts | 25 +- src/lib/menuSignature.test.ts | 1 - src/lib/outputChoice.test.ts | 73 +++++ src/lib/outputChoice.ts | 70 +++++ src/lib/printPreview.ts | 41 --- src/lib/printSheet.test.ts | 280 ++++++++++++++++++ src/lib/printSheet.ts | 163 ++++++++++ src/lib/printWayText.test.ts | 15 + src/lib/printWayText.ts | 17 ++ src/locales/ar.ts | 20 +- src/locales/bg.ts | 20 +- src/locales/cs.ts | 20 +- src/locales/da.ts | 20 +- src/locales/de.ts | 20 +- src/locales/el.ts | 20 +- src/locales/en.ts | 20 +- src/locales/es.ts | 20 +- src/locales/et.ts | 20 +- src/locales/fa.ts | 20 +- src/locales/fi.ts | 20 +- src/locales/fr.ts | 20 +- src/locales/he.ts | 20 +- src/locales/hr.ts | 20 +- src/locales/hu.ts | 20 +- src/locales/it.ts | 20 +- src/locales/ja.ts | 20 +- src/locales/ko.ts | 20 +- src/locales/lt.ts | 20 +- src/locales/lv.ts | 20 +- src/locales/nl.ts | 20 +- src/locales/no.ts | 20 +- src/locales/pl.ts | 20 +- src/locales/pt.ts | 20 +- src/locales/ro.ts | 20 +- src/locales/sk.ts | 20 +- src/locales/sl.ts | 20 +- src/locales/sr.ts | 20 +- src/locales/sv.ts | 20 +- src/locales/tr.ts | 20 +- src/locales/zh-hans.ts | 20 +- src/locales/zh-hant.ts | 20 +- src/store/slices/uiSlice.ts | 14 +- src/test/zebraPrint.test.ts | 24 -- 62 files changed, 1704 insertions(+), 305 deletions(-) create mode 100644 src/components/Output/OutputDialog.test.tsx create mode 100644 src/components/Output/OutputDialog.tsx rename src/components/Output/{PrintToZebraDialog.batchNotice.test.tsx => ZplSendPanel.batchNotice.test.tsx} (77%) rename src/components/Output/{PrintToZebraDialog.check.test.tsx => ZplSendPanel.check.test.tsx} (92%) rename src/components/Output/{PrintToZebraDialog.target.test.tsx => ZplSendPanel.target.test.tsx} (83%) rename src/components/Output/{PrintToZebraDialog.tsx => ZplSendPanel.tsx} (84%) rename src/components/Output/{PrintToZebraDialog.usb.test.tsx => ZplSendPanel.usb.test.tsx} (90%) create mode 100644 src/lib/outputChoice.test.ts create mode 100644 src/lib/outputChoice.ts create mode 100644 src/lib/printSheet.test.ts create mode 100644 src/lib/printSheet.ts create mode 100644 src/lib/printWayText.test.ts create mode 100644 src/lib/printWayText.ts diff --git a/README.md b/README.md index 07dafbe4..613d2d9f 100644 --- a/README.md +++ b/README.md @@ -81,9 +81,8 @@ Select several objects with Shift-click or a lasso. Position and size changes ap The **ZPL** panel at the bottom shows the generated ZPL, **Copy** puts it on the clipboard. On desktop the panel names the configured printer. A click shows its state and offers to read its configuration. The canvas is ZPLab's rendering of the label. A second rendering comes from the preview renderer under **File → Settings… → App → Preview**: the connected printer (desktop) or, with consent, the third-party service [Labelary](https://labelary.com/). **Preview** shows it for comparison. - **File → Export ZPL:** saves a `.zpl` file, or `.prn` where the save dialog offers file types -- **File → Send to Zebra Printer:** opens the send dialog. It sends over the network, through the Zebra Browser Print agent in the browser, or through the system spooler or USB on desktop. On desktop, **Check printer** asks the printer for its state over the network or USB. +- **File → Print:** opens the output dialog, where you choose what the label becomes. **ZPL** sends the code over the network, through the Zebra Browser Print agent in the browser, or through the system spooler or USB on desktop. On desktop, **Check printer** asks the printer for its state over the network or USB. **Image** renders the current label and prints it through the system print dialog. **PDF** saves the design as a file. - **File → Export PDF:** saves one PDF page per label page, rendered by the preview renderer. With the renderer off, ZPLab captures the canvas, so the PDF holds the current page only. A batch PDF needs the renderer and holds one page per data row. -- **File → Print as Image (browser):** renders the current page with the same renderer, then opens the browser print dialog ### 5. Save the design diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index a22b174a..fa1d6569 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -7,6 +7,7 @@ ], "permissions": [ "core:default", + "core:webview:allow-print", "window-state:default", "updater:default", "process:allow-restart", diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index cd39f1f6..f2ebe854 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -14,7 +14,7 @@ import { ZplImportModal } from "./Output/ZplImportModal"; import { VariableMappingModal } from "./Variables/VariableMappingModal"; import { ConnectDataWizard } from "./Variables/ConnectDataWizard"; import { CsvImportConfirmDialog } from "./Variables/CsvImportConfirmDialog"; -import { PrintToZebraDialog } from "./Output/PrintToZebraDialog"; +import { OutputDialog } from "./Output/OutputDialog"; import { DropdownMenu, DropdownItem, @@ -34,18 +34,17 @@ import { TableCellsIcon, PrinterIcon, Cog6ToothIcon, - PaperAirplaneIcon, XMarkIcon, SunIcon, MoonIcon, GlobeAltIcon, } from "@heroicons/react/16/solid"; -import { useLabelStore, useHistory, selectLabelaryNoticeRequired, selectEffectivePreviewProvider, selectEditorFrozen, selectSourceEditing, selectSourceEditDirty, selectDocumentEmits, selectBatchPrintCount } from "../store/labelStore"; +import { useLabelStore, useHistory, selectLabelaryNoticeRequired, selectEffectivePreviewProvider, selectEditorFrozen, selectSourceEditing, selectSourceEditDirty, selectDocumentEmits } from "../store/labelStore"; import { datasetTimestamp } from "@zplab/core/types/DataSource"; import { isCurrentDataContext, settleDatasetReplace } from "../store/datasetActions"; import { ConfirmDialog } from "./ui/ConfirmDialog"; import { formatTemplate } from "../lib/formatTemplate"; -import { buildMenuModel, type MenuItemId } from "../lib/menuModel"; +import { buildMenuModel, type MenuFlags, type MenuItemId } from "../lib/menuModel"; import { NativeMenuBridge } from "./NativeMenuBridge"; import type { MenuHandlers } from "../hooks/useNativeMenu"; import { isDesktopShell, isMacDesktop } from "../lib/platform"; @@ -87,7 +86,6 @@ const MENU_ICONS: Partial s.label); - const batchPrintCount = useLabelStore(selectBatchPrintCount); const pages = useLabelStore((s) => s.pages); const selectObject = useLabelStore((s) => s.selectObject); const addPage = useLabelStore((s) => s.addPage); @@ -116,7 +113,15 @@ export function AppShell() { const pageCount = useLabelStore((s) => s.pages.length); // The Labelary notice gates every Labelary render, so the action waits here until the user continues. const [afterNotice, setAfterNotice] = useState<(() => void) | null>(null); - const withNotice = (action: () => void) => (renderer === "labelary" && noticeRequired ? setAfterNotice(() => action) : action()); + // Hands back what the action returns, so a caller can wait for it. Nothing comes back while the + // notice is still open, because the work has not started yet. + const withNotice = (action: () => T): T | undefined => { + if (renderer === "labelary" && noticeRequired) { + setAfterNotice(() => () => void action()); + return undefined; + } + return action(); + }; const appUpdate = useLabelStore((s) => s.appUpdate); const checkForAppUpdate = useLabelStore((s) => s.checkForAppUpdate); const installAppUpdate = useLabelStore((s) => s.installAppUpdate); @@ -191,7 +196,7 @@ export function AppShell() { showZplImport, openZplImport, closeZplImport, - showZebraPrint, + outputSource, openZebraPrint, closeZebraPrint, currentZpl, @@ -207,15 +212,15 @@ export function AppShell() { const leftPanel = useCollapsiblePanel("zpl-panel-left"); const rightPanel = useCollapsiblePanel("zpl-panel-right"); - // One menu model for both surfaces: the DOM dropdown (web header) and the - // native OS menu (desktop, where the header row is not rendered at all). - const menuModel = buildMenuModel(t, { + // One menu model for both surfaces: the DOM dropdown in the web header, and the native OS menu on + // the desktop, where the header row is not rendered at all. The output dialog reads the same + // flags, so its first level and these entries agree on what the document can produce. + const menuFlags: MenuFlags = { hasObjects, documentEmits, sourceEditing, canBatchExport, batchRowCount, - batchPrintCount, connectDataWizard: isDesktopShell, canBatchPdf: renderer !== "none", pdfCurrentPageOnly: renderer === "none" && pageCount > 1, @@ -223,7 +228,8 @@ export function AppShell() { canRedo, // On macOS quit lives in the app submenu (Cmd+Q), not the File section. includeQuit: isDesktopShell && !isMacDesktop, - }); + }; + const menuModel = buildMenuModel(t, menuFlags); const menuHandlers: MenuHandlers = { new: handleNew, addPage, @@ -237,8 +243,7 @@ export function AppShell() { saveDesign: handleSave, importCsv: openCsvPicker, connectData: openConnectWizard, - print: () => withNotice(() => void handlePrint()), - sendToZebra: openZebraPrint, + print: openZebraPrint, undo: () => undo(), redo: () => redo(), github: () => openExternal(REPO_URL), @@ -565,8 +570,15 @@ export function AppShell() { onCancel={() => settleDatasetReplace(false)} /> )} - {showZebraPrint && ( - + {outputSource && ( + withNotice(handlePrint)} + onExportPdf={(scope) => withNotice(scope === "batch" ? exportBatchPdf : exportPdf)} + /> )} {afterNotice && ( { + act(() => { + useLabelStore.setState({ + zebraPrintSource: "label", + outputChoice: DEFAULT_OUTPUT_CHOICE, + printTarget: { ...DEFAULT_PRINT_TARGET, host: "172.17.17.175" }, + }); + }); +}); + +const show = (over: Partial[0]> = {}) => { + const props = { + zpl: () => "^XA^XZ", + source: "label" as const, + facts: everything, + onClose: vi.fn(), + onPrintImage: vi.fn(() => undefined), + onExportPdf: vi.fn(), + ...over, + }; + return { ...render(), props }; +}; + +describe("OutputDialog", () => { + it("opens on the ways to the printer and offers the two other kinds", () => { + const r = show(); + + expect(r.getByText(loc.kindImage)).toBeTruthy(); + expect(r.getByText(loc.kindPdf)).toBeTruthy(); + // The ZPL kind shows the send panel, address field and all. + expect(r.getByDisplayValue("172.17.17.175")).toBeTruthy(); + }); + + it("proposes the kind chosen last when it opens again", () => { + const first = show(); + act(() => { + fireEvent.click(first.getByText(loc.kindPdf)); + }); + expect(useLabelStore.getState().outputChoice.kind).toBe("pdf"); + + cleanup(); + expect(show().getByText(en.app.exportPdf)).toBeTruthy(); + }); + + it("puts a setup script on the printer and leaves nothing to choose", () => { + const r = show({ source: "setupScript" }); + + // Printer settings are nothing to put on paper, so the first level has one entry and stays away. + expect(r.queryByText(loc.kindImage)).toBeNull(); + expect(r.queryByText(loc.kindPdf)).toBeNull(); + expect(r.getByText(loc.heading)).toBeTruthy(); + }); + + it("hides the rendered kinds for a document with nothing to draw", () => { + // A config-only overlay emits code but renders a blank sheet, as the menu entries already say. + const r = show({ facts: { ...everything, hasObjects: false } }); + + expect(r.queryByText(loc.kindImage)).toBeNull(); + expect(r.queryByText(loc.kindPdf)).toBeNull(); + expect(r.getByDisplayValue("172.17.17.175")).toBeTruthy(); + }); + + it("offers the batch only where a dataset and a renderer meet", () => { + act(() => { + useLabelStore.getState().setOutputChoice({ kind: "pdf" }); + }); + expect(show().getByText(loc.scopeBatch)).toBeTruthy(); + + cleanup(); + // Without a renderer the batch export returns without a file, so it is not on offer. + const alone = show({ facts: { ...everything, canBatchPdf: false } }); + expect(alone.queryByText(loc.scopeBatch)).toBeNull(); + expect(alone.queryByText(loc.scopeDesign)).toBeNull(); + }); + + it("keeps a batch it cannot offer, so a dataset brings the choice back", () => { + act(() => { + useLabelStore.getState().setOutputChoice({ kind: "pdf", pdfScope: "batch" }); + }); + show({ facts: { ...everything, canBatchExport: false } }); + + expect(useLabelStore.getState().outputChoice.pdfScope).toBe("batch"); + }); + + it("names on the button what the export covers", () => { + act(() => { + useLabelStore.getState().setOutputChoice({ kind: "pdf" }); + }); + expect(show({ facts: { ...everything, pdfCurrentPageOnly: true } }).getByText(en.app.exportPdfCurrentPage)).toBeTruthy(); + }); + + it("commits the user to as many pages as the menu entry would", () => { + act(() => { + useLabelStore.getState().setOutputChoice({ kind: "pdf", pdfScope: "batch" }); + }); + const r = show({ facts: { ...everything, batchRowCount: 10_000 } }); + + // A ten thousand page render may not start without saying so, as the menu entry does not. + expect(r.getByText(en.app.exportBatchPdfFmt.replace("{n}", "10000"))).toBeTruthy(); + }); + + it("steps aside at once when the work has not started", () => { + const r = show(); + act(() => { + fireEvent.click(r.getByText(loc.kindImage)); + }); + act(() => { + fireEvent.click(r.getByText(loc.imagePrint)); + }); + + // Nothing came back, so a notice is waiting to be answered and this modal is in its way. + expect(r.props.onClose).toHaveBeenCalled(); + expect(r.props.onPrintImage).toHaveBeenCalled(); + }); + + it("stays and shows the wait while the label renders", async () => { + let finish: () => void = () => undefined; + const onPrintImage = vi.fn(() => new Promise((resolve) => (finish = resolve))); + const r = show({ onPrintImage }); + act(() => { + fireEvent.click(r.getByText(loc.kindImage)); + }); + act(() => { + fireEvent.click(r.getByText(loc.imagePrint)); + }); + + // Several seconds over the network, and a closed dialog would leave the click unanswered. + expect(r.props.onClose).not.toHaveBeenCalled(); + expect((r.getByText(loc.imagePrint) as HTMLButtonElement).disabled).toBe(true); + expect(r.container.querySelector(".animate-pulse")).toBeTruthy(); + + await act(async () => { + finish(); + }); + expect(r.props.onClose).toHaveBeenCalled(); + }); + + it("hands the PDF export the scope that is showing", () => { + act(() => { + useLabelStore.getState().setOutputChoice({ kind: "pdf" }); + }); + const r = show(); + act(() => { + fireEvent.click(r.getByText(loc.scopeBatch)); + }); + act(() => { + fireEvent.click(r.getByText(en.app.exportBatchPdfFmt.replace("{n}", "3"))); + }); + + expect(r.props.onExportPdf).toHaveBeenCalledWith("batch"); + }); + + it("reads the code only for the kind that sends it", () => { + const zpl = vi.fn(() => "^XA^XZ"); + const r = show({ zpl }); + expect(zpl).toHaveBeenCalled(); + + zpl.mockClear(); + act(() => { + fireEvent.click(r.getByText(loc.kindPdf)); + }); + // A mapped dataset regenerates the whole batch, which the PDF and image bodies never read. + expect(zpl).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/Output/OutputDialog.tsx b/src/components/Output/OutputDialog.tsx new file mode 100644 index 00000000..08c34109 --- /dev/null +++ b/src/components/Output/OutputDialog.tsx @@ -0,0 +1,156 @@ +import { XMarkIcon } from "@heroicons/react/16/solid"; +import { useState } from "react"; +import { useT } from "../../hooks/useT"; +import { DialogShell } from "../ui/DialogShell"; +import { Tooltip } from "../ui/Tooltip"; +import { RadioOption } from "../ui/RadioOption"; +import { primaryButtonCls, sectionHeadingCls } from "../ui/formStyles"; +import { useLabelStore } from "../../store/labelStore"; +import { formatTemplate } from "../../lib/formatTemplate"; +import { + effectiveKind, + effectiveScope, + offeredKinds, + offeredScopes, + type OutputFacts, + type OutputKind, + type OutputSource, + type PdfScope, +} from "../../lib/outputChoice"; +import { ZplSendPanel } from "./ZplSendPanel"; + +interface Props { + /** Read only where it is shown, because a batch regenerates megabytes on every click elsewhere. */ + zpl: () => string; + source: OutputSource; + facts: OutputFacts; + onClose: () => void; + /** The running print, or nothing when a notice has to be answered before the work starts. */ + onPrintImage: () => Promise | undefined; + onExportPdf: (scope: PdfScope) => void; +} + +/** The one place the label leaves the app. The first level is what it becomes, the second how, and + * the dialog proposes both again next time. */ +export function OutputDialog({ zpl, source, facts, onClose, onPrintImage, onExportPdf }: Props) { + const t = useT(); + const loc = t.zebraPrint; + const choice = useLabelStore((s) => s.outputChoice); + const setOutputChoice = useLabelStore((s) => s.setOutputChoice); + + const kinds = offeredKinds(source, facts); + const kind = effectiveKind(choice.kind, kinds); + // A setup script leaves one kind, and a first level with one entry is no choice to show. + const choosable = kinds.length > 1; + const scopes = offeredScopes(facts); + const scope = effectiveScope(choice.pdfScope, scopes); + // One scope is no choice, and then the action's own name says what it covers. + const scopeChoosable = scopes.length > 1; + const kindLabels: Record = { zpl: loc.kindZpl, image: loc.kindImage, pdf: loc.kindPdf }; + const kindHints: Record = { zpl: loc.kindZplHint, image: loc.kindImageHint, pdf: loc.kindPdfHint }; + const scopeLabels: Record = { design: loc.scopeDesign, batch: loc.scopeBatch }; + + // The modal would sit over the print dialog and the renderer's progress, so it steps aside first. + const leaveAnd = (action: () => void) => () => { + onClose(); + action(); + }; + + // The render takes seconds over the network, and the dialog stays to show that it is working. + const [rendering, setRendering] = useState(false); + const printImage = () => { + const started = onPrintImage(); + if (!started) return onClose(); + setRendering(true); + void started.then(onClose, onClose); + }; + + return ( + +
+ + {choosable ? loc.outputHeading : loc.heading} + + +
+ + {/* Pills, not the underlined tabs below them, so the two levels do not read as one strip. */} + {choosable && ( +
+ {kinds.map((key) => ( + + + + ))} +
+ )} + + {/* The head stays put, the ways and their bodies scroll, like every other modal here. */} +
+ {kind === "zpl" && } + + {kind === "image" && ( +
+

{loc.kindImageHint}

+ {/* Stands where the rendered label will be, like the placeholder in the font dialog. */} + {rendering &&
+ )} + + {kind === "pdf" && ( +
+

{loc.kindPdfHint}

+ {scopeChoosable && ( +
+ {scopes.map((value) => ( + setOutputChoice({ pdfScope: next })} + label={scopeLabels[value]} + /> + ))} +
+ )} +
+ +
+
+ )} +
+
+ ); +} diff --git a/src/components/Output/PrinterChip.test.tsx b/src/components/Output/PrinterChip.test.tsx index 8b386949..c0245f6c 100644 --- a/src/components/Output/PrinterChip.test.tsx +++ b/src/components/Output/PrinterChip.test.tsx @@ -3,7 +3,7 @@ import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; import { render, cleanup, fireEvent, act } from "@testing-library/react"; import { PrinterChip } from "./PrinterChip"; import { useLabelStore } from "../../store/labelStore"; -import { DEFAULT_PRINT_TARGET } from "../../lib/printTarget"; +import { DEFAULT_PRINT_TARGET, type PrintTarget } from "../../lib/printTarget"; import { fallbackTranslations as en } from "../../locales"; import type { PrinterStatusReport } from "../../lib/printerStatus"; @@ -74,6 +74,24 @@ describe("PrinterChip", () => { expect(dot(r)).toBe("blocked"); }); + it("says where the check asked, whenever that was the address and not the way", () => { + const target = (over: Partial) => + act(() => useLabelStore.setState({ printTarget: { ...DEFAULT_PRINT_TARGET, host: "172.17.17.175", ...over } })); + const r = render(); + fireEvent.click(r.getByText("172.17.17.175")); + expect(r.queryByText(en.zebraPrint.wayQueryAddress)).toBeNull(); + target({ transport: "local" }); + expect(r.getByText(en.zebraPrint.wayQueryAddress)).toBeTruthy(); + // A USB way without a device falls through to the address, so the note belongs there too. + target({ transport: "usb", usbId: "" }); + expect(r.getByText(en.zebraPrint.wayQueryAddress)).toBeTruthy(); + target({ transport: "usb", usbId: "dev1" }); + expect(r.queryByText(en.zebraPrint.wayQueryAddress)).toBeNull(); + // Without an address there is nothing to ask over, and the chip says that instead. + target({ transport: "local", host: "" }); + expect(r.queryByText(en.zebraPrint.wayQueryAddress)).toBeNull(); + }); + 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")); diff --git a/src/components/Output/PrinterChip.tsx b/src/components/Output/PrinterChip.tsx index 3513ec75..d9d9e089 100644 --- a/src/components/Output/PrinterChip.tsx +++ b/src/components/Output/PrinterChip.tsx @@ -43,6 +43,9 @@ export function PrinterChip() { const resolved = resolveQueryTarget(printTarget); const name = "failure" in resolved ? t.zebraPrint.printer : resolved.target.kind === "usb" ? t.zebraPrint.tabUsb : resolved.target.host; + // The stored way and not the effective one. Reading the effective way would enumerate printers on + // every mount, and only the desktop build renders this chip at all. + const askedOverAddress = !("failure" in resolved) && resolved.target.kind === "network" && printTarget.transport !== "network"; const view = reportView(loc, state.phase === "done" ? state.report : undefined); const reading = step !== undefined; const tone: ReadinessTone = state.phase === "failed" ? "blocked" : (view?.line.tone ?? "unknown"); @@ -81,6 +84,7 @@ export function PrinterChip() { )} {checkedAt &&
{checkedAt}
} + {askedOverAddress &&
{t.zebraPrint.wayQueryAddress}
} void checkPrinter())}> {loc.checkNow} diff --git a/src/components/Output/PrintToZebraDialog.batchNotice.test.tsx b/src/components/Output/ZplSendPanel.batchNotice.test.tsx similarity index 77% rename from src/components/Output/PrintToZebraDialog.batchNotice.test.tsx rename to src/components/Output/ZplSendPanel.batchNotice.test.tsx index 546c1372..66fa7c4d 100644 --- a/src/components/Output/PrintToZebraDialog.batchNotice.test.tsx +++ b/src/components/Output/ZplSendPanel.batchNotice.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom -import { describe, it, expect, afterEach, vi } from 'vitest'; +import { describe, it, expect, afterEach } from 'vitest'; import { render, cleanup, act } from '@testing-library/react'; -import { PrintToZebraDialog } from './PrintToZebraDialog'; +import { ZplSendPanel } from './ZplSendPanel'; import { useLabelStore } from '../../store/labelStore'; import { fallbackTranslations as en } from '../../locales'; @@ -25,13 +25,13 @@ const mapping = { bindings: { v1: 'sku' }, headerSnapshot: ['sku'] }; const noticeText = () => en.zebraPrint.batchNoticeFmt.replace('{n}', '3'); -describe('PrintToZebraDialog printer-impact notice', () => { +describe('ZplSendPanel printer-impact notice', () => { it('warns when the sent bytes carry printer-setup commands', () => { act(() => { useLabelStore.setState({ zebraPrintSource: 'label', dataset: null, columnMapping: null }); }); const { container } = render( - , + , ); expect(container.textContent).toContain('^JU'); }); @@ -40,19 +40,19 @@ describe('PrintToZebraDialog printer-impact notice', () => { act(() => { useLabelStore.setState({ zebraPrintSource: 'setupScript', dataset: null, columnMapping: null }); }); - const { container } = render(); + const { container } = render(); expect(container.textContent).not.toContain('^JU'); }); }); // Regression: the batch send-notice guards against an unaware bulk send, so it // must appear iff a mapped dataset drives the label source. -describe('PrintToZebraDialog batch notice', () => { +describe('ZplSendPanel batch notice', () => { it('shows the row-count notice for a mapped dataset on the label source', () => { act(() => { useLabelStore.setState({ zebraPrintSource: 'label', dataset, columnMapping: mapping, variables }); }); - const { container } = render(); + const { container } = render(); expect(container.textContent).toContain(noticeText()); }); @@ -66,7 +66,7 @@ describe('PrintToZebraDialog batch notice', () => { label: { widthMm: 50, heightMm: 30, dpmm: 8, printQuantity: 4 }, }); }); - const { container } = render(); + const { container } = render(); const expected = en.zebraPrint.batchNoticeQtyFmt .replace('{n}', '12') .replace('{rows}', '3') @@ -78,7 +78,7 @@ describe('PrintToZebraDialog batch notice', () => { act(() => { useLabelStore.setState({ zebraPrintSource: 'setupScript', dataset, columnMapping: mapping }); }); - const { container } = render(); + const { container } = render(); expect(container.textContent).not.toContain(noticeText()); }); @@ -86,7 +86,7 @@ describe('PrintToZebraDialog batch notice', () => { act(() => { useLabelStore.setState({ zebraPrintSource: 'label', dataset: null, columnMapping: null }); }); - const { container } = render(); + const { container } = render(); expect(container.textContent).not.toContain('one per data row'); }); }); diff --git a/src/components/Output/PrintToZebraDialog.check.test.tsx b/src/components/Output/ZplSendPanel.check.test.tsx similarity index 92% rename from src/components/Output/PrintToZebraDialog.check.test.tsx rename to src/components/Output/ZplSendPanel.check.test.tsx index 00ae2049..5d3273d2 100644 --- a/src/components/Output/PrintToZebraDialog.check.test.tsx +++ b/src/components/Output/ZplSendPanel.check.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; import { render, cleanup, act, fireEvent, waitFor } from "@testing-library/react"; -import { PrintToZebraDialog } from "./PrintToZebraDialog"; +import { ZplSendPanel } from "./ZplSendPanel"; import { useLabelStore } from "../../store/labelStore"; import { DEFAULT_PRINT_TARGET } from "../../lib/printTarget"; import { fallbackTranslations as en } from "../../locales"; @@ -29,12 +29,12 @@ beforeEach(() => { }); }); -describe("PrintToZebraDialog printer check", () => { +describe("ZplSendPanel printer check", () => { it("asks over the tab shown, not a stored way the build does not offer, and locks Send meanwhile", async () => { let finish: (value: unknown) => void = () => undefined; readPrinterStatus.mockReturnValue(new Promise((resolve) => (finish = resolve))); act(() => useLabelStore.setState({ printTarget: { ...DEFAULT_PRINT_TARGET, transport: "usb", usbId: "unplugged", host: "172.17.17.175" } })); - const r = render(); + const r = render(); await waitFor(() => expect(r.getByText(en.zebraPrint.checkPrinter)).toBeTruthy()); await act(async () => { fireEvent.click(r.getByText(en.zebraPrint.checkPrinter)); diff --git a/src/components/Output/PrintToZebraDialog.target.test.tsx b/src/components/Output/ZplSendPanel.target.test.tsx similarity index 83% rename from src/components/Output/PrintToZebraDialog.target.test.tsx rename to src/components/Output/ZplSendPanel.target.test.tsx index 69ae20f6..9233382e 100644 --- a/src/components/Output/PrintToZebraDialog.target.test.tsx +++ b/src/components/Output/ZplSendPanel.target.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom -import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; +import { describe, it, expect, afterEach, beforeEach } from "vitest"; import { render, cleanup, act, fireEvent } from "@testing-library/react"; -import { PrintToZebraDialog } from "./PrintToZebraDialog"; +import { ZplSendPanel } from "./ZplSendPanel"; import { useLabelStore } from "../../store/labelStore"; import { DEFAULT_PRINT_TARGET } from "../../lib/printTarget"; import { fallbackTranslations as en } from "../../locales"; @@ -14,12 +14,12 @@ beforeEach(() => { }); }); -describe("PrintToZebraDialog print target", () => { +describe("ZplSendPanel print target", () => { it("opens on the way last used and keeps the choice when a tab is clicked", () => { act(() => { useLabelStore.getState().setPrintTarget({ transport: "browserprint" }); }); - const r = render(); + const r = render(); expect(r.getByText(en.zebraPrint.discover)).toBeTruthy(); act(() => { fireEvent.click(r.getByText(en.zebraPrint.tabNetwork)); @@ -32,13 +32,13 @@ describe("PrintToZebraDialog print target", () => { act(() => { useLabelStore.getState().setPrintTarget({ transport: "local" }); }); - const r = render(); + const r = render(); expect(r.getByDisplayValue("172.17.17.175")).toBeTruthy(); expect(useLabelStore.getState().printTarget.transport).toBe("local"); }); it("commits the host trimmed and a typed port at once and refuses a keystroke that leaves no port", () => { - const r = render(); + const r = render(); const host = r.getByDisplayValue("172.17.17.175"); const port = r.getByDisplayValue("9100"); act(() => { diff --git a/src/components/Output/PrintToZebraDialog.tsx b/src/components/Output/ZplSendPanel.tsx similarity index 84% rename from src/components/Output/PrintToZebraDialog.tsx rename to src/components/Output/ZplSendPanel.tsx index cf7df576..2c2a8853 100644 --- a/src/components/Output/PrintToZebraDialog.tsx +++ b/src/components/Output/ZplSendPanel.tsx @@ -1,11 +1,12 @@ import { useState, type ReactNode } from "react"; import { errorMessage } from "../../lib/errorMessage"; -import { XMarkIcon } from "@heroicons/react/16/solid"; import { useT } from "../../hooks/useT"; -import { DialogShell } from "../ui/DialogShell"; import { Select } from "../ui/Select"; +import { primaryButtonCls } from "../ui/formStyles"; import { sendViaBrowserPrint, sendViaNetwork } from "../../lib/zebraPrint"; import { isDesktopShell } from "../../lib/platform"; +import { Tooltip } from "../ui/Tooltip"; +import { wayHint } from "../../lib/printWayText"; import { effectiveTransport, offeredTransports, type PrintTransport } from "../../lib/printTarget"; import { useLabelStore, selectBatchInputs, selectCanBatchExport, selectBatchPrintCount, selectPrinterReading } from "../../store/labelStore"; import { formatTemplate } from "../../lib/formatTemplate"; @@ -15,7 +16,6 @@ import { sendZplUsb, setupUsbAccess } from "../../lib/usbPrint"; import { pickerOptions, useBrowserPrintDevices, useLocalPrinters, useUsbPrinters } from "../../hooks/usePrintDevices"; import { PrinterAddressFields } from "../PrinterSettings/PrinterAddressFields"; import { PrinterCheck } from "./PrinterCheck"; -import { sectionHeadingCls } from "../ui/formStyles"; import type { PrinterQueryFailure } from "../../lib/printerQuery"; type Tab = PrintTransport; @@ -30,8 +30,8 @@ function StatusMessage({ status }: { status: Status }) { ); } -// The list-based transports (browser print, OS spooler, direct USB) share this -// body; network keeps its own ip/port form and stays separate. +// Browser print, the OS spooler and USB share this body, because each picks from a device list. +// Network keeps its own address form. interface TransportView { key: Tab; selected: string; @@ -62,7 +62,7 @@ function TransportBody({ view, fieldLabel }: { view: TransportView; fieldLabel: @@ -92,20 +92,14 @@ function ImpactNotices({ zpl }: { zpl: string }) { ); } -interface Props { - zpl: string; - onClose: () => void; -} - -export function PrintToZebraDialog({ zpl, onClose }: Props) { +/** The ways to a Zebra and the bytes they send, without a frame of its own, so the output dialog + * shows it under its own heading. */ +export function ZplSendPanel({ zpl }: { zpl: string }) { const t = useT(); // The way last used is the tab the dialog opens on, so a repeat print is one click. const transport = useLabelStore((s) => s.printTarget.transport); const setPrintTarget = useLabelStore((s) => s.setPrintTarget); - // The label source silently switches to batch form when a dataset is - // mapped; surface the count so nobody sends 10k labels unaware. A per-label - // ^PQ rides the stored template and multiplies EVERY recall, so the honest - // number (selectBatchPrintCount) is rows × quantity. + // A per-label ^PQ multiplies every recall, so the honest count is rows times quantity. const batchRows = useLabelStore((s) => s.zebraPrintSource === "label" ? selectBatchInputs(s)?.dataset.rows.length ?? null : null, ); @@ -143,8 +137,7 @@ export function PrintToZebraDialog({ zpl, onClose }: Props) { setNetStatus({ type: "success", message: t.zebraPrint.success }); return; case "responded": - // 2xx only counts as success; print servers / proxies that respond - // with 4xx or 5xx must surface as an error rather than green-success. + // A print server or proxy answers with a status, and 4xx or 5xx must not read as success. if (result.status >= 200 && result.status < 300) { setNetStatus({ type: "success", message: t.zebraPrint.success }); } else { @@ -229,7 +222,7 @@ export function PrintToZebraDialog({ zpl, onClose }: Props) { async function handleUsbSetup() { try { await setupUsbAccess(); - // Access granted: refresh (a hot-plugged printer may be new) and clear the prompt. + // Refresh after access is granted, because a printer plugged in meanwhile is not in the list. await usb.refresh(); setUsbNeedsSetup(false); setUsbStatus({ type: "idle" }); @@ -319,25 +312,7 @@ export function PrintToZebraDialog({ zpl, onClose }: Props) { const activeView = views.find((v) => v.key === tab); return ( - - {/* Header */} -
- - {t.zebraPrint.heading} - - -
- + <> {batchRows !== null && (

{printQuantity > 1 @@ -350,20 +325,20 @@ export function PrintToZebraDialog({ zpl, onClose }: Props) {

)} - {/* These are the exact bytes sent (batch form included), which the panel may never - have shown. Source-gated: a setup script changing settings is its purpose. */} + {/* These are the exact bytes sent, which the panel may never have shown. A setup script is + left out, because changing settings is what it is for. */} {printSource !== "setupScript" && } - {/* Tabs */}
{offered.map((key) => ( - + + + ))}
- {/* Network tab */} {tab === "network" && (
{window.location.protocol === "https:" && ( @@ -379,7 +354,7 @@ export function PrintToZebraDialog({ zpl, onClose }: Props) { @@ -389,8 +364,7 @@ export function PrintToZebraDialog({ zpl, onClose }: Props) {
)} - {/* List-based transports (browser print, OS spooler, direct USB) */} {activeView && } -
+ ); } diff --git a/src/components/Output/PrintToZebraDialog.usb.test.tsx b/src/components/Output/ZplSendPanel.usb.test.tsx similarity index 90% rename from src/components/Output/PrintToZebraDialog.usb.test.tsx rename to src/components/Output/ZplSendPanel.usb.test.tsx index 4123eb5f..6914a9e0 100644 --- a/src/components/Output/PrintToZebraDialog.usb.test.tsx +++ b/src/components/Output/ZplSendPanel.usb.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; import { render, cleanup, act, fireEvent, waitFor } from "@testing-library/react"; -import { PrintToZebraDialog } from "./PrintToZebraDialog"; +import { ZplSendPanel } from "./ZplSendPanel"; import { useLabelStore } from "../../store/labelStore"; import { DEFAULT_PRINT_TARGET } from "../../lib/printTarget"; import { fallbackTranslations as en } from "../../locales"; @@ -35,11 +35,11 @@ beforeEach(() => { }); }); -describe("PrintToZebraDialog USB access", () => { +describe("ZplSendPanel USB access", () => { it("offers the setup route with its message after a denied check, as after a denied send", async () => { readPrinterStatus.mockResolvedValue({ kind: "permission_denied" }); act(() => useLabelStore.setState({ printerState: { phase: "idle" }, printTarget: { ...DEFAULT_PRINT_TARGET, transport: "usb", usbId: "usb1" } })); - const r = render(); + const r = render(); await waitFor(() => expect(r.getByText(en.zebraPrint.checkPrinter)).toBeTruthy()); await act(async () => { fireEvent.click(r.getByText(en.zebraPrint.checkPrinter)); @@ -50,7 +50,7 @@ describe("PrintToZebraDialog USB access", () => { it("shows the manual route when the sandbox refuses to install the udev rule", async () => { setupUsbAccess.mockRejectedValue("flatpak"); - const r = render(); + const r = render(); await waitFor(() => expect(r.getByText(/Zebra ZD230/)).toBeTruthy()); await act(async () => { fireEvent.click(r.getByText(en.zebraPrint.send)); @@ -65,7 +65,7 @@ describe("PrintToZebraDialog USB access", () => { it("keeps the raw reason for any other failure", async () => { setupUsbAccess.mockRejectedValue("setup cancelled"); - const r = render(); + const r = render(); await waitFor(() => expect(r.getByText(/Zebra ZD230/)).toBeTruthy()); await act(async () => { fireEvent.click(r.getByText(en.zebraPrint.send)); diff --git a/src/components/PrinterSettings/PrintTargetTab.test.tsx b/src/components/PrinterSettings/PrintTargetTab.test.tsx index fe8a6b55..ca716e6b 100644 --- a/src/components/PrinterSettings/PrintTargetTab.test.tsx +++ b/src/components/PrinterSettings/PrintTargetTab.test.tsx @@ -15,6 +15,13 @@ beforeEach(() => { }); describe("PrintTargetTab", () => { + // The test build is the browser one, so the network way shows its web wording. + it("says under each offered way what it does in this build", () => { + const r = render(); + expect(r.getByText(en.zebraPrint.wayNetworkWeb)).toBeTruthy(); + expect(r.getByText(en.zebraPrint.wayBrowserPrint)).toBeTruthy(); + }); + it("binds the way to the print target and keeps the address in reach for the preview", () => { const r = render(); act(() => { diff --git a/src/components/PrinterSettings/PrintTargetTab.tsx b/src/components/PrinterSettings/PrintTargetTab.tsx index 6f7197d0..e64a252a 100644 --- a/src/components/PrinterSettings/PrintTargetTab.tsx +++ b/src/components/PrinterSettings/PrintTargetTab.tsx @@ -2,6 +2,7 @@ import { useT } from "../../hooks/useT"; import { useLabelStore } from "../../store/labelStore"; import { isDesktopShell } from "../../lib/platform"; import { effectiveTransport, offeredTransports, type PrintTransport } from "../../lib/printTarget"; +import { wayHint } from "../../lib/printWayText"; import { pickerOptions, useBrowserPrintDevices, useLocalPrinters, useUsbPrinters, type DeviceListState } from "../../hooks/usePrintDevices"; import { buttonCls, sectionHeadingCls } from "../ui/formStyles"; import { RadioOption } from "../ui/RadioOption"; @@ -33,7 +34,7 @@ export function PrintTargetTab() { const offered = offeredTransports(isDesktopShell, { local: local.present, usb: usb.present }); const way = effectiveTransport(transport, offered); - // A device way without devices stays visible but disabled, so the user sees why it is not on offer. + // A device way without devices stays visible, and then its reason replaces the description. const deviceWayHint = (value: PrintTransport) => (offered.includes(value) ? undefined : t.zebraPrint.noPrinters); const ways: { value: PrintTransport; label: string; desktopOnly?: boolean }[] = [ { value: "network", label: t.zebraPrint.tabNetwork }, @@ -56,7 +57,7 @@ export function PrintTargetTab() { current={way} onSelect={(next) => setPrintTarget({ transport: next })} label={w.label} - hint={w.desktopOnly ? deviceWayHint(w.value) : undefined} + hint={deviceWayHint(w.value) ?? wayHint(t.zebraPrint, w.value, isDesktopShell)} disabled={!offered.includes(w.value)} /> ))} diff --git a/src/components/ui/formStyles.ts b/src/components/ui/formStyles.ts index 7152499e..bcb09d34 100644 --- a/src/components/ui/formStyles.ts +++ b/src/components/ui/formStyles.ts @@ -6,6 +6,8 @@ export const sectionHeadingCls = 'font-mono text-[10px] uppercase tracking-wides * surface-2 + border styling used by `inputCls` so buttons sit naturally * next to form fields without dominating the visual hierarchy. */ export const buttonCls = 'px-3 py-1.5 rounded text-xs font-mono bg-surface-2 border border-border text-text hover:bg-border transition-colors'; +/** The action a dialog exists for: the send, the print, the export. */ +export const primaryButtonCls = 'px-3 py-1.5 text-xs font-mono rounded bg-accent text-bg hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed transition-opacity'; /** `buttonCls` hovers, so a disabled button needs its hover taken back too. */ export const disabledCls = 'disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-surface-2'; /** The same look for a control that locks with `aria-disabled` and keeps focus. */ diff --git a/src/hooks/useNativeMenu.rebuild.test.tsx b/src/hooks/useNativeMenu.rebuild.test.tsx index f05a30c4..f219a605 100644 --- a/src/hooks/useNativeMenu.rebuild.test.tsx +++ b/src/hooks/useNativeMenu.rebuild.test.tsx @@ -57,7 +57,6 @@ const BASE_FLAGS: MenuFlags = { sourceEditing: false, canBatchExport: false, batchRowCount: 0, - batchPrintCount: 0, connectDataWizard: true, canBatchPdf: false, pdfCurrentPageOnly: false, @@ -65,7 +64,7 @@ const BASE_FLAGS: MenuFlags = { canRedo: false, includeQuit: true, }; -const BATCH_FLAGS: MenuFlags = { ...BASE_FLAGS, canBatchExport: true, batchRowCount: 3, batchPrintCount: 3 }; +const BATCH_FLAGS: MenuFlags = { ...BASE_FLAGS, canBatchExport: true, batchRowCount: 3 }; const LABELS: SubmenuLabels = { file: "File", edit: "Edit", help: "Help", quit: "Quit" }; const HISTORY: HistorySubmenu = { @@ -170,11 +169,11 @@ describe("useNativeMenu after a rebuild", () => { const { handlers } = await rebuild({ flags: BATCH_FLAGS }); click(en.app.newDesign); - click(en.app.sendToZebraBatchFmt.replace("{n}", "3")); + click(en.app.print); click(en.app.exportBatchZplFmt.replace("{n}", "3")); expect(handlers.new).toHaveBeenCalledOnce(); - expect(handlers.sendToZebra).toHaveBeenCalledOnce(); + expect(handlers.print).toHaveBeenCalledOnce(); expect(handlers.exportBatch).toHaveBeenCalledOnce(); }); @@ -193,9 +192,9 @@ describe("useNativeMenu after a rebuild", () => { const { handlers } = await rebuild({ dark: true }); click(en.app.newDesign); - click(en.app.sendToZebra); + click(en.app.print); expect(handlers.new).toHaveBeenCalledOnce(); - expect(handlers.sendToZebra).toHaveBeenCalledOnce(); + expect(handlers.print).toHaveBeenCalledOnce(); }); }); diff --git a/src/hooks/useZplImportExport.ts b/src/hooks/useZplImportExport.ts index a3823939..70f1e030 100644 --- a/src/hooks/useZplImportExport.ts +++ b/src/hooks/useZplImportExport.ts @@ -8,7 +8,7 @@ import { } from "../store/labelStore"; import { generateMultiPageZPL, generateBatchZpl } from "@zplab/core/lib/zplGenerator"; import { generateSetupScript, setupFormatBlocks } from "../lib/zplSetupScript"; -import { printLabel } from "../lib/printPreview"; +import { printErrorMessage, printLabel, printReplaced } from "../lib/printSheet"; import { saveTextFile, saveErrorMessage, ZPL_SAVE_FILTERS } from "../lib/fileDialogs"; import { labelaryErrorMessage } from "../lib/labelary"; import { currentPageLabel, selectEffectivePreviewProvider, selectLabelaryEndpoint } from "../store/labelStore.selectors"; @@ -59,24 +59,30 @@ export function useZplImportExport(canvasRef: RefObject { const renderer = selectEffectivePreviewProvider(useLabelStore.getState()); - const image = (async () => { + // Two stages with two kinds of failure: the renderer may be out of reach, or no print may start. + let image: string; + try { // A premium host needs the keychain key before the request goes out. if (renderer === "labelary" && !useLabelStore.getState().labelaryApiKeyLoaded) { await useLabelStore.getState().hydrateLabelaryApiKey(); } const s = useLabelStore.getState(); const job = { label: currentPageLabel(s), objects: currentObjects(s), variables: s.variables, active: buildActiveRow(s.dataset, s.columnMapping) }; - return renderLabelImageUrl(renderer, job, { + image = await renderLabelImageUrl(renderer, job, { labelary: selectLabelaryEndpoint(s), printTarget: s.printTarget, captureCanvas: async (dots) => (await canvasRef.current?.captureLabelDots(dots)) ?? null, }); - })(); + } catch (e) { + setUserError(renderer === "labelary" ? labelaryErrorMessage(e) : errorMessage(e), { retryExport: renderer === "labelary" }); + return; + } try { await printLabel(image); clearUserError(); } catch (e) { - setUserError(renderer === "labelary" ? labelaryErrorMessage(e) : errorMessage(e), { retryExport: renderer === "labelary" }); + // The print the user replaced is the older one, so it may not speak for the newer one's banner. + if (e !== printReplaced) setUserError(printErrorMessage); } }; @@ -103,7 +109,7 @@ export function useZplImportExport(canvasRef: RefObject setShowZplImport(true), closeZplImport: () => setShowZplImport(false), - showZebraPrint: zebraPrintSource !== null, + outputSource: zebraPrintSource, openZebraPrint: () => openZebraPrintStore('label'), closeZebraPrint: closeZebraPrintStore, currentZpl, diff --git a/src/index.css b/src/index.css index 40c08ab0..a887731f 100644 --- a/src/index.css +++ b/src/index.css @@ -150,3 +150,43 @@ html, body, #root { background: var(--color-bg); color: var(--color-text); } + +/* The sheet these rules dress is built in printSheet.ts. */ +/* Off the screen rather than hidden, because the sheet sits in the page from the moment it is + mounted until the engine is done with it and must never flash into view. */ +.print-sheet { + position: fixed; + left: -10000px; + top: 0; +} + +@media print { + /* No page margin, or the engine draws its own header and footer there and stamps the sheet with + the title and the date. Printing the app's own interface is no goal of this app. */ + @page { + margin: 0; + } + + /* The page takes its ground from the root element, so the app's own must be reset there. */ + html.printing-label, + html.printing-label body { + background: #fff; + color: #000; + } + + html.printing-label body > *:not(.print-sheet) { + display: none !important; + } + + html.printing-label .print-sheet { + position: static; + } + + /* A 100x150 label is taller than a sheet, and without the cap its foot lands on a second page. + Block, because an inline image adds the baseline gap below it and spills that page anyway. */ + .print-sheet img { + display: block; + max-width: 100%; + max-height: 100vh; + } +} diff --git a/src/lib/menuModel.test.ts b/src/lib/menuModel.test.ts index 2757d025..76f15d91 100644 --- a/src/lib/menuModel.test.ts +++ b/src/lib/menuModel.test.ts @@ -8,7 +8,6 @@ const FLAGS: MenuFlags = { sourceEditing: false, canBatchExport: false, batchRowCount: 0, - batchPrintCount: 0, connectDataWizard: false, canBatchPdf: true, pdfCurrentPageOnly: false, @@ -26,7 +25,7 @@ describe("buildMenuModel", () => { const m = buildMenuModel(en, FLAGS); expect(ids(m)).toEqual([ "new", "addPage", "importZpl", "settings", "exportZpl", "exportPdf", - "openDesign", "saveDesign", "importCsv", "print", "sendToZebra", + "openDesign", "saveDesign", "importCsv", "print", ]); expect(m.file).toHaveLength(5); }); @@ -40,7 +39,7 @@ describe("buildMenuModel", () => { it("gates object-dependent items on hasObjects and export/save on documentEmits", () => { const m = buildMenuModel(en, { ...FLAGS, hasObjects: false, documentEmits: false }); - for (const id of ["exportZpl", "saveDesign", "print", "sendToZebra"]) { + for (const id of ["exportZpl", "saveDesign", "print"]) { expect(byId(m, id)?.enabled).toBe(false); } expect(byId(m, "new")?.enabled).toBe(true); @@ -49,15 +48,14 @@ describe("buildMenuModel", () => { const emits = buildMenuModel(en, { ...FLAGS, hasObjects: false, documentEmits: true }); expect(byId(emits, "exportZpl")?.enabled).toBe(true); expect(byId(emits, "saveDesign")?.enabled).toBe(true); - // A config-only overlay stream is a legitimate setup job for the device; - // only the Labelary render stays objects-gated. - expect(byId(emits, "sendToZebra")?.enabled).toBe(true); - expect(byId(emits, "print")?.enabled).toBe(false); + // The one output entry stays open, because a config-only overlay stream is still a legitimate + // setup job for the device. The dialog drops the two rendered kinds behind it. + expect(byId(emits, "print")?.enabled).toBe(true); }); it("disables document-replacing and emitting entries during a source-edit session", () => { const m = buildMenuModel(en, { ...FLAGS, sourceEditing: true }); - for (const id of ["new", "addPage", "importZpl", "openDesign", "saveDesign", "importCsv", "exportZpl", "print", "sendToZebra"]) { + for (const id of ["new", "addPage", "importZpl", "openDesign", "saveDesign", "importCsv", "exportZpl", "print"]) { expect(byId(m, id)?.enabled, id).toBe(false); } expect(byId(m, "settings")?.enabled).toBe(true); @@ -70,14 +68,11 @@ describe("buildMenuModel", () => { expect(item?.label).toContain("7"); }); - it("labels sendToZebra with the physical print count while a batch is mapped", () => { - expect(byId(buildMenuModel(en, FLAGS), "sendToZebra")?.label).toBe(en.app.sendToZebra); - // 7 rows × ^PQ 3: the send label counts printed labels, the export - // label counts file rows. - const m = buildMenuModel(en, { - ...FLAGS, canBatchExport: true, batchRowCount: 7, batchPrintCount: 21, - }); - expect(byId(m, "sendToZebra")?.label).toContain("21"); + it("keeps the output entry's label free of any count, because it names no kind", () => { + expect(byId(buildMenuModel(en, FLAGS), "print")?.label).toBe(en.app.print); + // The counts belong to the entries that name what they produce, and to the dialog itself. + const m = buildMenuModel(en, { ...FLAGS, canBatchExport: true, batchRowCount: 7 }); + expect(byId(m, "print")?.label).toBe(en.app.print); expect(byId(m, "exportBatch")?.label).toContain("7"); }); diff --git a/src/lib/menuModel.ts b/src/lib/menuModel.ts index 08639f76..5ec8a1ea 100644 --- a/src/lib/menuModel.ts +++ b/src/lib/menuModel.ts @@ -1,4 +1,5 @@ import type { Translations } from '../locales'; +import { anyProducible, offeredScopes, producibleKinds } from './outputChoice'; import { formatTemplate } from './formatTemplate'; /** @@ -23,7 +24,6 @@ export type MenuItemId = | 'importCsv' | 'connectData' | 'print' - | 'sendToZebra' | 'undo' | 'redo' | 'github' @@ -72,9 +72,6 @@ export interface MenuFlags { sourceEditing: boolean; canBatchExport: boolean; batchRowCount: number; - /** Physical labels a batch send produces: rows × per-label ^PQ quantity. - * exportBatch keeps batchRowCount (it names file content, not printing). */ - batchPrintCount: number; /** Desktop routes every data source through the connect-data wizard (one File * entry); web keeps the direct CSV import as its only supported source. */ connectDataWizard: boolean; @@ -90,6 +87,7 @@ export interface MenuFlags { export function buildMenuModel(t: Translations, f: MenuFlags): MenuModel { const live = !f.sourceEditing; + const kinds = producibleKinds(f); const file: MenuSection[] = [ [ { id: 'new', label: t.app.newDesign, enabled: live }, @@ -106,8 +104,8 @@ export function buildMenuModel(t: Translations, f: MenuFlags): MenuModel { enabled: f.hasObjects && live, }] : []), - { id: 'exportPdf', label: f.pdfCurrentPageOnly ? t.app.exportPdfCurrentPage : t.app.exportPdf, enabled: f.hasObjects && live }, - ...(f.canBatchExport && f.canBatchPdf + { id: 'exportPdf', label: f.pdfCurrentPageOnly ? t.app.exportPdfCurrentPage : t.app.exportPdf, enabled: kinds.pdf }, + ...(offeredScopes(f).includes('batch') ? [{ id: 'exportBatchPdf' as const, label: formatTemplate(t.app.exportBatchPdfFmt, { n: String(f.batchRowCount) }), @@ -123,17 +121,10 @@ export function buildMenuModel(t: Translations, f: MenuFlags): MenuModel { : { id: 'importCsv' as const, label: t.app.importCsvData, enabled: live }, ], [ - { id: 'print', label: t.app.print, enabled: f.hasObjects && live }, - { - // Sends the full export (a config-only overlay stream is a legitimate - // setup job), so it follows documentEmits like exportZpl; the rendered - // print above stays hasObjects (rendering nothing has no value). - id: 'sendToZebra', - label: f.canBatchExport - ? formatTemplate(t.app.sendToZebraBatchFmt, { n: String(f.batchPrintCount) }) - : t.app.sendToZebra, - enabled: f.documentEmits && live, - }, + // The one door to the output dialog, which names no kind of its own, so it may propose the + // one chosen last. No accelerator on purpose: on macOS the print sheet stays armed until the + // next print, and a Cmd+P that bypassed this entry would print the previous label. + { id: 'print', label: t.app.print, enabled: anyProducible(f) }, ], ...(f.includeQuit ? [[{ id: 'quit' as const, label: t.app.quitMenu, enabled: true }]] diff --git a/src/lib/menuSignature.test.ts b/src/lib/menuSignature.test.ts index 986f94ce..07ef15ea 100644 --- a/src/lib/menuSignature.test.ts +++ b/src/lib/menuSignature.test.ts @@ -10,7 +10,6 @@ const FLAGS: MenuFlags = { sourceEditing: false, canBatchExport: false, batchRowCount: 0, - batchPrintCount: 0, connectDataWizard: false, canBatchPdf: true, pdfCurrentPageOnly: false, diff --git a/src/lib/outputChoice.test.ts b/src/lib/outputChoice.test.ts new file mode 100644 index 00000000..2638a873 --- /dev/null +++ b/src/lib/outputChoice.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from "vitest"; +import { effectiveKind, effectiveScope, offeredKinds, offeredScopes, producibleKinds, type OutputFacts } from "./outputChoice"; + +const everything: OutputFacts = { + hasObjects: true, + documentEmits: true, + sourceEditing: false, + canBatchExport: true, + canBatchPdf: true, + pdfCurrentPageOnly: false, + batchRowCount: 3, +}; + +describe("producibleKinds", () => { + it("sends a config-only document as code and renders nothing from it", () => { + // An imported overlay with no objects is a legitimate setup job, but there is nothing to draw. + const overlayOnly = { ...everything, hasObjects: false }; + + expect(producibleKinds(overlayOnly)).toEqual({ zpl: true, image: false, pdf: false }); + }); + + it("offers nothing while the source is being edited", () => { + // The editor is about to replace the document, so nothing of it may go out meanwhile. + expect(producibleKinds({ ...everything, sourceEditing: true })).toEqual({ zpl: false, image: false, pdf: false }); + }); + + it("offers nothing from a document that emits nothing", () => { + expect(producibleKinds({ ...everything, hasObjects: false, documentEmits: false })).toEqual({ + zpl: false, + image: false, + pdf: false, + }); + }); +}); + +describe("offeredKinds", () => { + it("puts a setup script on the printer and nowhere else", () => { + expect(offeredKinds("setupScript", everything)).toEqual(["zpl"]); + expect(offeredKinds("label", everything)).toEqual(["zpl", "image", "pdf"]); + }); + + it("drops the rendered kinds where rendering has nothing to draw", () => { + expect(offeredKinds("label", { ...everything, hasObjects: false })).toEqual(["zpl"]); + }); + + it("asks nothing of the document for a setup script", () => { + // The script is the printer's own settings, so an empty design still has it to send. + const empty = { ...everything, hasObjects: false, documentEmits: false }; + + expect(offeredKinds("setupScript", empty)).toEqual(["zpl"]); + }); +}); + +describe("offeredScopes", () => { + it("needs a dataset and a renderer for the batch", () => { + expect(offeredScopes(everything)).toEqual(["design", "batch"]); + expect(offeredScopes({ ...everything, canBatchExport: false })).toEqual(["design"]); + // Without a renderer the batch export draws nothing and returns without a file. + expect(offeredScopes({ ...everything, canBatchPdf: false })).toEqual(["design"]); + }); +}); + +describe("the remembered decision", () => { + it("holds where it still can", () => { + expect(effectiveKind("pdf", offeredKinds("label", everything))).toBe("pdf"); + expect(effectiveScope("batch", offeredScopes(everything))).toBe("batch"); + }); + + it("falls back to what is left, without rewriting the memory", () => { + expect(effectiveKind("pdf", offeredKinds("setupScript", everything))).toBe("zpl"); + expect(effectiveScope("batch", offeredScopes({ ...everything, canBatchPdf: false }))).toBe("design"); + }); +}); diff --git a/src/lib/outputChoice.ts b/src/lib/outputChoice.ts new file mode 100644 index 00000000..88ea70fb --- /dev/null +++ b/src/lib/outputChoice.ts @@ -0,0 +1,70 @@ +// The two levels of the output dialog: what the label becomes, and how, where there is a choice. + +/** The bytes the dialog is about: the label itself, or a script that only configures the printer. */ +export type OutputSource = "label" | "setupScript"; + +/** What the label becomes. */ +export type OutputKind = "zpl" | "image" | "pdf"; + +/** The second level of the PDF kind. The batch count is shown and not chosen, so this is all that + * is left to decide. */ +export type PdfScope = "design" | "batch"; + +/** What the dialog proposes again next time. The transport way is not in here: it already rides the + * print target, where the printer settings read it too. */ +export interface OutputChoice { + kind: OutputKind; + pdfScope: PdfScope; +} + +export const DEFAULT_OUTPUT_CHOICE: OutputChoice = { kind: "zpl", pdfScope: "design" }; + +/** What this document can produce, in the terms the menu already works out. The menu entries and + * the dialog read the same answer, so they cannot disagree about what is possible. */ +export interface OutputFacts { + hasObjects: boolean; + documentEmits: boolean; + /** A live source-edit session may not emit the document it is about to replace. */ + sourceEditing: boolean; + canBatchExport: boolean; + canBatchPdf: boolean; + /** Named on the batch action, so the dialog commits the user to as many pages as the menu does. */ + batchRowCount: number; + /** Without a renderer the PDF holds the current page only, which the action names. */ + pdfCurrentPageOnly: boolean; +} + +/** A config-only overlay stream is a legitimate setup job, so ZPL goes out where nothing renders. + * The two rendered kinds need objects, because rendering nothing has no value. */ +export const producibleKinds = (f: OutputFacts): Record => { + const live = !f.sourceEditing; + return { + zpl: live && f.documentEmits, + image: live && f.hasObjects, + pdf: live && f.hasObjects, + }; +}; + +/** The one door to the dialog, open as soon as anything at all can come out of it. */ +export const anyProducible = (f: OutputFacts): boolean => Object.values(producibleKinds(f)).some(Boolean); + +/** A setup script is the printer's own settings and nothing to put on paper, so it goes one way and + * asks nothing of the document. */ +export function offeredKinds(source: OutputSource, f: OutputFacts): OutputKind[] { + if (source === "setupScript") return ["zpl"]; + const can = producibleKinds(f); + return (["zpl", "image", "pdf"] as const).filter((kind) => can[kind]); +} + +/** The batch needs a mapped dataset and a renderer that can draw every row. Without both there is + * nothing to choose, and the export covers the whole design. */ +export function offeredScopes(f: OutputFacts): PdfScope[] { + return f.canBatchExport && f.canBatchPdf ? ["design", "batch"] : ["design"]; +} + +/** Falls back to the choice that always holds, without touching what is remembered. Mapping a + * dataset again brings the batch back on its own. */ +export const effectiveKind = (kind: OutputKind, offered: OutputKind[]): OutputKind => + offered.includes(kind) ? kind : (offered[0] ?? "zpl"); + +export const effectiveScope = (scope: PdfScope, offered: PdfScope[]): PdfScope => (offered.includes(scope) ? scope : "design"); diff --git a/src/lib/printPreview.ts b/src/lib/printPreview.ts index 29b22043..b33b7344 100644 --- a/src/lib/printPreview.ts +++ b/src/lib/printPreview.ts @@ -37,44 +37,3 @@ export function buildPreviewZpl( // and a render must print, so the format is not stored (^DF prints nothing, p.1704). return stripSidecarComments(generateZPL(withoutStoredFormat(label), previewed, [])); } - -export function buildLoadingHtml(): string { - return `Loading preview…`; -} - -export function buildPrintHtml(imageUrl: string): string { - return ` - - `; -} - -export async function printLabel(image: Promise): Promise { - // Open the window synchronously (inside the user-click call stack) so browsers - // don't treat it as a popup. Fill it in once the render arrives. - const win = window.open("", "_blank"); - if (!win) { - // The render is already under way. Settle it, so nothing rejects unhandled and no blob URL leaks. - void image.then((url) => URL.revokeObjectURL(url), () => undefined); - return; - } - win.document.write(buildLoadingHtml()); - win.document.close(); - - try { - const url = await image; - win.document.open(); - win.document.write(buildPrintHtml(url)); - win.document.close(); - setTimeout(() => URL.revokeObjectURL(url), 10_000); - } catch (e) { - win.close(); - throw e; - } -} diff --git a/src/lib/printSheet.test.ts b/src/lib/printSheet.test.ts new file mode 100644 index 00000000..4d5546cf --- /dev/null +++ b/src/lib/printSheet.test.ts @@ -0,0 +1,280 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { printLabel, printReplaced } from "./printSheet"; + +const decodes = () => Object.defineProperty(HTMLImageElement.prototype, "decode", { value: () => Promise.resolve(), configurable: true }); + +describe("printLabel", () => { + let revoked: string[]; + let imgs: HTMLImageElement[]; + let sheetSrc: string | null; + let sheetsAtPrint = 0; + let print: ReturnType; + const armed = () => document.documentElement.classList.contains("printing-label"); + const sheets = () => document.querySelectorAll(".print-sheet").length; + /** The module's own image never enters the document before it is ready, so the test holds it instead. */ + const held = (nth = imgs.length - 1): HTMLImageElement => { + const img = imgs[nth]; + if (!img) throw new Error(`no image number ${nth} was built`); + return img; + }; + const seen = () => { + sheetSrc = document.querySelector(".print-sheet img")?.src ?? null; + sheetsAtPrint = sheets(); + }; + /** A normal browser print. */ + const announcing = () => { + seen(); + window.dispatchEvent(new Event("beforeprint")); + window.dispatchEvent(new Event("afterprint")); + }; + /** The same, with a dialog the user has not answered yet. */ + const slow = () => { + seen(); + window.dispatchEvent(new Event("beforeprint")); + }; + /** The macOS shell path. */ + const shell = () => { + seen(); + return Promise.resolve(); + }; + + beforeEach(() => { + revoked = []; + imgs = []; + sheetSrc = null; + sheetsAtPrint = 0; + vi.useFakeTimers(); + vi.spyOn(URL, "revokeObjectURL").mockImplementation((url) => revoked.push(url)); + // jsdom loads no images and offers no decode, so the browser's is stood in for. + decodes(); + const create = document.createElement.bind(document); + vi.spyOn(document, "createElement").mockImplementation((tag: string) => { + const el = create(tag); + if (tag === "img") imgs.push(el as HTMLImageElement); + return el; + }); + print = vi.fn(announcing); + vi.stubGlobal("print", print); + }); + + afterEach(async () => { + // The shell path holds its sheet until the next print, so one more print clears the module out. + decodes(); + print.mockImplementation(announcing); + await printLabel("blob:reset"); + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + delete (HTMLImageElement.prototype as Partial).decode; + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + document.body.innerHTML = ""; + document.documentElement.classList.remove("printing-label"); + }); + + it("prints the label through this document and gives the bytes back", async () => { + await printLabel("blob:label"); + + expect(print).toHaveBeenCalledOnce(); + expect(sheetSrc).toBe("blob:label"); + expect(armed()).toBe(false); + expect(sheets()).toBe(0); + expect(revoked).toEqual(["blob:label"]); + }); + + it("leaves nothing listening once the job is through", async () => { + await printLabel("blob:over"); + + // A print the user starts themselves must show the app, not the label from before. + window.dispatchEvent(new Event("beforeprint")); + expect(armed()).toBe(false); + expect(sheets()).toBe(0); + }); + + it("keeps the sheet standing for as long as a dialog takes", async () => { + print.mockImplementation(slow); + await printLabel("blob:slow"); + + await vi.advanceTimersByTimeAsync(900_000); + // No deadline may cut a print short: the sheet is what the engine paints when the user confirms. + expect(armed()).toBe(true); + expect(sheets()).toBe(1); + expect(revoked).toEqual([]); + window.dispatchEvent(new Event("afterprint")); + expect(sheets()).toBe(0); + }); + + it("answers the caller as soon as the engine takes the call", async () => { + // An engine that opens a dialog and reports nothing until the user has answered it. + print.mockImplementation(() => { + seen(); + }); + + await printLabel("blob:quiet"); + + await vi.advanceTimersByTimeAsync(900_000); + // No deadline may take the sheet away while a dialog of that engine is still open. + expect(armed()).toBe(true); + expect(sheets()).toBe(1); + expect(revoked).toEqual([]); + }); + + it("hands a refused print to the caller instead of reporting success", async () => { + print.mockImplementation(() => { + throw new Error("printing is blocked"); + }); + + await expect(printLabel("blob:blocked")).rejects.toThrow("printing is blocked"); + expect(armed()).toBe(false); + expect(sheets()).toBe(0); + expect(revoked).toEqual(["blob:blocked"]); + }); + + it("still prints where the print media query has no event target", async () => { + // Safari 13 is such a build, and a second source may not cost the first one. + vi.stubGlobal("matchMedia", () => { + throw new TypeError("addEventListener is not a function"); + }); + + await printLabel("blob:old-safari"); + + expect(sheetSrc).toBe("blob:old-safari"); + expect(sheets()).toBe(0); + expect(revoked).toEqual(["blob:old-safari"]); + }); + + it("takes both edges of the print media query as word about the job", async () => { + let fire: (e: { matches: boolean }) => void = () => undefined; + const media = { + matches: false, + addEventListener: (_: string, cb: (e: { matches: boolean }) => void) => { + fire = cb; + }, + removeEventListener: () => undefined, + }; + vi.stubGlobal("matchMedia", () => media); + // An engine that reports no print event, which WebKit builds are repeatedly suspected of. + print.mockImplementation(() => { + seen(); + fire({ matches: true }); + }); + + await printLabel("blob:media"); + + await vi.advanceTimersByTimeAsync(2_000); + // The deadline would otherwise have pulled the sheet out from under an open print dialog. + expect(sheetSrc).toBe("blob:media"); + expect(armed()).toBe(true); + expect(sheets()).toBe(1); + + fire({ matches: false }); + // And the sheet may not outlive the job, or the next print of the user's own shows this label. + expect(armed()).toBe(false); + expect(sheets()).toBe(0); + expect(revoked).toEqual(["blob:media"]); + }); + + it("replaces the previous label instead of stacking a second one onto the page", async () => { + await printLabel("blob:first"); + await printLabel("blob:second"); + + expect(sheetSrc).toBe("blob:second"); + expect(sheetsAtPrint).toBe(1); + expect(revoked).toEqual(["blob:first", "blob:second"]); + }); + + describe("when the shell prints for us", () => { + it("takes the promise as the answer and keeps the sheet for the dialog", async () => { + print.mockImplementation(shell); + await printLabel("blob:shell"); + + // No print event ever arrives on this path, and the shell paints the document when the user + // confirms, so taking the sheet away on a deadline would print the app instead of the label. + expect(sheetSrc).toBe("blob:shell"); + expect(armed()).toBe(true); + expect(sheets()).toBe(1); + await vi.advanceTimersByTimeAsync(900_000); + expect(sheets()).toBe(1); + expect(revoked).toEqual([]); + }); + + it("hands a late refusal to its own caller and leaves the sheet that replaced it", async () => { + let refuse: (e: Error) => void = () => undefined; + print.mockImplementation(() => new Promise((_, reject) => (refuse = reject))); + const first = expect(printLabel("blob:a")).rejects.toThrow("not allowed"); + await vi.advanceTimersByTimeAsync(0); + + print.mockImplementation(shell); + await printLabel("blob:b"); + refuse(new Error("webview.print not allowed")); + await first; + + // The late refusal belongs to a print that is already gone and may not touch the one standing. + expect(sheets()).toBe(1); + expect(armed()).toBe(true); + expect(revoked).toEqual(["blob:a"]); + }); + + it("hands the shell's own refusal to the caller", async () => { + // A missing webview print permission is how Tauri rejects this call on macOS. + print.mockImplementation(() => Promise.reject(new Error("webview.print not allowed"))); + + await expect(printLabel("blob:denied")).rejects.toThrow("not allowed"); + expect(armed()).toBe(false); + expect(sheets()).toBe(0); + expect(revoked).toEqual(["blob:denied"]); + }); + }); + + describe("while the bytes are still coming", () => { + beforeEach(() => { + // No decode, so the load event is the only gate and the test holds it open. + delete (HTMLImageElement.prototype as Partial).decode; + }); + + it("has nothing in the document and nothing armed", async () => { + const printing = printLabel("blob:pending"); + await vi.advanceTimersByTimeAsync(0); + + // The user's own Ctrl+P must print the app, not a label that is not even ready. + expect(armed()).toBe(false); + expect(sheets()).toBe(0); + expect(print).not.toHaveBeenCalled(); + + held().dispatchEvent(new Event("load")); + await printing; + expect(sheetSrc).toBe("blob:pending"); + }); + + it("gives the later click the sheet, whatever order the renders come back in", async () => { + const first = expect(printLabel("blob:one")).rejects.toBe(printReplaced); + await vi.advanceTimersByTimeAsync(0); + const second = printLabel("blob:two"); + await vi.advanceTimersByTimeAsync(0); + expect(sheets()).toBe(0); + + // The older render answers first here, and must not take the job from the newer click. + held(0).dispatchEvent(new Event("load")); + await first; + expect(print).not.toHaveBeenCalled(); + expect(revoked).toEqual(["blob:one"]); + + held(1).dispatchEvent(new Event("load")); + await second; + expect(sheetSrc).toBe("blob:two"); + expect(sheetsAtPrint).toBe(1); + }); + + it("gives the bytes back and leaves no sheet when the image fails", async () => { + const printing = printLabel("blob:broken"); + await vi.advanceTimersByTimeAsync(0); + held().dispatchEvent(new Event("error")); + + await expect(printing).rejects.toThrow("could not be loaded"); + expect(print).not.toHaveBeenCalled(); + expect(armed()).toBe(false); + expect(sheets()).toBe(0); + expect(revoked).toEqual(["blob:broken"]); + }); + }); +}); diff --git a/src/lib/printSheet.ts b/src/lib/printSheet.ts new file mode 100644 index 00000000..b518d8f5 --- /dev/null +++ b/src/lib/printSheet.ts @@ -0,0 +1,163 @@ +// Printing a rendered label through this document, with the stylesheet in index.css. + +/** The sheet the print shows. One at a time, so the class needs no instance of its own. */ +const SHEET_CLASS = "print-sheet"; +/** Without this flag on the root the user's own Ctrl+P would hide the app and print a blank page. */ +const PRINTING_CLASS = "printing-label"; + +/** Shown when no print starts. The reasons are the engine's own or bytes that will not load, and + * neither leaves the caller anything to add that the label would explain. */ +export const printErrorMessage = "Could not start the print."; + +/** Not a failure to report: a newer print took this sheet over, which is what the user asked for. */ +export const printReplaced = new Error("the print was replaced"); + +let standing: Standing | undefined; + +/** Counts the clicks, so the newest print wins even when an older render answers after it. */ +let issued = 0; + +interface Standing { + sheet: HTMLDivElement; + url: string; + stop: () => void; + settle: (failure?: unknown) => void; +} + +/** Prints through this document and not a popup. A Tauri webview opens no second window, so + * window.open returned null there and the click did nothing. Resolves once a print is under way, + * so a click that starts nothing reaches the caller as a failure. */ +export async function printLabel(url: string): Promise { + const ticket = ++issued; + const img = document.createElement("img"); + img.alt = ""; + img.src = url; + try { + // Waiting happens before anything is installed, so a failure here leaves no trace to undo. + await imageReady(img); + } catch (e) { + URL.revokeObjectURL(url); + throw e; + } + // Renders come back in their own order, and the later click is the one the user meant. + if (ticket !== issued) { + URL.revokeObjectURL(url); + throw printReplaced; + } + return printNow(img, url); +} + +/** No await before the print, so a second print can never find a half-installed first one. */ +function printNow(img: HTMLImageElement, url: string): Promise { + const sheet = document.createElement("div"); + sheet.className = SHEET_CLASS; + sheet.append(img); + retire(standing?.sheet); + const { promise, settle } = deferred(); + // Tracked before it can be seen, or a throw below would orphan the sheet for the session. + standing = { sheet, url, settle, stop: () => undefined }; + document.body.append(sheet); + arm(true); + try { + // The watch stands before the print, because Chromium reports the job while the call blocks. + const stop = watchEpisode(sheet); + standing.stop = stop; + const answer: unknown = window.print(); + // Two mechanisms with two ways of reporting, like the network send. Tauri replaces this call on + // macOS with one into the shell, which answers with a promise and sends no print event. A print + // that got under way there keeps its sheet until the next one, since nothing says it is through. + // Leaving the stylesheet armed is safe, because Tauri shims this very call on that platform, the + // shell binds no print accelerator and the webview offers no print of its own, so every print + // there is ours. + if (thenable(answer)) { + // The local one, because a print that reported itself during the call is already retired. + stop(); + if (standing?.sheet === sheet) standing.stop = () => undefined; + answer.then(() => settle(), refused(sheet, settle)); + } else { + // The call came back without throwing, and that is all an engine promises: it prints, it opens + // a dialog, or it throws. Waiting for a print event instead cuts short every build that + // reports the job only once the user has answered that dialog. + settle(); + } + } catch (e) { + settle(e); + retire(sheet); + } + return promise; +} + +/** Only a refusal clears the sheet, because it is the one answer that printed nothing. */ +const refused = (sheet: HTMLDivElement, settle: (failure?: unknown) => void) => (e: unknown) => { + settle(e); + retire(sheet); +}; + +/** The job's own brackets, for one purpose: the sheet goes when the episode ends. An engine that + * never reports an end leaves it to the next print, as the shell path does. */ +function watchEpisode(sheet: HTMLDivElement): () => void { + const onAfter = () => retire(sheet); + // The media query brackets the episode too, and some builds answer only there. + const stopMedia = watchMedia((e) => { + if (!e.matches) retire(sheet); + }); + window.addEventListener("afterprint", onAfter); + return () => { + stopMedia(); + window.removeEventListener("afterprint", onAfter); + }; +} + +/** Safari 13 has the print media query but not its event target, and there the print events are the + * only word. A second source may not cost the first one. */ +function watchMedia(onChange: (e: MediaQueryListEvent) => void): () => void { + try { + const media = window.matchMedia("print"); + media.addEventListener("change", onChange); + return () => media.removeEventListener("change", onChange); + } catch { + return () => undefined; + } +} + +/** The shell answers with a promise, and a thenable from another realm is the same answer. */ +const thenable = (v: unknown): v is PromiseLike => + typeof v === "object" && v !== null && typeof (v as { then?: unknown }).then === "function"; + +/** Every teardown names the sheet it means, so an older print can never retire the one that + * replaced it. */ +function retire(sheet: HTMLDivElement | undefined): void { + if (!standing || standing.sheet !== sheet) return; + const { url, stop } = standing; + standing = undefined; + stop(); + arm(false); + sheet.remove(); + URL.revokeObjectURL(url); +} + +const arm = (on: boolean): void => { + document.documentElement.classList.toggle(PRINTING_CLASS, on); +}; + +/** The print decides this outcome, not the call that starts it. Settling twice is how the paths + * overlap on purpose: the first reason wins and every later one is a no-op. */ +function deferred(): { promise: Promise; settle: (failure?: unknown) => void } { + let settle: (failure?: unknown) => void = () => undefined; + const promise = new Promise((resolve, reject) => { + settle = (failure) => (failure === undefined ? resolve() : reject(failure)); + }); + return { promise, settle }; +} + +/** Awaited before the print, or the sheet spools blank. No deadline, because the caller renders + * first and hands over local bytes. */ +async function imageReady(img: HTMLImageElement): Promise { + if (img.complete) return; + const loaded = new Promise((resolve, reject) => { + img.onload = () => resolve(); + img.onerror = () => reject(new Error("the label image could not be loaded")); + }); + if (!img.decode) return loaded; + return img.decode().catch(() => loaded); +} diff --git a/src/lib/printWayText.test.ts b/src/lib/printWayText.test.ts new file mode 100644 index 00000000..8718548b --- /dev/null +++ b/src/lib/printWayText.test.ts @@ -0,0 +1,15 @@ +import { describe, it, expect } from "vitest"; +import en from "../locales/en"; +import { wayHint } from "./printWayText"; + +describe("wayHint", () => { + it("promises a reply only in the build that can read one", () => { + expect(wayHint(en.zebraPrint, "network", true)).toBe(en.zebraPrint.wayNetwork); + expect(wayHint(en.zebraPrint, "network", false)).toBe(en.zebraPrint.wayNetworkWeb); + }); + + it("keeps one wording for the ways that exist in one build only", () => { + expect(wayHint(en.zebraPrint, "usb", true)).toBe(en.zebraPrint.wayUsb); + expect(wayHint(en.zebraPrint, "browserprint", false)).toBe(en.zebraPrint.wayBrowserPrint); + }); +}); diff --git a/src/lib/printWayText.ts b/src/lib/printWayText.ts new file mode 100644 index 00000000..68c8fceb --- /dev/null +++ b/src/lib/printWayText.ts @@ -0,0 +1,17 @@ +// One wording per way to the printer, shared by the send dialog and the printer settings. +import type { PrintTransport } from "./printTarget"; +import type { Translations } from "../locales"; + +type Loc = Translations["zebraPrint"]; + +const WAY_KEY = { + network: "wayNetwork", + browserprint: "wayBrowserPrint", + local: "wayLocal", + usb: "wayUsb", +} as const satisfies Record; + +/** The network way is a raw TCP socket in the desktop shell and a plain POST in the browser, so it is the + * one way whose wording depends on the build. The other three exist in one build each. */ +export const wayHint = (loc: Loc, transport: PrintTransport, desktop: boolean): string => + transport === "network" && !desktop ? loc.wayNetworkWeb : loc[WAY_KEY[transport]]; diff --git a/src/locales/ar.ts b/src/locales/ar.ts index 3385a3a6..17cdab90 100644 --- a/src/locales/ar.ts +++ b/src/locales/ar.ts @@ -988,7 +988,6 @@ const ar = { exportZpl: 'تصدير ZPL', menuInitError: 'تعذّر تحميل قائمة التطبيق. يُرجى إعادة تشغيل التطبيق.', importCsvData: 'استيراد بيانات CSV', - sendToZebraBatchFmt: 'إرسال إلى Zebra ({n} ملصقات)', exportBatchZplFmt: 'تصدير ZPL دفعي ({n} ملصقات)', exportPdf: 'تصدير PDF', exportBatchPdfFmt: 'تصدير PDF دفعي ({n} ملصقات)', @@ -1011,8 +1010,7 @@ const ar = { deletePageConfirm: 'حذف الصفحة الحالية؟', openDesign: 'فتح تصميم', saveDesign: 'حفظ تصميم', - print: 'طباعة كصورة (المتصفح)', - sendToZebra: 'إرسال إلى طابعة Zebra', + print: 'طباعة', propertiesTab: 'الخصائص', layersTab: 'الطبقات', fontsTab: 'الخطوط', @@ -1839,6 +1837,16 @@ const ar = { }, zebraPrint: { heading: 'إرسال إلى طابعة Zebra', + outputHeading: 'الإخراج', + kindZpl: 'ZPL', + kindImage: 'صورة', + kindPdf: 'PDF', + kindZplHint: 'يرسل الكود إلى الطابعة، التي ترسم الملصق بنفسها.', + kindImageHint: 'يطبع الملصق الحالي كصورة على طابعة عادية.', + kindPdfHint: 'يحفظ الملصق المعروض كملف PDF.', + scopeBatch: 'صفحة واحدة لكل صف بيانات', + scopeDesign: 'جميع الصفحات', + imagePrint: 'طباعة', batchNoticeFmt: 'يطبع {n} ملصقات (واحد لكل صف بيانات).', batchNoticeQtyFmt: 'يطبع {n} ملصقات ({rows} صفوف × {q} لكل صف).', tabNetwork: 'الشبكة (IP)', @@ -1862,6 +1870,12 @@ const ar = { errorGeneric: 'فشل إرسال ZPL', httpsWarning: 'الصفحة على HTTPS، قد يحظر المتصفح الطباعة المباشرة عبر IP (محتوى مختلط).', tabUsb: 'USB', + wayNetwork: 'يُرسل مباشرة إلى الطابعة عبر TCP. كما يقرأ الرد، فتعمل الحالة والمعاينة والشارة.', + wayNetworkWeb: 'يرسل الكود إلى منفذ الطابعة. لا يحصل المتصفح على رد، فلا يُقرأ أي شيء.', + wayBrowserPrint: 'يمر عبر Zebra Browser Print على الكمبيوتر الذي يشغّل المتصفح.', + wayLocal: 'يُسلّم المهمة إلى قائمة انتظار نظام التشغيل كبيانات خام. تعرضها قائمة الانتظار، ولا يقرأ أي رد.', + wayUsb: 'يُرسل مباشرة إلى الجهاز المتصل ويقرأ الرد.', + wayQueryAddress: 'يستعلم الفحص دائمًا عن طريق عنوان الطابعة.', usbPermissionDenied: 'لا يمكن الوصول إلى طابعة USB. انقر على «إعداد الوصول» لمنح الإذن.', usbSetupAccess: 'إعداد الوصول', usbSetupFlatpak: 'داخل Flatpak، لا يمكن للتطبيق تثبيت قاعدة udev بنفسه. ثبّت الملف 70-zplab.rules من مستودع ZPLab على النظام المضيف، ثم أعد توصيل الطابعة.', diff --git a/src/locales/bg.ts b/src/locales/bg.ts index 9abb43d0..fda2c9da 100644 --- a/src/locales/bg.ts +++ b/src/locales/bg.ts @@ -988,7 +988,6 @@ const bg = { exportZpl: 'Export ZPL', menuInitError: 'Менюто на приложението не можа да се зареди. Рестартирайте приложението.', importCsvData: 'Импортиране на CSV данни', - sendToZebraBatchFmt: 'Изпрати към Zebra ({n} етикета)', exportBatchZplFmt: 'Експорт на пакетен ZPL ({n} етикета)', exportPdf: 'Експорт на PDF', exportBatchPdfFmt: 'Експорт на пакетен PDF ({n} етикета)', @@ -1011,8 +1010,7 @@ const bg = { deletePageConfirm: 'Изтриване на текущата страница?', openDesign: 'Отвори дизайн', saveDesign: 'Запази дизайн', - print: 'Печат като изображение (браузър)', - sendToZebra: 'Изпрати към принтер Zebra', + print: 'Печат', propertiesTab: 'Свойства', layersTab: 'Слоеве', fontsTab: 'Шрифтове', @@ -1839,6 +1837,16 @@ const bg = { }, zebraPrint: { heading: 'Изпрати към принтер Zebra', + outputHeading: 'Печат и експорт', + kindZpl: 'ZPL', + kindImage: 'Изображение', + kindPdf: 'PDF', + kindZplHint: 'Изпраща кода към принтера, който сам изчертава етикета.', + kindImageHint: 'Отпечатва текущия етикет като изображение на обикновен принтер.', + kindPdfHint: 'Запазва визуализирания етикет като PDF файл.', + scopeBatch: 'Една страница за всеки ред с данни', + scopeDesign: 'Всички страници', + imagePrint: 'Отпечатай', batchNoticeFmt: 'Отпечатва {n} етикета (по един за всеки ред с данни).', batchNoticeQtyFmt: 'Отпечатва {n} етикета ({rows} реда × {q} на ред).', tabNetwork: 'Мрежа (IP)', @@ -1862,6 +1870,12 @@ const bg = { errorGeneric: 'Неуспешно изпращане на ZPL', httpsWarning: 'Страницата е на HTTPS, директният печат по IP може да бъде блокиран от браузъра (смесено съдържание).', tabUsb: 'USB', + wayNetwork: 'Изпраща директно до принтера през TCP. Той също връща отговор, затова статусът, визуализацията и етикетът работят.', + wayNetworkWeb: 'Изпраща кода до порта на принтера. Браузърът не получава отговор, затова нищо не се чете обратно.', + wayBrowserPrint: 'Минава през Zebra Browser Print на компютъра, на който работи браузърът.', + wayLocal: 'Предава заданието като необработени данни на опашката на операционната система. Опашката го показва, но нищо не връща отговор.', + wayUsb: 'Изпраща директно до свързаното устройство и връща отговор.', + wayQueryAddress: 'Проверката винаги пита през адреса на принтера.', usbPermissionDenied: 'Няма достъп до USB принтера. Щракнете върху Настройване на достъп, за да го предоставите.', usbSetupAccess: 'Настройване на достъп', usbSetupFlatpak: 'В Flatpak приложението не може само да инсталира правилото за udev. Инсталирайте 70-zplab.rules от хранилището на ZPLab на хост системата, след което включете принтера отново.', diff --git a/src/locales/cs.ts b/src/locales/cs.ts index ef423725..986f2808 100644 --- a/src/locales/cs.ts +++ b/src/locales/cs.ts @@ -988,7 +988,6 @@ const cs = { exportZpl: 'Export ZPL', menuInitError: 'Nabídku aplikace se nepodařilo načíst. Restartujte aplikaci.', importCsvData: 'Importovat data CSV', - sendToZebraBatchFmt: 'Odeslat na Zebra ({n} etiket)', exportBatchZplFmt: 'Exportovat dávkové ZPL ({n} etiket)', exportPdf: 'Exportovat PDF', exportBatchPdfFmt: 'Exportovat dávkové PDF ({n} etiket)', @@ -1011,8 +1010,7 @@ const cs = { deletePageConfirm: 'Smazat aktuální stránku?', openDesign: 'Otevřít návrh', saveDesign: 'Uložit návrh', - print: 'Tisk jako obrázek (prohlížeč)', - sendToZebra: 'Odeslat na tiskárnu Zebra', + print: 'Tisk', propertiesTab: 'Vlastnosti', layersTab: 'Vrstvy', fontsTab: 'Písma', @@ -1839,6 +1837,16 @@ const cs = { }, zebraPrint: { heading: 'Odeslat na tiskárnu Zebra', + outputHeading: 'Výstup', + kindZpl: 'ZPL', + kindImage: 'Obrázek', + kindPdf: 'PDF', + kindZplHint: 'Odešle kód do tiskárny, která štítek vykreslí sama.', + kindImageHint: 'Vytiskne aktuální štítek jako obrázek na běžné tiskárně.', + kindPdfHint: 'Uloží vykreslený štítek jako soubor PDF.', + scopeBatch: 'Jedna stránka na každý řádek dat', + scopeDesign: 'Všechny stránky', + imagePrint: 'Vytisknout', batchNoticeFmt: 'Vytiskne {n} etiket (jednu na každý řádek dat).', batchNoticeQtyFmt: 'Vytiskne {n} etiket ({rows} řádků × {q} na řádek).', tabNetwork: 'Síť (IP)', @@ -1862,6 +1870,12 @@ const cs = { errorGeneric: 'Odeslání ZPL selhalo', httpsWarning: 'Stránka používá HTTPS, přímý tisk přes IP může být prohlížečem zablokován (smíšený obsah).', tabUsb: 'USB', + wayNetwork: 'Odesílá přímo do tiskárny přes TCP. Zároveň čte odpověď, takže stav, náhled i štítek fungují.', + wayNetworkWeb: 'Odesílá kód na port tiskárny. Prohlížeč nedostane odpověď, takže se nic nenačte zpět.', + wayBrowserPrint: 'Jde přes Zebra Browser Print na počítači, na kterém běží prohlížeč.', + wayLocal: 'Předá úlohu do fronty operačního systému jako surová data. Fronta ji zobrazí, ale nic nečte odpověď.', + wayUsb: 'Odesílá přímo do připojeného zařízení a čte odpověď.', + wayQueryAddress: 'Kontrola se vždy dotazuje přes adresu tiskárny.', usbPermissionDenied: 'Bez přístupu k tiskárně USB. Přístup udělíte kliknutím na Nastavit přístup.', usbSetupAccess: 'Nastavit přístup', usbSetupFlatpak: 'Uvnitř Flatpaku si aplikace nemůže pravidlo udev nainstalovat sama. Nainstalujte 70-zplab.rules z repozitáře ZPLab na hostitelský systém a tiskárnu znovu připojte.', diff --git a/src/locales/da.ts b/src/locales/da.ts index 2c27f6bc..829b13df 100644 --- a/src/locales/da.ts +++ b/src/locales/da.ts @@ -988,7 +988,6 @@ const da = { exportZpl: 'Export ZPL', menuInitError: 'Programmets menu kunne ikke indlæses. Genstart appen.', importCsvData: 'Importer CSV-data', - sendToZebraBatchFmt: 'Send til Zebra ({n} etiketter)', exportBatchZplFmt: 'Eksportér batch-ZPL ({n} etiketter)', exportPdf: 'Eksportér PDF', exportBatchPdfFmt: 'Eksportér batch-PDF ({n} etiketter)', @@ -1011,8 +1010,7 @@ const da = { deletePageConfirm: 'Slet den aktuelle side?', openDesign: 'Åbn design', saveDesign: 'Gem design', - print: 'Udskriv som billede (browser)', - sendToZebra: 'Send til Zebra-printer', + print: 'Udskriv', propertiesTab: 'Egenskaber', layersTab: 'Lag', fontsTab: 'Skrifttyper', @@ -1839,6 +1837,16 @@ const da = { }, zebraPrint: { heading: 'Send til Zebra-printer', + outputHeading: 'Uddata', + kindZpl: 'ZPL', + kindImage: 'Billede', + kindPdf: 'PDF', + kindZplHint: 'Sender koden til printeren, som selv tegner etiketten.', + kindImageHint: 'Udskriver den aktuelle etiket som et billede på en almindelig printer.', + kindPdfHint: 'Gemmer den gengivne etiket som en PDF-fil.', + scopeBatch: 'En side pr. datarække', + scopeDesign: 'Alle sider', + imagePrint: 'Udskriv', batchNoticeFmt: 'Udskriver {n} etiketter (én pr. datarække).', batchNoticeQtyFmt: 'Udskriver {n} etiketter ({rows} rækker × {q} pr. række).', tabNetwork: 'Netværk (IP)', @@ -1862,6 +1870,12 @@ const da = { errorGeneric: 'Afsendelse af ZPL mislykkedes', httpsWarning: 'Siden bruger HTTPS, direkte udskrivning via IP kan blive blokeret af browseren (blandet indhold).', tabUsb: 'USB', + wayNetwork: 'Sender direkte til printeren via TCP. Den læser også retur, så status, forhåndsvisning og chip fungerer.', + wayNetworkWeb: 'Sender koden til printerens port. Browseren får intet svar, så intet læses retur.', + wayBrowserPrint: 'Går via Zebra Browser Print på den computer, der kører browseren.', + wayLocal: 'Overdrager jobbet til operativsystemets kø som rådata. Køen viser det, men intet læses retur.', + wayUsb: 'Sender direkte til den tilsluttede enhed og læser retur.', + wayQueryAddress: 'Tjekket spørger altid via printerens adresse.', usbPermissionDenied: 'Ingen adgang til USB-printeren. Klik på Konfigurer adgang for at give den.', usbSetupAccess: 'Konfigurer adgang', usbSetupFlatpak: 'Inde i Flatpak kan appen ikke selv installere udev-reglen. Installer 70-zplab.rules fra ZPLab-repositoriet på værtssystemet, og tilslut derefter printeren igen.', diff --git a/src/locales/de.ts b/src/locales/de.ts index 423ad77a..74a10a08 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -988,7 +988,6 @@ const de = { exportZpl: 'Export ZPL', menuInitError: 'Das Anwendungsmenü konnte nicht geladen werden. Bitte starten Sie die App neu.', importCsvData: 'CSV-Daten importieren', - sendToZebraBatchFmt: 'An Zebra senden ({n} Etiketten)', exportBatchZplFmt: 'Batch-ZPL exportieren ({n} Etiketten)', exportPdf: 'PDF exportieren', exportBatchPdfFmt: 'Batch-PDF exportieren ({n} Etiketten)', @@ -1011,8 +1010,7 @@ const de = { deletePageConfirm: 'Aktuelle Seite löschen?', openDesign: 'Design öffnen', saveDesign: 'Design speichern', - print: 'Als Bild drucken (Browser)', - sendToZebra: 'An Zebra-Drucker senden', + print: 'Drucken', propertiesTab: 'Eigenschaften', layersTab: 'Ebenen', fontsTab: 'Schriften', @@ -1089,6 +1087,16 @@ const de = { zebraPrint: { heading: 'An Zebra-Drucker senden', + outputHeading: 'Ausgabe', + kindZpl: 'ZPL', + kindImage: 'Bild', + kindPdf: 'PDF', + kindZplHint: 'Schickt den Code an den Drucker, der das Etikett selbst zeichnet.', + kindImageHint: 'Druckt das aktuelle Etikett als Bild auf einem gewöhnlichen Drucker.', + kindPdfHint: 'Speichert das gerenderte Etikett als PDF-Datei.', + scopeBatch: 'Eine Seite je Datenzeile', + scopeDesign: 'Alle Seiten', + imagePrint: 'Drucken', batchNoticeFmt: 'Druckt {n} Etiketten (eine pro Datenzeile).', batchNoticeQtyFmt: 'Druckt {n} Etiketten ({rows} Zeilen × {q} je Zeile).', tabNetwork: 'Netzwerk (IP)', @@ -1112,6 +1120,12 @@ const de = { errorGeneric: 'Fehler beim Senden', httpsWarning: 'Seite läuft über HTTPS, direktes IP-Drucken kann vom Browser blockiert werden (Mixed Content).', tabUsb: 'USB', + wayNetwork: 'Sendet direkt per TCP an den Drucker. Er liest auch zurück, damit Status, Vorschau und Chip funktionieren.', + wayNetworkWeb: 'Schickt den Code an den Druckerport. Der Browser bekommt keine Antwort, also liest nichts zurück.', + wayBrowserPrint: 'Läuft über Zebra Browser Print auf dem Rechner, der den Browser ausführt.', + wayLocal: 'Übergibt den Auftrag als Rohdaten an die Warteschlange des Betriebssystems. Die Warteschlange zeigt ihn an, aber nichts liest zurück.', + wayUsb: 'Sendet direkt an das verbundene Gerät und liest zurück.', + wayQueryAddress: 'Die Prüfung fragt immer über die Druckeradresse.', usbPermissionDenied: 'Kein Zugriff auf den USB-Drucker. Klicke auf Zugriff einrichten.', usbSetupAccess: 'Zugriff einrichten', usbSetupFlatpak: 'Innerhalb von Flatpak kann die App die udev-Regel nicht selbst installieren. Installiere 70-zplab.rules aus dem ZPLab-Repository auf dem Host und schließe den Drucker danach erneut an.', diff --git a/src/locales/el.ts b/src/locales/el.ts index 51745c7d..51c02f8a 100644 --- a/src/locales/el.ts +++ b/src/locales/el.ts @@ -988,7 +988,6 @@ const el = { exportZpl: 'Export ZPL', menuInitError: 'Δεν ήταν δυνατή η φόρτωση του μενού της εφαρμογής. Επανεκκινήστε την εφαρμογή.', importCsvData: 'Εισαγωγή δεδομένων CSV', - sendToZebraBatchFmt: 'Αποστολή σε Zebra ({n} ετικέτες)', exportBatchZplFmt: 'Εξαγωγή παρτίδας ZPL ({n} ετικέτες)', exportPdf: 'Εξαγωγή PDF', exportBatchPdfFmt: 'Εξαγωγή παρτίδας PDF ({n} ετικέτες)', @@ -1011,8 +1010,7 @@ const el = { deletePageConfirm: 'Διαγραφή της τρέχουσας σελίδας;', openDesign: 'Άνοιγμα σχεδίου', saveDesign: 'Αποθήκευση σχεδίου', - print: 'Εκτύπωση ως εικόνα (πρόγραμμα περιήγησης)', - sendToZebra: 'Αποστολή σε εκτυπωτή Zebra', + print: 'Εκτύπωση', propertiesTab: 'Ιδιότητες', layersTab: 'Επίπεδα', fontsTab: 'Γραμματοσειρές', @@ -1839,6 +1837,16 @@ const el = { }, zebraPrint: { heading: 'Αποστολή σε εκτυπωτή Zebra', + outputHeading: 'Εκτύπωση και εξαγωγή', + kindZpl: 'ZPL', + kindImage: 'Εικόνα', + kindPdf: 'PDF', + kindZplHint: 'Στέλνει τον κώδικα στον εκτυπωτή, ο οποίος σχεδιάζει μόνος του την ετικέτα.', + kindImageHint: 'Εκτυπώνει την τρέχουσα ετικέτα ως εικόνα σε έναν απλό εκτυπωτή.', + kindPdfHint: 'Αποθηκεύει την αποδοσμένη ετικέτα ως αρχείο PDF.', + scopeBatch: 'Μία σελίδα ανά γραμμή δεδομένων', + scopeDesign: 'Όλες οι σελίδες', + imagePrint: 'Εκτύπωση', batchNoticeFmt: 'Εκτυπώνει {n} ετικέτες (μία ανά γραμμή δεδομένων).', batchNoticeQtyFmt: 'Εκτυπώνει {n} ετικέτες ({rows} γραμμές × {q} η καθεμία).', tabNetwork: 'Δίκτυο (IP)', @@ -1862,6 +1870,12 @@ const el = { errorGeneric: 'Αποτυχία αποστολής ZPL', httpsWarning: 'Η σελίδα χρησιμοποιεί HTTPS, η άμεση εκτύπωση μέσω IP ενδέχεται να αποκλειστεί από τον φυλλομετρητή (μεικτό περιεχόμενο).', tabUsb: 'USB', + wayNetwork: 'Στέλνει απευθείας στον εκτυπωτή μέσω TCP. Διαβάζει και απάντηση, οπότε η κατάσταση, η προεπισκόπηση και η ετικέτα λειτουργούν.', + wayNetworkWeb: 'Στέλνει τον κώδικα στη θύρα του εκτυπωτή. Το πρόγραμμα περιήγησης δεν λαμβάνει απάντηση, οπότε τίποτα δεν διαβάζεται πίσω.', + wayBrowserPrint: 'Περνάει μέσω του Zebra Browser Print στον υπολογιστή που εκτελεί το πρόγραμμα περιήγησης.', + wayLocal: 'Παραδίδει την εργασία στην ουρά του λειτουργικού συστήματος ως ανεπεξέργαστα δεδομένα. Η ουρά την εμφανίζει, αλλά τίποτα δεν διαβάζει απάντηση.', + wayUsb: 'Στέλνει απευθείας στη συνδεδεμένη συσκευή και διαβάζει απάντηση.', + wayQueryAddress: 'Ο έλεγχος ρωτάει πάντα μέσω της διεύθυνσης του εκτυπωτή.', usbPermissionDenied: 'Δεν υπάρχει πρόσβαση στον εκτυπωτή USB. Κάντε κλικ στο Ρύθμιση πρόσβασης για να την παραχωρήσετε.', usbSetupAccess: 'Ρύθμιση πρόσβασης', usbSetupFlatpak: 'Μέσα στο Flatpak η εφαρμογή δεν μπορεί να εγκαταστήσει μόνη της τον κανόνα udev. Εγκαταστήστε το 70-zplab.rules από το αποθετήριο ZPLab στο σύστημα υποδοχής και συνδέστε ξανά τον εκτυπωτή.', diff --git a/src/locales/en.ts b/src/locales/en.ts index 43856408..60ad5f22 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -988,7 +988,6 @@ const en = { exportZpl: 'Export ZPL', menuInitError: 'The application menu could not be loaded. Please restart the app.', importCsvData: 'Import CSV data', - sendToZebraBatchFmt: 'Send to Zebra ({n} labels)', exportBatchZplFmt: 'Export batch ZPL ({n} labels)', exportPdf: 'Export PDF', exportBatchPdfFmt: 'Export batch PDF ({n} labels)', @@ -1011,8 +1010,7 @@ const en = { deletePageConfirm: 'Delete the current page?', openDesign: 'Open design', saveDesign: 'Save design', - print: 'Print as Image (browser)', - sendToZebra: 'Send to Zebra Printer', + print: 'Print', propertiesTab: 'Properties', layersTab: 'Layers', fontsTab: 'Fonts', @@ -1089,6 +1087,16 @@ const en = { zebraPrint: { heading: 'Send to Zebra Printer', + outputHeading: 'Output', + kindZpl: 'ZPL', + kindImage: 'Image', + kindPdf: 'PDF', + kindZplHint: 'Sends the code to the printer, which draws the label itself.', + kindImageHint: 'Prints the current label as an image on an ordinary printer.', + kindPdfHint: 'Saves the rendered label as a PDF file.', + scopeBatch: 'One page per data row', + scopeDesign: 'All pages', + imagePrint: 'Print', batchNoticeFmt: 'Prints {n} labels (one per data row).', batchNoticeQtyFmt: 'Prints {n} labels ({rows} rows × {q} each).', tabNetwork: 'Network (IP)', @@ -1112,6 +1120,12 @@ const en = { errorGeneric: 'Failed to send ZPL', httpsWarning: 'Page is on HTTPS, direct IP printing may be blocked by the browser (Mixed Content).', tabUsb: 'USB', + wayNetwork: 'Sends straight to the printer over TCP. It reads back too, so the status, the preview and the chip work.', + wayNetworkWeb: "Posts the code to the printer's port. The browser gets no reply, so nothing reads back.", + wayBrowserPrint: 'Goes through Zebra Browser Print on the computer running the browser.', + wayLocal: "Hands the job to the operating system's queue as raw data. The queue shows it, and nothing reads back.", + wayUsb: 'Sends straight to the connected device and reads back.', + wayQueryAddress: 'The check always asks over the printer address.', usbPermissionDenied: 'No access to the USB printer. Click Set up access to grant it.', usbSetupAccess: 'Set up access', usbSetupFlatpak: 'Inside Flatpak the app cannot install the udev rule itself. Install 70-zplab.rules from the ZPLab repository on the host, then re-plug the printer.', diff --git a/src/locales/es.ts b/src/locales/es.ts index 51604952..4aabeeb9 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -988,7 +988,6 @@ const es = { exportZpl: 'Export ZPL', menuInitError: 'No se pudo cargar el menú de la aplicación. Reinicia la aplicación.', importCsvData: 'Importar datos CSV', - sendToZebraBatchFmt: 'Enviar a Zebra ({n} etiquetas)', exportBatchZplFmt: 'Exportar ZPL por lotes ({n} etiquetas)', exportPdf: 'Exportar PDF', exportBatchPdfFmt: 'Exportar PDF por lotes ({n} etiquetas)', @@ -1011,8 +1010,7 @@ const es = { deletePageConfirm: '¿Eliminar la página actual?', openDesign: 'Abrir diseño', saveDesign: 'Guardar diseño', - print: 'Imprimir como imagen (navegador)', - sendToZebra: 'Enviar a impresora Zebra', + print: 'Imprimir', propertiesTab: 'Propiedades', layersTab: 'Capas', fontsTab: 'Fuentes', @@ -1839,6 +1837,16 @@ const es = { }, zebraPrint: { heading: 'Enviar a impresora Zebra', + outputHeading: 'Impresión y exportación', + kindZpl: 'ZPL', + kindImage: 'Imagen', + kindPdf: 'PDF', + kindZplHint: 'Envía el código a la impresora, que dibuja la etiqueta por sí misma.', + kindImageHint: 'Imprime la etiqueta actual como imagen en una impresora normal.', + kindPdfHint: 'Guarda la etiqueta renderizada como archivo PDF.', + scopeBatch: 'Una página por fila de datos', + scopeDesign: 'Todas las páginas', + imagePrint: 'Imprimir', batchNoticeFmt: 'Imprime {n} etiquetas (una por fila de datos).', batchNoticeQtyFmt: 'Imprime {n} etiquetas ({rows} filas × {q} cada una).', tabNetwork: 'Red (IP)', @@ -1862,6 +1870,12 @@ const es = { errorGeneric: 'Error al enviar ZPL', httpsWarning: 'La página está en HTTPS, la impresión directa por IP puede ser bloqueada por el navegador (contenido mixto).', tabUsb: 'USB', + wayNetwork: 'Envía directamente a la impresora por TCP. También responde, así que el estado, la vista previa y el chip funcionan.', + wayNetworkWeb: 'Envía el código al puerto de la impresora. El navegador no recibe respuesta, así que nada responde.', + wayBrowserPrint: 'Pasa por Zebra Browser Print en el ordenador que ejecuta el navegador.', + wayLocal: 'Entrega el trabajo a la cola del sistema operativo como datos sin procesar. La cola lo muestra, pero nada responde.', + wayUsb: 'Envía directamente al dispositivo conectado y recibe respuesta.', + wayQueryAddress: 'La comprobación siempre consulta la dirección de la impresora.', usbPermissionDenied: 'Sin acceso a la impresora USB. Haz clic en Configurar acceso para concederlo.', usbSetupAccess: 'Configurar acceso', usbSetupFlatpak: 'Dentro de Flatpak, la aplicación no puede instalar la regla udev por sí misma. Instala 70-zplab.rules desde el repositorio de ZPLab en el host y luego vuelve a conectar la impresora.', diff --git a/src/locales/et.ts b/src/locales/et.ts index 3e142bab..7be23f34 100644 --- a/src/locales/et.ts +++ b/src/locales/et.ts @@ -988,7 +988,6 @@ const et = { exportZpl: 'Export ZPL', menuInitError: 'Rakenduse menüüd ei õnnestunud laadida. Käivitage rakendus uuesti.', importCsvData: 'Impordi CSV-andmed', - sendToZebraBatchFmt: 'Saada Zebrale ({n} silti)', exportBatchZplFmt: 'Ekspordi partii-ZPL ({n} silti)', exportPdf: 'Ekspordi PDF', exportBatchPdfFmt: 'Ekspordi partii-PDF ({n} silti)', @@ -1011,8 +1010,7 @@ const et = { deletePageConfirm: 'Kustuta praegune leht?', openDesign: 'Ava kujundus', saveDesign: 'Salvesta kujundus', - print: 'Prindi pildina (brauser)', - sendToZebra: 'Saada Zebra printerile', + print: 'Prindi', propertiesTab: 'Omadused', layersTab: 'Kihid', fontsTab: 'Fondid', @@ -1839,6 +1837,16 @@ const et = { }, zebraPrint: { heading: 'Saada Zebra printerile', + outputHeading: 'Väljund', + kindZpl: 'ZPL', + kindImage: 'Pilt', + kindPdf: 'PDF', + kindZplHint: 'Saadab koodi printerisse, mis joonistab sildi ise.', + kindImageHint: 'Prindib praeguse sildi pildina tavalisel printeril.', + kindPdfHint: 'Salvestab renderdatud sildi PDF-failina.', + scopeBatch: 'Üks leht andmerea kohta', + scopeDesign: 'Kõik lehed', + imagePrint: 'Prindi', batchNoticeFmt: 'Prindib {n} silti (üks andmerea kohta).', batchNoticeQtyFmt: 'Prindib {n} silti ({rows} rida × {q} rea kohta).', tabNetwork: 'Võrk (IP)', @@ -1862,6 +1870,12 @@ const et = { errorGeneric: 'ZPL saatmine ebaõnnestus', httpsWarning: 'Leht kasutab HTTPS-i, otsene IP-printimistöö võib olla brauseri poolt blokeeritud (segasisaldus).', tabUsb: 'USB', + wayNetwork: 'Saadab otse printerisse TCP kaudu. Ta loeb ka vastust, nii et olek, eelvaade ja märgis töötavad.', + wayNetworkWeb: 'Saadab koodi printeri porti. Brauser ei saa vastust, nii et midagi tagasi ei loeta.', + wayBrowserPrint: 'Käib Zebra Browser Printi kaudu arvutis, kus brauser töötab.', + wayLocal: 'Annab töö operatsioonisüsteemi järjekorda toorandmetena. Järjekord näitab seda, kuid midagi vastust ei loe.', + wayUsb: 'Saadab otse ühendatud seadmesse ja loeb vastust.', + wayQueryAddress: 'Kontroll küsib alati printeri aadressi kaudu.', usbPermissionDenied: 'USB-printerile puudub juurdepääs. Klõpsa Seadista juurdepääs, et see anda.', usbSetupAccess: 'Seadista juurdepääs', usbSetupFlatpak: 'Flatpaki sees ei saa rakendus ise udev-reeglit paigaldada. Paigalda 70-zplab.rules ZPLab-i hoidlast hostsüsteemi ja ühenda printer uuesti.', diff --git a/src/locales/fa.ts b/src/locales/fa.ts index 1d1d5087..833088c0 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -988,7 +988,6 @@ const fa = { exportZpl: 'خروجی ZPL', menuInitError: 'منوی برنامه بارگذاری نشد. لطفاً برنامه را دوباره اجرا کنید.', importCsvData: 'وارد کردن داده‌های CSV', - sendToZebraBatchFmt: 'ارسال به Zebra ({n} برچسب)', exportBatchZplFmt: 'خروجی ZPL دسته‌ای ({n} برچسب)', exportPdf: 'خروجی PDF', exportBatchPdfFmt: 'خروجی PDF دسته‌ای ({n} برچسب)', @@ -1011,8 +1010,7 @@ const fa = { deletePageConfirm: 'صفحه فعلی حذف شود؟', openDesign: 'باز کردن طرح', saveDesign: 'ذخیره طرح', - print: 'چاپ به عنوان تصویر (مرورگر)', - sendToZebra: 'ارسال به چاپگر Zebra', + print: 'چاپ', propertiesTab: 'ویژگی‌ها', layersTab: 'لایه‌ها', fontsTab: 'فونت‌ها', @@ -1839,6 +1837,16 @@ const fa = { }, zebraPrint: { heading: 'ارسال به چاپگر Zebra', + outputHeading: 'خروجی', + kindZpl: 'ZPL', + kindImage: 'تصویر', + kindPdf: 'PDF', + kindZplHint: 'کد را به چاپگر می‌فرستد که خودش برچسب را رسم می‌کند.', + kindImageHint: 'برچسب فعلی را به صورت تصویر روی یک چاپگر معمولی چاپ می‌کند.', + kindPdfHint: 'برچسب رندر شده را به‌صورت فایل PDF ذخیره می‌کند.', + scopeBatch: 'یک صفحه برای هر ردیف داده', + scopeDesign: 'همه صفحات', + imagePrint: 'چاپ', batchNoticeFmt: '{n} برچسب چاپ می‌کند (یکی برای هر ردیف داده).', batchNoticeQtyFmt: '{n} برچسب چاپ می‌کند ({rows} ردیف × {q} برای هر ردیف).', tabNetwork: 'شبکه (IP)', @@ -1862,6 +1870,12 @@ const fa = { errorGeneric: 'ارسال ZPL ناموفق بود', httpsWarning: 'صفحه روی HTTPS است، چاپ مستقیم از طریق IP ممکن است توسط مرورگر مسدود شود (محتوای مختلط).', tabUsb: 'USB', + wayNetwork: 'مستقیماً از طریق TCP به چاپگر ارسال می‌کند. پاسخ را هم می‌خواند، پس وضعیت، پیش‌نمایش و برچسب کار می‌کنند.', + wayNetworkWeb: 'کد را به پورت چاپگر ارسال می‌کند. مرورگر پاسخی دریافت نمی‌کند، پس چیزی خوانده نمی‌شود.', + wayBrowserPrint: 'از طریق Zebra Browser Print روی کامپیوتری که مرورگر را اجرا می‌کند عبور می‌کند.', + wayLocal: 'کار را به‌صورت داده خام به صف سیستم‌عامل می‌سپارد. صف آن را نشان می‌دهد، اما چیزی پاسخ را نمی‌خواند.', + wayUsb: 'مستقیماً به دستگاه متصل ارسال می‌کند و پاسخ را می‌خواند.', + wayQueryAddress: 'بررسی همیشه از طریق آدرس چاپگر می‌پرسد.', usbPermissionDenied: 'دسترسی به چاپگر USB وجود ندارد. برای اعطای دسترسی روی «تنظیم دسترسی» کلیک کنید.', usbSetupAccess: 'تنظیم دسترسی', usbSetupFlatpak: 'داخل Flatpak، برنامه نمی‌تواند قانون udev را خودش نصب کند. فایل 70-zplab.rules را از مخزن ZPLab روی میزبان نصب کنید، سپس چاپگر را دوباره وصل کنید.', diff --git a/src/locales/fi.ts b/src/locales/fi.ts index ce0806f5..c3b9361f 100644 --- a/src/locales/fi.ts +++ b/src/locales/fi.ts @@ -988,7 +988,6 @@ const fi = { exportZpl: 'Export ZPL', menuInitError: 'Sovelluksen valikkoa ei voitu ladata. Käynnistä sovellus uudelleen.', importCsvData: 'Tuo CSV-tiedot', - sendToZebraBatchFmt: 'Lähetä Zebralle ({n} etikettiä)', exportBatchZplFmt: 'Vie erä-ZPL ({n} etikettiä)', exportPdf: 'Vie PDF', exportBatchPdfFmt: 'Vie erä-PDF ({n} etikettiä)', @@ -1011,8 +1010,7 @@ const fi = { deletePageConfirm: 'Poistetaanko nykyinen sivu?', openDesign: 'Avaa rakenne', saveDesign: 'Tallenna rakenne', - print: 'Tulosta kuvana (selain)', - sendToZebra: 'Lähetä Zebra-tulostimelle', + print: 'Tulosta', propertiesTab: 'Ominaisuudet', layersTab: 'Tasot', fontsTab: 'Fontit', @@ -1839,6 +1837,16 @@ const fi = { }, zebraPrint: { heading: 'Lähetä Zebra-tulostimelle', + outputHeading: 'Tuloste', + kindZpl: 'ZPL', + kindImage: 'Kuva', + kindPdf: 'PDF', + kindZplHint: 'Lähettää koodin tulostimelle, joka piirtää etiketin itse.', + kindImageHint: 'Tulostaa nykyisen etiketin kuvana tavallisella tulostimella.', + kindPdfHint: 'Tallentaa renderöidyn etiketin PDF-tiedostona.', + scopeBatch: 'Yksi sivu per datarivi', + scopeDesign: 'Kaikki sivut', + imagePrint: 'Tulosta', batchNoticeFmt: 'Tulostaa {n} etikettiä (yksi per datarivi).', batchNoticeQtyFmt: 'Tulostaa {n} etikettiä ({rows} riviä × {q} per rivi).', tabNetwork: 'Verkko (IP)', @@ -1862,6 +1870,12 @@ const fi = { errorGeneric: 'ZPL:n lähetys epäonnistui', httpsWarning: 'Sivu on HTTPS:llä, suora IP-tulostus saattaa olla selaimen estämä (sekasisältö).', tabUsb: 'USB', + wayNetwork: 'Lähettää suoraan tulostimelle TCP:n kautta. Se lukee myös vastauksen, jolloin tila, esikatselu ja merkki toimivat.', + wayNetworkWeb: 'Lähettää koodin tulostimen porttiin. Selain ei saa vastausta, jolloin mitään ei lueta takaisin.', + wayBrowserPrint: 'Kulkee Zebra Browser Printin kautta tietokoneella, jolla selain on käynnissä.', + wayLocal: 'Antaa työn käyttöjärjestelmän jonoon raakadatana. Jono näyttää sen, mutta mikään ei lue vastausta.', + wayUsb: 'Lähettää suoraan liitettyyn laitteeseen ja lukee vastauksen.', + wayQueryAddress: 'Tarkistus kysyy aina tulostimen osoitteen kautta.', usbPermissionDenied: 'Ei käyttöoikeutta USB-tulostimeen. Napsauta Määritä käyttöoikeus myöntääksesi sen.', usbSetupAccess: 'Määritä käyttöoikeus', usbSetupFlatpak: 'Flatpakin sisällä sovellus ei voi asentaa udev-sääntöä itse. Asenna 70-zplab.rules ZPLab-tietovarastosta isäntäjärjestelmään ja kytke tulostin uudelleen.', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 72863880..62e39e5a 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -988,7 +988,6 @@ const fr = { exportZpl: 'Export ZPL', menuInitError: "Le menu de l'application n'a pas pu être chargé. Veuillez redémarrer l'application.", importCsvData: 'Importer des données CSV', - sendToZebraBatchFmt: 'Envoyer à Zebra ({n} étiquettes)', exportBatchZplFmt: 'Exporter ZPL en lot ({n} étiquettes)', exportPdf: 'Exporter PDF', exportBatchPdfFmt: 'Exporter PDF en lot ({n} étiquettes)', @@ -1011,8 +1010,7 @@ const fr = { deletePageConfirm: 'Supprimer la page actuelle ?', openDesign: 'Ouvrir le design', saveDesign: 'Enregistrer le design', - print: 'Imprimer en image (navigateur)', - sendToZebra: 'Envoyer à l’imprimante Zebra', + print: 'Imprimer', propertiesTab: 'Propriétés', layersTab: 'Calques', fontsTab: 'Polices', @@ -1839,6 +1837,16 @@ const fr = { }, zebraPrint: { heading: 'Envoyer à l’imprimante Zebra', + outputHeading: 'Impression et export', + kindZpl: 'ZPL', + kindImage: 'Image', + kindPdf: 'PDF', + kindZplHint: "Envoie le code à l'imprimante, qui dessine elle-même l'étiquette.", + kindImageHint: "Imprime l'étiquette actuelle en image sur une imprimante ordinaire.", + kindPdfHint: "Enregistre l'étiquette rendue sous forme de fichier PDF.", + scopeBatch: 'Une page par ligne de données', + scopeDesign: 'Toutes les pages', + imagePrint: 'Imprimer', batchNoticeFmt: 'Imprime {n} étiquettes (une par ligne de données).', batchNoticeQtyFmt: 'Imprime {n} étiquettes ({rows} lignes × {q} chacune).', tabNetwork: 'Réseau (IP)', @@ -1862,6 +1870,12 @@ const fr = { errorGeneric: 'Échec de l’envoi ZPL', httpsWarning: 'La page est en HTTPS, l\'impression directe par IP peut être bloquée par le navigateur (contenu mixte).', tabUsb: 'USB', + wayNetwork: "Envoie directement à l'imprimante par TCP. Elle répond aussi, donc le statut, l'aperçu et la puce fonctionnent.", + wayNetworkWeb: "Envoie le code au port de l'imprimante. Le navigateur ne reçoit aucune réponse, donc rien ne répond.", + wayBrowserPrint: "Passe par Zebra Browser Print sur l'ordinateur qui exécute le navigateur.", + wayLocal: "Confie le travail à la file d'attente du système comme données brutes. La file l'affiche, mais rien ne répond.", + wayUsb: "Envoie directement à l'appareil connecté et reçoit une réponse.", + wayQueryAddress: "La vérification interroge toujours l'adresse de l'imprimante.", usbPermissionDenied: "Aucun accès à l'imprimante USB. Clique sur Configurer l'accès pour l'autoriser.", usbSetupAccess: "Configurer l'accès", usbSetupFlatpak: "À l'intérieur de Flatpak, l'application ne peut pas installer la règle udev elle-même. Installe 70-zplab.rules depuis le dépôt ZPLab sur l'hôte, puis rebranche l'imprimante.", diff --git a/src/locales/he.ts b/src/locales/he.ts index 7e111e46..dad41b87 100644 --- a/src/locales/he.ts +++ b/src/locales/he.ts @@ -988,7 +988,6 @@ const he = { exportZpl: 'ייצוא ZPL', menuInitError: 'לא ניתן היה לטעון את תפריט היישום. יש להפעיל מחדש את היישום.', importCsvData: 'ייבוא נתוני CSV', - sendToZebraBatchFmt: 'שלח ל-Zebra ({n} תוויות)', exportBatchZplFmt: 'ייצוא ZPL באצווה ({n} תוויות)', exportPdf: 'ייצוא PDF', exportBatchPdfFmt: 'ייצוא PDF באצווה ({n} תוויות)', @@ -1011,8 +1010,7 @@ const he = { deletePageConfirm: 'למחוק את הדף הנוכחי?', openDesign: 'פתח עיצוב', saveDesign: 'שמור עיצוב', - print: 'הדפסה כתמונה (דפדפן)', - sendToZebra: 'שלח למדפסת Zebra', + print: 'הדפסה', propertiesTab: 'מאפיינים', layersTab: 'שכבות', fontsTab: 'גופנים', @@ -1839,6 +1837,16 @@ const he = { }, zebraPrint: { heading: 'שלח למדפסת Zebra', + outputHeading: 'פלט', + kindZpl: 'ZPL', + kindImage: 'תמונה', + kindPdf: 'PDF', + kindZplHint: 'שולח את הקוד למדפסת, שמציירת את התווית בעצמה.', + kindImageHint: 'מדפיס את התווית הנוכחית כתמונה על מדפסת רגילה.', + kindPdfHint: 'שומר את התווית המעובדת כקובץ PDF.', + scopeBatch: 'עמוד אחד לכל שורת נתונים', + scopeDesign: 'כל העמודים', + imagePrint: 'הדפס', batchNoticeFmt: 'מדפיס {n} תוויות (אחת לכל שורת נתונים).', batchNoticeQtyFmt: 'מדפיס {n} תוויות ({rows} שורות × {q} לכל שורה).', tabNetwork: 'רשת (IP)', @@ -1862,6 +1870,12 @@ const he = { errorGeneric: 'שליחת ZPL נכשלה', httpsWarning: 'הדף נמצא על HTTPS, הדפסה ישירה דרך IP עלולה להיחסם על ידי הדפדפן (תוכן מעורב).', tabUsb: 'USB', + wayNetwork: 'שולח ישירות למדפסת דרך TCP. הוא גם קורא תגובה, כך שהסטטוס, התצוגה המקדימה והתג פועלים.', + wayNetworkWeb: 'שולח את הקוד לפורט המדפסת. הדפדפן לא מקבל תגובה, כך ששום דבר לא נקרא בחזרה.', + wayBrowserPrint: 'עובר דרך Zebra Browser Print במחשב שמריץ את הדפדפן.', + wayLocal: 'מעביר את העבודה לתור של מערכת ההפעלה כנתונים גולמיים. התור מציג אותה, אך שום דבר לא קורא תגובה.', + wayUsb: 'שולח ישירות למכשיר המחובר וקורא תגובה.', + wayQueryAddress: 'הבדיקה שואלת תמיד לפי כתובת המדפסת.', usbPermissionDenied: "אין גישה למדפסת ה-USB. לחץ על 'הגדרת גישה' כדי להעניק אותה.", usbSetupAccess: 'הגדרת גישה', usbSetupFlatpak: 'בתוך Flatpak האפליקציה לא יכולה להתקין את כלל ה-udev בעצמה. התקן את 70-zplab.rules ממאגר ZPLab על המארח, ואז חבר את המדפסת מחדש.', diff --git a/src/locales/hr.ts b/src/locales/hr.ts index e93c6601..20dcee85 100644 --- a/src/locales/hr.ts +++ b/src/locales/hr.ts @@ -988,7 +988,6 @@ const hr = { exportZpl: 'Export ZPL', menuInitError: 'Izbornik aplikacije nije se mogao učitati. Ponovno pokrenite aplikaciju.', importCsvData: 'Uvoz CSV podataka', - sendToZebraBatchFmt: 'Pošalji na Zebra ({n} etiketa)', exportBatchZplFmt: 'Izvoz skupnog ZPL-a ({n} etiketa)', exportPdf: 'Izvoz PDF-a', exportBatchPdfFmt: 'Izvoz skupnog PDF-a ({n} etiketa)', @@ -1011,8 +1010,7 @@ const hr = { deletePageConfirm: 'Izbrisati trenutnu stranicu?', openDesign: 'Otvori dizajn', saveDesign: 'Spremi dizajn', - print: 'Ispis kao slika (preglednik)', - sendToZebra: 'Pošalji na Zebra pisač', + print: 'Ispis', propertiesTab: 'Svojstva', layersTab: 'Slojevi', fontsTab: 'Fontovi', @@ -1839,6 +1837,16 @@ const hr = { }, zebraPrint: { heading: 'Pošalji na Zebra pisač', + outputHeading: 'Ispis i izvoz', + kindZpl: 'ZPL', + kindImage: 'Slika', + kindPdf: 'PDF', + kindZplHint: 'Šalje kôd pisaču, koji sam crta etiketu.', + kindImageHint: 'Ispisuje trenutnu etiketu kao sliku na običnom pisaču.', + kindPdfHint: 'Sprema iscrtanu etiketu kao PDF datoteku.', + scopeBatch: 'Jedna stranica po retku podataka', + scopeDesign: 'Sve stranice', + imagePrint: 'Ispiši', batchNoticeFmt: 'Ispisuje {n} etiketa (jednu po retku podataka).', batchNoticeQtyFmt: 'Ispisuje {n} etiketa ({rows} redaka × {q} po retku).', tabNetwork: 'Mreža (IP)', @@ -1862,6 +1870,12 @@ const hr = { errorGeneric: 'Slanje ZPL-a nije uspjelo', httpsWarning: 'Stranica koristi HTTPS, izravno ispisivanje putem IP-a može biti blokirano od preglednika (mješoviti sadržaj).', tabUsb: 'USB', + wayNetwork: 'Šalje izravno pisaču putem TCP-a. Također čita odgovor, pa status, pregled i oznaka rade.', + wayNetworkWeb: 'Šalje kod na port pisača. Preglednik ne prima odgovor, pa ništa ne čita natrag.', + wayBrowserPrint: 'Prolazi kroz Zebra Browser Print na računalu koje pokreće preglednik.', + wayLocal: 'Predaje zadatak redu operacijskog sustava kao sirove podatke. Red ga prikazuje, ali ništa ne čita odgovor.', + wayUsb: 'Šalje izravno na povezani uređaj i čita odgovor.', + wayQueryAddress: 'Provjera uvijek pita putem adrese pisača.', usbPermissionDenied: 'Nema pristupa USB pisaču. Kliknite Postavi pristup da biste ga odobrili.', usbSetupAccess: 'Postavi pristup', usbSetupFlatpak: 'Unutar Flatpaka aplikacija ne može sama instalirati udev pravilo. Instalirajte 70-zplab.rules iz ZPLab repozitorija na glavnom sustavu, zatim ponovno priključite pisač.', diff --git a/src/locales/hu.ts b/src/locales/hu.ts index 781c878f..dddf0c82 100644 --- a/src/locales/hu.ts +++ b/src/locales/hu.ts @@ -988,7 +988,6 @@ const hu = { exportZpl: 'Export ZPL', menuInitError: 'Az alkalmazás menüje nem tölthető be. Indítsa újra az alkalmazást.', importCsvData: 'CSV-adatok importálása', - sendToZebraBatchFmt: 'Küldés Zebrára ({n} címke)', exportBatchZplFmt: 'Köteg ZPL exportálása ({n} címke)', exportPdf: 'PDF exportálása', exportBatchPdfFmt: 'Köteg PDF exportálása ({n} címke)', @@ -1011,8 +1010,7 @@ const hu = { deletePageConfirm: 'Törli az aktuális oldalt?', openDesign: 'Terv megnyitása', saveDesign: 'Terv mentése', - print: 'Nyomtatás képként (böngésző)', - sendToZebra: 'Küldés Zebra nyomtatóra', + print: 'Nyomtatás', propertiesTab: 'Tulajdonságok', layersTab: 'Rétegek', fontsTab: 'Betűtípusok', @@ -1839,6 +1837,16 @@ const hu = { }, zebraPrint: { heading: 'Küldés Zebra nyomtatóra', + outputHeading: 'Kimenet', + kindZpl: 'ZPL', + kindImage: 'Kép', + kindPdf: 'PDF', + kindZplHint: 'Elküldi a kódot a nyomtatónak, amely maga rajzolja meg a címkét.', + kindImageHint: 'Kinyomtatja a jelenlegi címkét képként egy szokásos nyomtatón.', + kindPdfHint: 'Elmenti a renderelt címkét PDF-fájlként.', + scopeBatch: 'Egy oldal adatsoronként', + scopeDesign: 'Összes oldal', + imagePrint: 'Nyomtatás', batchNoticeFmt: '{n} címkét nyomtat (adatsoronként egyet).', batchNoticeQtyFmt: '{n} címkét nyomtat ({rows} sor × soronként {q}).', tabNetwork: 'Hálózat (IP)', @@ -1862,6 +1870,12 @@ const hu = { errorGeneric: 'ZPL küldése sikertelen', httpsWarning: 'Az oldal HTTPS-en fut, az IP-n keresztüli közvetlen nyomtatást a böngésző blokkolhatja (vegyes tartalom).', tabUsb: 'USB', + wayNetwork: 'Egyenesen a nyomtatóra küldi TCP-n keresztül. Választ is olvas, így a státusz, az előnézet és a jelvény működik.', + wayNetworkWeb: 'Elküldi a kódot a nyomtató portjára. A böngésző nem kap választ, így semmi nem olvas vissza.', + wayBrowserPrint: 'A böngészőt futtató számítógépen keresztül a Zebra Browser Printen megy át.', + wayLocal: 'A feladatot nyers adatként adja át az operációs rendszer sorának. A sor megjeleníti, de semmi nem olvas választ.', + wayUsb: 'Egyenesen a csatlakoztatott eszközre küldi, és választ olvas.', + wayQueryAddress: 'Az ellenőrzés mindig a nyomtató címén keresztül kérdez.', usbPermissionDenied: 'Nincs hozzáférés az USB-nyomtatóhoz. Kattintson a Hozzáférés beállítása gombra az engedélyezéshez.', usbSetupAccess: 'Hozzáférés beállítása', usbSetupFlatpak: 'A Flatpakon belül az alkalmazás nem tudja magától telepíteni az udev szabályt. Telepítse a 70-zplab.rules fájlt a ZPLab tárolóból a gazdagépre, majd csatlakoztassa újra a nyomtatót.', diff --git a/src/locales/it.ts b/src/locales/it.ts index 7db1e115..820ba5a3 100644 --- a/src/locales/it.ts +++ b/src/locales/it.ts @@ -988,7 +988,6 @@ const it = { exportZpl: 'Export ZPL', menuInitError: "Impossibile caricare il menu dell'applicazione. Riavvia l'app.", importCsvData: 'Importa dati CSV', - sendToZebraBatchFmt: 'Invia a Zebra ({n} etichette)', exportBatchZplFmt: 'Esporta ZPL in batch ({n} etichette)', exportPdf: 'Esporta PDF', exportBatchPdfFmt: 'Esporta PDF in batch ({n} etichette)', @@ -1011,8 +1010,7 @@ const it = { deletePageConfirm: 'Eliminare la pagina corrente?', openDesign: 'Apri design', saveDesign: 'Salva design', - print: 'Stampa come immagine (browser)', - sendToZebra: 'Invia a stampante Zebra', + print: 'Stampa', propertiesTab: 'Proprietà', layersTab: 'Livelli', fontsTab: 'Caratteri', @@ -1839,6 +1837,16 @@ const it = { }, zebraPrint: { heading: 'Invia a stampante Zebra', + outputHeading: 'Stampa ed esportazione', + kindZpl: 'ZPL', + kindImage: 'Immagine', + kindPdf: 'PDF', + kindZplHint: "Invia il codice alla stampante, che disegna l'etichetta da sola.", + kindImageHint: "Stampa l'etichetta corrente come immagine su una stampante normale.", + kindPdfHint: "Salva l'etichetta renderizzata come file PDF.", + scopeBatch: 'Una pagina per riga di dati', + scopeDesign: 'Tutte le pagine', + imagePrint: 'Stampa', batchNoticeFmt: 'Stampa {n} etichette (una per riga di dati).', batchNoticeQtyFmt: 'Stampa {n} etichette ({rows} righe × {q} ciascuna).', tabNetwork: 'Rete (IP)', @@ -1862,6 +1870,12 @@ const it = { errorGeneric: 'Invio ZPL non riuscito', httpsWarning: 'La pagina è in HTTPS, la stampa diretta tramite IP potrebbe essere bloccata dal browser (contenuto misto).', tabUsb: 'USB', + wayNetwork: 'Invia direttamente alla stampante via TCP. Risponde anche, quindi stato, anteprima e chip funzionano.', + wayNetworkWeb: 'Invia il codice alla porta della stampante. Il browser non riceve risposta, quindi nulla risponde.', + wayBrowserPrint: 'Passa da Zebra Browser Print sul computer che esegue il browser.', + wayLocal: 'Affida il lavoro alla coda del sistema operativo come dati grezzi. La coda lo mostra, ma nulla risponde.', + wayUsb: 'Invia direttamente al dispositivo collegato e riceve risposta.', + wayQueryAddress: "Il controllo interroga sempre l'indirizzo della stampante.", usbPermissionDenied: 'Nessun accesso alla stampante USB. Fai clic su Configura accesso per concederlo.', usbSetupAccess: 'Configura accesso', usbSetupFlatpak: "All'interno di Flatpak l'app non può installare la regola udev da sola. Installa 70-zplab.rules dal repository ZPLab sull'host, poi ricollega la stampante.", diff --git a/src/locales/ja.ts b/src/locales/ja.ts index 19632c85..25aa24d8 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -988,7 +988,6 @@ const ja = { exportZpl: 'ZPL エクスポート', menuInitError: 'アプリケーションメニューを読み込めませんでした。アプリを再起動してください。', importCsvData: 'CSVデータをインポート', - sendToZebraBatchFmt: 'Zebra へ送信 ({n} ラベル)', exportBatchZplFmt: 'バッチZPLをエクスポート ({n} ラベル)', exportPdf: 'PDF エクスポート', exportBatchPdfFmt: 'バッチPDFをエクスポート ({n} ラベル)', @@ -1011,8 +1010,7 @@ const ja = { deletePageConfirm: '現在のページを削除しますか?', openDesign: 'デザインを開く', saveDesign: 'デザインを保存', - print: '画像として印刷(ブラウザ)', - sendToZebra: 'Zebra プリンターへ送信', + print: '印刷', propertiesTab: 'プロパティ', layersTab: 'レイヤー', fontsTab: 'フォント', @@ -1839,6 +1837,16 @@ const ja = { }, zebraPrint: { heading: 'Zebra プリンターへ送信', + outputHeading: '出力', + kindZpl: 'ZPL', + kindImage: '画像', + kindPdf: 'PDF', + kindZplHint: 'コードをプリンターへ送り、プリンター自身がラベルを描画します。', + kindImageHint: '現在のラベルを画像として通常のプリンターで印刷します。', + kindPdfHint: 'レンダリングしたラベルをPDFファイルとして保存します。', + scopeBatch: 'データ行ごとに1ページ', + scopeDesign: 'すべてのページ', + imagePrint: '印刷', batchNoticeFmt: '{n} 件のラベルを印刷します(データ行ごとに1枚)。', batchNoticeQtyFmt: '{n} 件のラベルを印刷します({rows} 行 × 各 {q} 枚)。', tabNetwork: 'ネットワーク (IP)', @@ -1862,6 +1870,12 @@ const ja = { errorGeneric: 'ZPL の送信に失敗しました', httpsWarning: 'ページが HTTPS 上にあります、ブラウザが直接 IP 印刷をブロックする場合があります(混在コンテンツ)。', tabUsb: 'USB', + wayNetwork: 'TCP経由でプリンターに直接送信します。応答も読み取るので、状態、プレビュー、チップが機能します。', + wayNetworkWeb: 'コードをプリンターのポートに送信します。ブラウザーは応答を受け取らないため、何も読み取りません。', + wayBrowserPrint: 'ブラウザを実行しているコンピューター上のZebra Browser Printを経由します。', + wayLocal: 'ジョブを生データとしてオペレーティングシステムのキューに渡します。キューには表示されますが、応答は読み取りません。', + wayUsb: '接続された機器に直接送信し、応答を読み取ります。', + wayQueryAddress: '確認は常にプリンターのアドレスに問い合わせます。', usbPermissionDenied: 'USBプリンターにアクセスできません。「アクセスを設定」をクリックして許可してください。', usbSetupAccess: 'アクセスを設定', usbSetupFlatpak: 'Flatpak内ではアプリが自分でudevルールをインストールできません。ホスト側でZPLabリポジトリから70-zplab.rulesをインストールし、プリンターを再接続してください。', diff --git a/src/locales/ko.ts b/src/locales/ko.ts index ea401938..8cbc4fea 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -988,7 +988,6 @@ const ko = { exportZpl: 'ZPL 내보내기', menuInitError: '애플리케이션 메뉴를 불러올 수 없습니다. 앱을 다시 시작하세요.', importCsvData: 'CSV 데이터 가져오기', - sendToZebraBatchFmt: 'Zebra로 전송 ({n}개 라벨)', exportBatchZplFmt: '일괄 ZPL 내보내기 ({n}개 라벨)', exportPdf: 'PDF 내보내기', exportBatchPdfFmt: '일괄 PDF 내보내기 ({n}개 라벨)', @@ -1011,8 +1010,7 @@ const ko = { deletePageConfirm: '현재 페이지를 삭제하시겠습니까?', openDesign: '디자인 열기', saveDesign: '디자인 저장', - print: '이미지로 인쇄 (브라우저)', - sendToZebra: 'Zebra 프린터로 전송', + print: '인쇄', propertiesTab: '속성', layersTab: '레이어', fontsTab: '글꼴', @@ -1839,6 +1837,16 @@ const ko = { }, zebraPrint: { heading: 'Zebra 프린터로 전송', + outputHeading: '출력', + kindZpl: 'ZPL', + kindImage: '이미지', + kindPdf: 'PDF', + kindZplHint: '코드를 프린터로 전송하면, 프린터가 레이블을 직접 그립니다.', + kindImageHint: '현재 레이블을 이미지로 일반 프린터로 인쇄합니다.', + kindPdfHint: '렌더링된 레이블을 PDF 파일로 저장합니다.', + scopeBatch: '데이터 행마다 1페이지', + scopeDesign: '모든 페이지', + imagePrint: '인쇄', batchNoticeFmt: '{n}개 라벨을 인쇄합니다 (데이터 행마다 1개).', batchNoticeQtyFmt: '{n}개 라벨을 인쇄합니다 ({rows}개 행 × 행당 {q}개).', tabNetwork: '네트워크 (IP)', @@ -1862,6 +1870,12 @@ const ko = { errorGeneric: 'ZPL 전송에 실패했습니다', httpsWarning: '페이지가 HTTPS 상에 있습니다, 브라우저가 직접 IP 인쇄를 차단할 수 있습니다(혼합 콘텐츠).', tabUsb: 'USB', + wayNetwork: 'TCP를 통해 프린터로 바로 전송합니다. 응답도 읽으므로 상태, 미리보기, 칩이 작동합니다.', + wayNetworkWeb: '코드를 프린터 포트로 전송합니다. 브라우저는 응답을 받지 못하므로 아무것도 읽어오지 않습니다.', + wayBrowserPrint: '브라우저를 실행하는 컴퓨터의 Zebra Browser Print를 거칩니다.', + wayLocal: '작업을 원시 데이터로 운영체제의 대기열에 넘깁니다. 대기열에는 표시되지만 아무것도 응답하지 않습니다.', + wayUsb: '연결된 장치로 바로 전송하고 응답을 읽습니다.', + wayQueryAddress: '확인은 항상 프린터 주소로 문의합니다.', usbPermissionDenied: "USB 프린터에 액세스할 수 없습니다. '액세스 설정'을 클릭하여 권한을 부여하세요.", usbSetupAccess: '액세스 설정', usbSetupFlatpak: 'Flatpak 내부에서는 앱이 직접 udev 규칙을 설치할 수 없습니다. 호스트에서 ZPLab 저장소의 70-zplab.rules를 설치한 뒤 프린터를 다시 연결하세요.', diff --git a/src/locales/lt.ts b/src/locales/lt.ts index 1649a8e4..9f541f4c 100644 --- a/src/locales/lt.ts +++ b/src/locales/lt.ts @@ -988,7 +988,6 @@ const lt = { exportZpl: 'Export ZPL', menuInitError: 'Nepavyko įkelti programos meniu. Iš naujo paleiskite programą.', importCsvData: 'Importuoti CSV duomenis', - sendToZebraBatchFmt: 'Siųsti į Zebra ({n} etikečių)', exportBatchZplFmt: 'Eksportuoti paketinį ZPL ({n} etikečių)', exportPdf: 'Eksportuoti PDF', exportBatchPdfFmt: 'Eksportuoti paketinį PDF ({n} etikečių)', @@ -1011,8 +1010,7 @@ const lt = { deletePageConfirm: 'Ištrinti dabartinį puslapį?', openDesign: 'Atidaryti dizainą', saveDesign: 'Išsaugoti dizainą', - print: 'Spausdinti kaip vaizdą (naršyklė)', - sendToZebra: 'Siųsti į Zebra spausdintuvą', + print: 'Spausdinti', propertiesTab: 'Savybės', layersTab: 'Sluoksniai', fontsTab: 'Šriftai', @@ -1839,6 +1837,16 @@ const lt = { }, zebraPrint: { heading: 'Siųsti į Zebra spausdintuvą', + outputHeading: 'Išvestis', + kindZpl: 'ZPL', + kindImage: 'Vaizdas', + kindPdf: 'PDF', + kindZplHint: 'Siunčia kodą į spausdintuvą, kuris pats nupiešia etiketę.', + kindImageHint: 'Spausdina dabartinę etiketę kaip vaizdą įprastame spausdintuve.', + kindPdfHint: 'Išsaugo atvaizduotą etiketę kaip PDF failą.', + scopeBatch: 'Vienas puslapis kiekvienai duomenų eilutei', + scopeDesign: 'Visi puslapiai', + imagePrint: 'Spausdinti', batchNoticeFmt: 'Spausdina {n} etikečių (po vieną kiekvienai duomenų eilutei).', batchNoticeQtyFmt: 'Spausdina {n} etikečių ({rows} eilutės × {q} kiekvienai).', tabNetwork: 'Tinklas (IP)', @@ -1862,6 +1870,12 @@ const lt = { errorGeneric: 'ZPL siuntimas nepavyko', httpsWarning: 'Puslapis naudoja HTTPS, tiesioginis spausdinimas per IP gali būti užblokuotas naršyklės (mišrus turinys).', tabUsb: 'USB', + wayNetwork: 'Siunčia tiesiai į spausdintuvą per TCP. Jis taip pat nuskaito atsakymą, todėl būsena, peržiūra ir ženklelis veikia.', + wayNetworkWeb: 'Siunčia kodą į spausdintuvo prievadą. Naršyklė nesulaukia atsakymo, todėl atgal nieko nenuskaitoma.', + wayBrowserPrint: 'Eina per Zebra Browser Print kompiuteryje, kuriame veikia naršyklė.', + wayLocal: 'Perduoda užduotį operacinės sistemos eilei kaip neapdorotus duomenis. Eilė ją rodo, bet atsakymas niekada nenuskaitomas.', + wayUsb: 'Siunčia tiesiai į prijungtą įrenginį ir nuskaito atsakymą.', + wayQueryAddress: 'Patikra visada klausia per spausdintuvo adresą.', usbPermissionDenied: 'Nėra prieigos prie USB spausdintuvo. Spustelėkite Nustatyti prieigą, kad ją suteiktumėte.', usbSetupAccess: 'Nustatyti prieigą', usbSetupFlatpak: 'Flatpak aplinkoje programa negali pati įdiegti udev taisyklės. Įdiekite 70-zplab.rules iš ZPLab saugyklos pagrindinėje sistemoje, tada iš naujo prijunkite spausdintuvą.', diff --git a/src/locales/lv.ts b/src/locales/lv.ts index e1f85453..46cd0411 100644 --- a/src/locales/lv.ts +++ b/src/locales/lv.ts @@ -988,7 +988,6 @@ const lv = { exportZpl: 'Export ZPL', menuInitError: 'Neizdevās ielādēt lietotnes izvēlni. Lūdzu, restartējiet lietotni.', importCsvData: 'Importēt CSV datus', - sendToZebraBatchFmt: 'Sūtīt uz Zebra ({n} etiķetes)', exportBatchZplFmt: 'Eksportēt pakešu ZPL ({n} etiķetes)', exportPdf: 'Eksportēt PDF', exportBatchPdfFmt: 'Eksportēt pakešu PDF ({n} etiķetes)', @@ -1011,8 +1010,7 @@ const lv = { deletePageConfirm: 'Dzēst pašreizējo lapu?', openDesign: 'Atvērt dizainu', saveDesign: 'Saglabāt dizainu', - print: 'Drukāt kā attēlu (pārlūks)', - sendToZebra: 'Sūtīt uz Zebra printeri', + print: 'Drukāt', propertiesTab: 'Īpašības', layersTab: 'Slāņi', fontsTab: 'Fonti', @@ -1839,6 +1837,16 @@ const lv = { }, zebraPrint: { heading: 'Sūtīt uz Zebra printeri', + outputHeading: 'Izvade', + kindZpl: 'ZPL', + kindImage: 'Attēls', + kindPdf: 'PDF', + kindZplHint: 'Nosūta kodu uz printeri, kurš pats uzzīmē etiķeti.', + kindImageHint: 'Izdrukā pašreizējo etiķeti kā attēlu parastā printerī.', + kindPdfHint: 'Saglabā atveidoto etiķeti kā PDF failu.', + scopeBatch: 'Viena lapa katrai datu rindai', + scopeDesign: 'Visas lapas', + imagePrint: 'Drukāt', batchNoticeFmt: 'Izdrukā {n} etiķetes (pa vienai katrai datu rindai).', batchNoticeQtyFmt: 'Izdrukā {n} etiķetes ({rows} rindas × {q} katrā rindā).', tabNetwork: 'Tīkls (IP)', @@ -1862,6 +1870,12 @@ const lv = { errorGeneric: 'ZPL nosūtīšana neizdevās', httpsWarning: 'Lapa izmanto HTTPS, tiešā IP drukāšana var tikt bloķēta pārlūkprogrammā (jaukts saturs).', tabUsb: 'USB', + wayNetwork: 'Nosūta tieši uz printeri, izmantojot TCP. Tas arī nolasa atbildi, tāpēc statuss, priekšskatījums un žetons darbojas.', + wayNetworkWeb: 'Nosūta kodu uz printera portu. Pārlūkprogramma nesaņem atbildi, tāpēc nekas netiek nolasīts atpakaļ.', + wayBrowserPrint: 'Notiek caur Zebra Browser Print datorā, kurā darbojas pārlūkprogramma.', + wayLocal: 'Nodod darbu operētājsistēmas rindai kā neapstrādātus datus. Rinda to parāda, bet atbilde netiek nolasīta.', + wayUsb: 'Nosūta tieši uz pievienoto ierīci un nolasa atbildi.', + wayQueryAddress: 'Pārbaude vienmēr jautā, izmantojot printera adresi.', usbPermissionDenied: 'Nav piekļuves USB printerim. Noklikšķiniet uz Iestatīt piekļuvi, lai to piešķirtu.', usbSetupAccess: 'Iestatīt piekļuvi', usbSetupFlatpak: 'Flatpak iekšienē lietotne nevar pati instalēt udev kārtulu. Instalējiet 70-zplab.rules no ZPLab repozitorija resursdatorā un pēc tam atkārtoti pievienojiet printeri.', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 141f18ed..862cb95c 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -988,7 +988,6 @@ const nl = { exportZpl: 'Export ZPL', menuInitError: 'Het menu van de applicatie kon niet worden geladen. Start de app opnieuw.', importCsvData: 'CSV-gegevens importeren', - sendToZebraBatchFmt: 'Verzenden naar Zebra ({n} etiketten)', exportBatchZplFmt: 'Batch-ZPL exporteren ({n} etiketten)', exportPdf: 'PDF exporteren', exportBatchPdfFmt: 'Batch-PDF exporteren ({n} etiketten)', @@ -1011,8 +1010,7 @@ const nl = { deletePageConfirm: 'Huidige pagina verwijderen?', openDesign: 'Ontwerp openen', saveDesign: 'Ontwerp opslaan', - print: 'Afdrukken als afbeelding (browser)', - sendToZebra: 'Verzenden naar Zebra-printer', + print: 'Afdrukken', propertiesTab: 'Eigenschappen', layersTab: 'Lagen', fontsTab: 'Lettertypen', @@ -1839,6 +1837,16 @@ const nl = { }, zebraPrint: { heading: 'Verzenden naar Zebra-printer', + outputHeading: 'Uitvoer', + kindZpl: 'ZPL', + kindImage: 'Afbeelding', + kindPdf: 'PDF', + kindZplHint: 'Stuurt de code naar de printer, die het etiket zelf tekent.', + kindImageHint: 'Drukt het huidige etiket als afbeelding af op een gewone printer.', + kindPdfHint: 'Slaat het gerenderde etiket op als PDF-bestand.', + scopeBatch: 'Eén pagina per gegevensrij', + scopeDesign: "Alle pagina's", + imagePrint: 'Afdrukken', batchNoticeFmt: 'Drukt {n} etiketten af (één per gegevensrij).', batchNoticeQtyFmt: 'Drukt {n} etiketten af ({rows} rijen × {q} per rij).', tabNetwork: 'Netwerk (IP)', @@ -1862,6 +1870,12 @@ const nl = { errorGeneric: 'ZPL verzenden mislukt', httpsWarning: 'Pagina gebruikt HTTPS, direct afdrukken via IP kan worden geblokkeerd door de browser (gemengde inhoud).', tabUsb: 'USB', + wayNetwork: 'Stuurt rechtstreeks naar de printer via TCP. Hij leest ook terug, zodat status, voorbeeld en chip werken.', + wayNetworkWeb: 'Stuurt de code naar de printerpoort. De browser krijgt geen antwoord, dus niets leest terug.', + wayBrowserPrint: 'Loopt via Zebra Browser Print op de computer die de browser uitvoert.', + wayLocal: 'Geeft de taak als ruwe data door aan de wachtrij van het besturingssysteem. De wachtrij toont hem, maar niets leest terug.', + wayUsb: 'Stuurt rechtstreeks naar het aangesloten apparaat en leest terug.', + wayQueryAddress: 'De controle vraagt altijd via het printeradres.', usbPermissionDenied: 'Geen toegang tot de USB-printer. Klik op Toegang instellen om toegang te verlenen.', usbSetupAccess: 'Toegang instellen', usbSetupFlatpak: 'Binnen Flatpak kan de app de udev-regel niet zelf installeren. Installeer 70-zplab.rules uit de ZPLab-repository op de host en sluit de printer vervolgens opnieuw aan.', diff --git a/src/locales/no.ts b/src/locales/no.ts index b38b23db..8901be57 100644 --- a/src/locales/no.ts +++ b/src/locales/no.ts @@ -988,7 +988,6 @@ const no = { exportZpl: 'Export ZPL', menuInitError: 'Programmenyen kunne ikke lastes inn. Start appen på nytt.', importCsvData: 'Importer CSV-data', - sendToZebraBatchFmt: 'Send til Zebra ({n} etiketter)', exportBatchZplFmt: 'Eksporter batch-ZPL ({n} etiketter)', exportPdf: 'Eksporter PDF', exportBatchPdfFmt: 'Eksporter batch-PDF ({n} etiketter)', @@ -1011,8 +1010,7 @@ const no = { deletePageConfirm: 'Slette gjeldende side?', openDesign: 'Åpne design', saveDesign: 'Lagre design', - print: 'Skriv ut som bilde (nettleser)', - sendToZebra: 'Send til Zebra-skriver', + print: 'Skriv ut', propertiesTab: 'Egenskaper', layersTab: 'Lag', fontsTab: 'Skrifter', @@ -1839,6 +1837,16 @@ const no = { }, zebraPrint: { heading: 'Send til Zebra-skriver', + outputHeading: 'Utdata', + kindZpl: 'ZPL', + kindImage: 'Bilde', + kindPdf: 'PDF', + kindZplHint: 'Sender koden til skriveren, som tegner etiketten selv.', + kindImageHint: 'Skriver ut den aktuelle etiketten som bilde på en vanlig skriver.', + kindPdfHint: 'Lagrer den gjengitte etiketten som en PDF-fil.', + scopeBatch: 'Én side per datarad', + scopeDesign: 'Alle sider', + imagePrint: 'Skriv ut', batchNoticeFmt: 'Skriver ut {n} etiketter (én per datarad).', batchNoticeQtyFmt: 'Skriver ut {n} etiketter ({rows} rader × {q} per rad).', tabNetwork: 'Nettverk (IP)', @@ -1862,6 +1870,12 @@ const no = { errorGeneric: 'Sending av ZPL mislyktes', httpsWarning: 'Siden bruker HTTPS, direkte utskrift via IP kan bli blokkert av nettleseren (blandet innhold).', tabUsb: 'USB', + wayNetwork: 'Sender rett til skriveren over TCP. Den leser også tilbake, så status, forhåndsvisning og chip fungerer.', + wayNetworkWeb: 'Sender koden til skriverporten. Nettleseren får ikke svar, så ingenting leser tilbake.', + wayBrowserPrint: 'Går via Zebra Browser Print på datamaskinen som kjører nettleseren.', + wayLocal: 'Overlater jobben til operativsystemets kø som rådata. Køen viser den, men ingenting leser tilbake.', + wayUsb: 'Sender rett til den tilkoblede enheten og leser tilbake.', + wayQueryAddress: 'Kontrollen spør alltid via skriveradressen.', usbPermissionDenied: 'Ingen tilgang til USB-skriveren. Klikk på Konfigurer tilgang for å gi den.', usbSetupAccess: 'Konfigurer tilgang', usbSetupFlatpak: 'Inne i Flatpak kan appen ikke installere udev-regelen selv. Installer 70-zplab.rules fra ZPLab-repositoriet på verten, og koble deretter skriveren til på nytt.', diff --git a/src/locales/pl.ts b/src/locales/pl.ts index d79dc1dc..7dfeaa9a 100644 --- a/src/locales/pl.ts +++ b/src/locales/pl.ts @@ -988,7 +988,6 @@ const pl = { exportZpl: 'Export ZPL', menuInitError: 'Nie udało się załadować menu aplikacji. Uruchom aplikację ponownie.', importCsvData: 'Importuj dane CSV', - sendToZebraBatchFmt: 'Wyślij do Zebra ({n} etykiet)', exportBatchZplFmt: 'Eksportuj wsadowy ZPL ({n} etykiet)', exportPdf: 'Eksportuj PDF', exportBatchPdfFmt: 'Eksportuj wsadowy PDF ({n} etykiet)', @@ -1011,8 +1010,7 @@ const pl = { deletePageConfirm: 'Usunąć bieżącą stronę?', openDesign: 'Otwórz projekt', saveDesign: 'Zapisz projekt', - print: 'Drukuj jako obraz (przeglądarka)', - sendToZebra: 'Wyślij do drukarki Zebra', + print: 'Drukuj', propertiesTab: 'Właściwości', layersTab: 'Warstwy', fontsTab: 'Czcionki', @@ -1839,6 +1837,16 @@ const pl = { }, zebraPrint: { heading: 'Wyślij do drukarki Zebra', + outputHeading: 'Wydruk i eksport', + kindZpl: 'ZPL', + kindImage: 'Obraz', + kindPdf: 'PDF', + kindZplHint: 'Wysyła kod do drukarki, która sama rysuje etykietę.', + kindImageHint: 'Drukuje aktualną etykietę jako obraz na zwykłej drukarce.', + kindPdfHint: 'Zapisuje wyrenderowaną etykietę jako plik PDF.', + scopeBatch: 'Jedna strona na wiersz danych', + scopeDesign: 'Wszystkie strony', + imagePrint: 'Drukuj', batchNoticeFmt: 'Drukuje {n} etykiet (po jednej na wiersz danych).', batchNoticeQtyFmt: 'Drukuje {n} etykiet ({rows} wierszy × {q} na wiersz).', tabNetwork: 'Sieć (IP)', @@ -1862,6 +1870,12 @@ const pl = { errorGeneric: 'Błąd wysyłania ZPL', httpsWarning: 'Strona używa HTTPS, bezpośrednie drukowanie przez IP może zostać zablokowane przez przeglądarkę (mieszana treść).', tabUsb: 'USB', + wayNetwork: 'Wysyła bezpośrednio do drukarki przez TCP. Odpowiada też, dzięki czemu stan, podgląd i znacznik działają.', + wayNetworkWeb: 'Wysyła kod na port drukarki. Przeglądarka nie otrzymuje odpowiedzi, więc nic nie wraca.', + wayBrowserPrint: 'Przechodzi przez Zebra Browser Print na komputerze, na którym działa przeglądarka.', + wayLocal: 'Przekazuje zadanie do kolejki systemu operacyjnego jako surowe dane. Kolejka je pokazuje, ale nic nie odpowiada.', + wayUsb: 'Wysyła bezpośrednio do podłączonego urządzenia i odpowiada.', + wayQueryAddress: 'Sprawdzenie zawsze pyta przez adres drukarki.', usbPermissionDenied: 'Brak dostępu do drukarki USB. Kliknij Skonfiguruj dostęp, aby go przyznać.', usbSetupAccess: 'Skonfiguruj dostęp', usbSetupFlatpak: 'Wewnątrz Flatpaka aplikacja nie może sama zainstalować reguły udev. Zainstaluj 70-zplab.rules z repozytorium ZPLab na hoście, a następnie podłącz drukarkę ponownie.', diff --git a/src/locales/pt.ts b/src/locales/pt.ts index 0f98155c..e3c00bd1 100644 --- a/src/locales/pt.ts +++ b/src/locales/pt.ts @@ -988,7 +988,6 @@ const pt = { exportZpl: 'Export ZPL', menuInitError: 'Não foi possível carregar o menu do aplicativo. Reinicie o aplicativo.', importCsvData: 'Importar dados CSV', - sendToZebraBatchFmt: 'Enviar para Zebra ({n} etiquetas)', exportBatchZplFmt: 'Exportar ZPL em lote ({n} etiquetas)', exportPdf: 'Exportar PDF', exportBatchPdfFmt: 'Exportar PDF em lote ({n} etiquetas)', @@ -1011,8 +1010,7 @@ const pt = { deletePageConfirm: 'Eliminar a página atual?', openDesign: 'Abrir design', saveDesign: 'Salvar design', - print: 'Imprimir como imagem (navegador)', - sendToZebra: 'Enviar para impressora Zebra', + print: 'Imprimir', propertiesTab: 'Propriedades', layersTab: 'Camadas', fontsTab: 'Fontes', @@ -1839,6 +1837,16 @@ const pt = { }, zebraPrint: { heading: 'Enviar para impressora Zebra', + outputHeading: 'Impressão e exportação', + kindZpl: 'ZPL', + kindImage: 'Imagem', + kindPdf: 'PDF', + kindZplHint: 'Envia o código para a impressora, que desenha a etiqueta sozinha.', + kindImageHint: 'Imprime a etiqueta atual como imagem em uma impressora comum.', + kindPdfHint: 'Salva a etiqueta renderizada como um arquivo PDF.', + scopeBatch: 'Uma página por linha de dados', + scopeDesign: 'Todas as páginas', + imagePrint: 'Imprimir', batchNoticeFmt: 'Imprime {n} etiquetas (uma por linha de dados).', batchNoticeQtyFmt: 'Imprime {n} etiquetas ({rows} linhas × {q} cada).', tabNetwork: 'Rede (IP)', @@ -1862,6 +1870,12 @@ const pt = { errorGeneric: 'Falha ao enviar ZPL', httpsWarning: 'A página está em HTTPS, a impressão direta por IP pode ser bloqueada pelo navegador (conteúdo misto).', tabUsb: 'USB', + wayNetwork: 'Envia diretamente para a impressora por TCP. Também responde, então o status, a pré-visualização e o chip funcionam.', + wayNetworkWeb: 'Envia o código para a porta da impressora. O navegador não recebe resposta, então nada responde.', + wayBrowserPrint: 'Passa pelo Zebra Browser Print no computador que executa o navegador.', + wayLocal: 'Entrega o trabalho à fila do sistema operacional como dados brutos. A fila o mostra, mas nada responde.', + wayUsb: 'Envia diretamente para o dispositivo conectado e recebe resposta.', + wayQueryAddress: 'A verificação sempre consulta o endereço da impressora.', usbPermissionDenied: 'Sem acesso à impressora USB. Clique em Configurar acesso para conceder.', usbSetupAccess: 'Configurar acesso', usbSetupFlatpak: 'Dentro do Flatpak, a aplicação não pode instalar a regra udev por si própria. Instale o 70-zplab.rules a partir do repositório ZPLab no anfitrião e depois volte a ligar a impressora.', diff --git a/src/locales/ro.ts b/src/locales/ro.ts index cc6a48b8..8870723b 100644 --- a/src/locales/ro.ts +++ b/src/locales/ro.ts @@ -988,7 +988,6 @@ const ro = { exportZpl: 'Export ZPL', menuInitError: 'Meniul aplicației nu a putut fi încărcat. Reporniți aplicația.', importCsvData: 'Importă date CSV', - sendToZebraBatchFmt: 'Trimite la Zebra ({n} etichete)', exportBatchZplFmt: 'Exportă ZPL în lot ({n} etichete)', exportPdf: 'Exportă PDF', exportBatchPdfFmt: 'Exportă PDF în lot ({n} etichete)', @@ -1011,8 +1010,7 @@ const ro = { deletePageConfirm: 'Ștergeți pagina curentă?', openDesign: 'Deschide design', saveDesign: 'Salvează design', - print: 'Tipărire ca imagine (browser)', - sendToZebra: 'Trimite la imprimanta Zebra', + print: 'Imprimare', propertiesTab: 'Proprietăți', layersTab: 'Straturi', fontsTab: 'Fonturi', @@ -1839,6 +1837,16 @@ const ro = { }, zebraPrint: { heading: 'Trimite la imprimanta Zebra', + outputHeading: 'Imprimare și export', + kindZpl: 'ZPL', + kindImage: 'Imagine', + kindPdf: 'PDF', + kindZplHint: 'Trimite codul la imprimantă, care desenează eticheta singură.', + kindImageHint: 'Imprimă eticheta curentă ca imagine pe o imprimantă obișnuită.', + kindPdfHint: 'Salvează eticheta randată ca fișier PDF.', + scopeBatch: 'O pagină pe rând de date', + scopeDesign: 'Toate paginile', + imagePrint: 'Imprimă', batchNoticeFmt: 'Tipărește {n} etichete (una pe rând de date).', batchNoticeQtyFmt: 'Tipărește {n} etichete ({rows} rânduri × {q} pe rând).', tabNetwork: 'Rețea (IP)', @@ -1862,6 +1870,12 @@ const ro = { errorGeneric: 'Trimiterea ZPL a eşuat', httpsWarning: 'Pagina este pe HTTPS, imprimarea directă prin IP poate fi blocată de browser (conținut mixt).', tabUsb: 'USB', + wayNetwork: 'Trimite direct la imprimantă prin TCP. Citește și răspuns, așa că starea, previzualizarea și eticheta funcționează.', + wayNetworkWeb: 'Trimite codul la portul imprimantei. Browserul nu primește răspuns, așa că nimic nu se citește înapoi.', + wayBrowserPrint: 'Trece prin Zebra Browser Print pe computerul care rulează browserul.', + wayLocal: 'Predă sarcina cozii sistemului de operare ca date brute. Coada o arată, dar nimic nu citește răspuns.', + wayUsb: 'Trimite direct la dispozitivul conectat și citește răspuns.', + wayQueryAddress: 'Verificarea întreabă mereu prin adresa imprimantei.', usbPermissionDenied: 'Fără acces la imprimanta USB. Fă clic pe Configurează accesul pentru a-l acorda.', usbSetupAccess: 'Configurează accesul', usbSetupFlatpak: 'În interiorul Flatpak, aplicația nu poate instala regula udev singură. Instalează 70-zplab.rules din depozitul ZPLab pe sistemul-host, apoi reconectează imprimanta.', diff --git a/src/locales/sk.ts b/src/locales/sk.ts index 7e30258b..4cce3d1a 100644 --- a/src/locales/sk.ts +++ b/src/locales/sk.ts @@ -988,7 +988,6 @@ const sk = { exportZpl: 'Export ZPL', menuInitError: 'Ponuku aplikácie sa nepodarilo načítať. Reštartujte aplikáciu.', importCsvData: 'Importovať údaje CSV', - sendToZebraBatchFmt: 'Odoslať na Zebra ({n} etikiet)', exportBatchZplFmt: 'Exportovať dávkové ZPL ({n} etikiet)', exportPdf: 'Exportovať PDF', exportBatchPdfFmt: 'Exportovať dávkové PDF ({n} etikiet)', @@ -1011,8 +1010,7 @@ const sk = { deletePageConfirm: 'Odstrániť aktuálnu stránku?', openDesign: 'Otvoriť návrh', saveDesign: 'Uložiť návrh', - print: 'Tlač ako obrázok (prehliadač)', - sendToZebra: 'Odoslať na tlačiareň Zebra', + print: 'Tlačiť', propertiesTab: 'Vlastnosti', layersTab: 'Vrstvy', fontsTab: 'Písma', @@ -1839,6 +1837,16 @@ const sk = { }, zebraPrint: { heading: 'Odoslať na tlačiareň Zebra', + outputHeading: 'Výstup', + kindZpl: 'ZPL', + kindImage: 'Obrázok', + kindPdf: 'PDF', + kindZplHint: 'Odošle kód do tlačiarne, ktorá štítok vykreslí sama.', + kindImageHint: 'Vytlačí aktuálny štítok ako obrázok na bežnej tlačiarni.', + kindPdfHint: 'Uloží vykreslený štítok ako súbor PDF.', + scopeBatch: 'Jedna stránka na každý riadok údajov', + scopeDesign: 'Všetky stránky', + imagePrint: 'Vytlačiť', batchNoticeFmt: 'Vytlačí {n} etikiet (jednu na každý riadok údajov).', batchNoticeQtyFmt: 'Vytlačí {n} etikiet ({rows} riadkov × {q} na riadok).', tabNetwork: 'Sieť (IP)', @@ -1862,6 +1870,12 @@ const sk = { errorGeneric: 'Odoslanie ZPL zlyhalo', httpsWarning: 'Stránka beží cez HTTPS, priama tlač cez IP môže byť prehliadačom zablokovaná (zmiešaný obsah).', tabUsb: 'USB', + wayNetwork: 'Odosiela priamo do tlačiarne cez TCP. Zároveň číta odpoveď, takže stav, náhľad a štítok fungujú.', + wayNetworkWeb: 'Odosiela kód na port tlačiarne. Prehliadač nedostane odpoveď, takže sa nič nenačíta späť.', + wayBrowserPrint: 'Ide cez Zebra Browser Print na počítači, na ktorom beží prehliadač.', + wayLocal: 'Odovzdá úlohu do frontu operačného systému ako surové dáta. Front ju zobrazí, ale nič nečíta odpoveď.', + wayUsb: 'Odosiela priamo do pripojeného zariadenia a číta odpoveď.', + wayQueryAddress: 'Kontrola sa vždy pýta cez adresu tlačiarne.', usbPermissionDenied: 'Žiadny prístup k tlačiarni USB. Prístup udelíte kliknutím na Nastaviť prístup.', usbSetupAccess: 'Nastaviť prístup', usbSetupFlatpak: 'Vo Flatpaku si aplikácia nemôže pravidlo udev nainštalovať sama. Nainštalujte 70-zplab.rules z repozitára ZPLab na hostiteľský systém a potom znova pripojte tlačiareň.', diff --git a/src/locales/sl.ts b/src/locales/sl.ts index 0b2f8737..c95c3bbc 100644 --- a/src/locales/sl.ts +++ b/src/locales/sl.ts @@ -988,7 +988,6 @@ const sl = { exportZpl: 'Export ZPL', menuInitError: 'Menija aplikacije ni bilo mogoče naložiti. Znova zaženite aplikacijo.', importCsvData: 'Uvozi podatke CSV', - sendToZebraBatchFmt: 'Pošlji na Zebra ({n} etiket)', exportBatchZplFmt: 'Izvozi paketni ZPL ({n} etiket)', exportPdf: 'Izvozi PDF', exportBatchPdfFmt: 'Izvozi paketni PDF ({n} etiket)', @@ -1011,8 +1010,7 @@ const sl = { deletePageConfirm: 'Izbrišem trenutno stran?', openDesign: 'Odpri dizajn', saveDesign: 'Shrani dizajn', - print: 'Natisni kot sliko (brskalnik)', - sendToZebra: 'Pošlji na tiskalnik Zebra', + print: 'Natisni', propertiesTab: 'Lastnosti', layersTab: 'Plasti', fontsTab: 'Pisave', @@ -1839,6 +1837,16 @@ const sl = { }, zebraPrint: { heading: 'Pošlji na tiskalnik Zebra', + outputHeading: 'Tiskanje in izvoz', + kindZpl: 'ZPL', + kindImage: 'Slika', + kindPdf: 'PDF', + kindZplHint: 'Pošlje kodo tiskalniku, ki etiketo nariše sam.', + kindImageHint: 'Natisne trenutno etiketo kot sliko na navadnem tiskalniku.', + kindPdfHint: 'Shrani izrisano etiketo kot datoteko PDF.', + scopeBatch: 'Ena stran na vrstico podatkov', + scopeDesign: 'Vse strani', + imagePrint: 'Natisni', batchNoticeFmt: 'Natisne {n} etiket (eno na vrstico podatkov).', batchNoticeQtyFmt: 'Natisne {n} etiket ({rows} vrstic × {q} na vrstico).', tabNetwork: 'Omrežje (IP)', @@ -1862,6 +1870,12 @@ const sl = { errorGeneric: 'Pošiljanje ZPL ni uspelo', httpsWarning: 'Stran uporablja HTTPS, neposredno tiskanje prek IP-ja je morda blokirano s strani brskalnika (mešana vsebina).', tabUsb: 'USB', + wayNetwork: 'Pošlje neposredno na tiskalnik preko TCP. Prebere tudi odziv, zato stanje, predogled in značka delujejo.', + wayNetworkWeb: 'Pošlje kodo na vrata tiskalnika. Brskalnik ne prejme odziva, zato nič ne prebere nazaj.', + wayBrowserPrint: 'Poteka preko Zebra Browser Print na računalniku, ki poganja brskalnik.', + wayLocal: 'Preda nalogo v vrsto operacijskega sistema kot surove podatke. Vrsta jo prikaže, a nič ne prebere odziva.', + wayUsb: 'Pošlje neposredno na povezano napravo in prebere odziv.', + wayQueryAddress: 'Preverjanje vedno sprašuje preko naslova tiskalnika.', usbPermissionDenied: 'Ni dostopa do tiskalnika USB. Za dodelitev kliknite Nastavi dostop.', usbSetupAccess: 'Nastavi dostop', usbSetupFlatpak: 'Znotraj Flatpaka aplikacija ne more sama namestiti pravila udev. Namestite 70-zplab.rules iz repozitorija ZPLab na gostitelja in nato tiskalnik znova priključite.', diff --git a/src/locales/sr.ts b/src/locales/sr.ts index 08184edf..f7b7186d 100644 --- a/src/locales/sr.ts +++ b/src/locales/sr.ts @@ -988,7 +988,6 @@ const sr = { exportZpl: 'Export ZPL', menuInitError: 'Мени апликације није могао да се учита. Поново покрените апликацију.', importCsvData: 'Увоз CSV података', - sendToZebraBatchFmt: 'Пошаљи на Zebra ({n} етикета)', exportBatchZplFmt: 'Извоз групног ZPL-а ({n} етикета)', exportPdf: 'Извоз PDF-а', exportBatchPdfFmt: 'Извоз групног PDF-а ({n} етикета)', @@ -1011,8 +1010,7 @@ const sr = { deletePageConfirm: 'Обрисати тренутну страницу?', openDesign: 'Отвори дизајн', saveDesign: 'Сачувај дизајн', - print: 'Штампање као слика (прегледач)', - sendToZebra: 'Пошаљи на Zebra штампач', + print: 'Штампај', propertiesTab: 'Својства', layersTab: 'Слојеви', fontsTab: 'Фонтови', @@ -1839,6 +1837,16 @@ const sr = { }, zebraPrint: { heading: 'Пошаљи на Zebra штампач', + outputHeading: 'Штампање и извоз', + kindZpl: 'ZPL', + kindImage: 'Слика', + kindPdf: 'PDF', + kindZplHint: 'Шаље код штампачу, који сам исцртава етикету.', + kindImageHint: 'Штампа тренутну етикету као слику на обичном штампачу.', + kindPdfHint: 'Чува рендеровану етикету као PDF датотеку.', + scopeBatch: 'Једна страница за сваки ред података', + scopeDesign: 'Све странице', + imagePrint: 'Штампај', batchNoticeFmt: 'Штампа {n} етикета (по једну за сваки ред података).', batchNoticeQtyFmt: 'Штампа {n} етикета ({rows} редова × {q} по реду).', tabNetwork: 'Мрежа (IP)', @@ -1862,6 +1870,12 @@ const sr = { errorGeneric: 'Slanje ZPL-a nije uspelo', httpsWarning: 'Stranica koristi HTTPS, direktno štampanje putem IP može biti blokirano od strane pregledača (mešoviti sadržaj).', tabUsb: 'USB', + wayNetwork: 'Шаље директно штампачу преко TCP-а. Такође чита одговор, па статус, преглед и беџ раде.', + wayNetworkWeb: 'Шаље код на порт штампача. Прегледач не прима одговор, па ништа не чита назад.', + wayBrowserPrint: 'Иде преко Zebra Browser Print-а на рачунару који покреће прегледач.', + wayLocal: 'Предаје задатак реду операционог система као сирове податке. Ред га приказује, али ништа не чита одговор.', + wayUsb: 'Шаље директно на повезани уређај и чита одговор.', + wayQueryAddress: 'Провера увек пита преко адресе штампача.', usbPermissionDenied: 'Нема приступа USB штампачу. Кликните на Подеси приступ да бисте га омогућили.', usbSetupAccess: 'Подеси приступ', usbSetupFlatpak: 'Унутар Flatpak-а апликација не може сама да инсталира udev правило. Инсталирајте 70-zplab.rules из ZPLab репозиторијума на хост систем, затим поново прикључите штампач.', diff --git a/src/locales/sv.ts b/src/locales/sv.ts index e8034f98..0ee19ae6 100644 --- a/src/locales/sv.ts +++ b/src/locales/sv.ts @@ -988,7 +988,6 @@ const sv = { exportZpl: 'Export ZPL', menuInitError: 'Programmets meny kunde inte laddas. Starta om appen.', importCsvData: 'Importera CSV-data', - sendToZebraBatchFmt: 'Skicka till Zebra ({n} etiketter)', exportBatchZplFmt: 'Exportera batch-ZPL ({n} etiketter)', exportPdf: 'Exportera PDF', exportBatchPdfFmt: 'Exportera batch-PDF ({n} etiketter)', @@ -1011,8 +1010,7 @@ const sv = { deletePageConfirm: 'Ta bort aktuell sida?', openDesign: 'Öppna design', saveDesign: 'Spara design', - print: 'Skriv ut som bild (webbläsare)', - sendToZebra: 'Skicka till Zebra-skrivare', + print: 'Skriv ut', propertiesTab: 'Egenskaper', layersTab: 'Lager', fontsTab: 'Typsnitt', @@ -1839,6 +1837,16 @@ const sv = { }, zebraPrint: { heading: 'Skicka till Zebra-skrivare', + outputHeading: 'Utdata', + kindZpl: 'ZPL', + kindImage: 'Bild', + kindPdf: 'PDF', + kindZplHint: 'Skickar koden till skrivaren, som ritar etiketten själv.', + kindImageHint: 'Skriver ut den aktuella etiketten som en bild på en vanlig skrivare.', + kindPdfHint: 'Sparar den renderade etiketten som en PDF-fil.', + scopeBatch: 'En sida per datarad', + scopeDesign: 'Alla sidor', + imagePrint: 'Skriv ut', batchNoticeFmt: 'Skriver ut {n} etiketter (en per datarad).', batchNoticeQtyFmt: 'Skriver ut {n} etiketter ({rows} rader × {q} per rad).', tabNetwork: 'Nätverk (IP)', @@ -1862,6 +1870,12 @@ const sv = { errorGeneric: 'Det gick inte att skicka ZPL', httpsWarning: 'Sidan använder HTTPS, direktutskrift via IP kan blockeras av webbläsaren (blandat innehåll).', tabUsb: 'USB', + wayNetwork: 'Skickar direkt till skrivaren via TCP. Den läser också tillbaka, så status, förhandsgranskning och chip fungerar.', + wayNetworkWeb: 'Skickar koden till skrivarens port. Webbläsaren får inget svar, så inget läser tillbaka.', + wayBrowserPrint: 'Går via Zebra Browser Print på datorn som kör webbläsaren.', + wayLocal: 'Lämnar jobbet till operativsystemets kö som rådata. Kön visar det, men inget läser tillbaka.', + wayUsb: 'Skickar direkt till den anslutna enheten och läser tillbaka.', + wayQueryAddress: 'Kontrollen frågar alltid via skrivarens adress.', usbPermissionDenied: 'Ingen åtkomst till USB-skrivaren. Klicka på Konfigurera åtkomst för att ge den.', usbSetupAccess: 'Konfigurera åtkomst', usbSetupFlatpak: 'Inuti Flatpak kan appen inte installera udev-regeln själv. Installera 70-zplab.rules från ZPLab-förrådet på värden och anslut sedan skrivaren igen.', diff --git a/src/locales/tr.ts b/src/locales/tr.ts index e46409d4..97da3c70 100644 --- a/src/locales/tr.ts +++ b/src/locales/tr.ts @@ -988,7 +988,6 @@ const tr = { exportZpl: 'ZPL Dışa Aktar', menuInitError: 'Uygulama menüsü yüklenemedi. Lütfen uygulamayı yeniden başlatın.', importCsvData: 'CSV verisi içe aktar', - sendToZebraBatchFmt: "Zebra'ya gönder ({n} etiket)", exportBatchZplFmt: 'Toplu ZPL dışa aktar ({n} etiket)', exportPdf: 'PDF Dışa Aktar', exportBatchPdfFmt: 'Toplu PDF dışa aktar ({n} etiket)', @@ -1011,8 +1010,7 @@ const tr = { deletePageConfirm: 'Geçerli sayfa silinsin mi?', openDesign: 'Tasarım Aç', saveDesign: 'Tasarım Kaydet', - print: 'Görüntü olarak yazdır (tarayıcı)', - sendToZebra: 'Zebra yazıcıya gönder', + print: 'Yazdır', propertiesTab: 'Özellikler', layersTab: 'Katmanlar', fontsTab: 'Yazı Tipleri', @@ -1839,6 +1837,16 @@ const tr = { }, zebraPrint: { heading: 'Zebra yazıcıya gönder', + outputHeading: 'Çıktı', + kindZpl: 'ZPL', + kindImage: 'Resim', + kindPdf: 'PDF', + kindZplHint: 'Kodu yazıcıya gönderir ve yazıcı etiketi kendisi çizer.', + kindImageHint: 'Geçerli etiketi resim olarak sıradan bir yazıcıda yazdırır.', + kindPdfHint: 'Oluşturulan etiketi PDF dosyası olarak kaydeder.', + scopeBatch: 'Veri satırı başına bir sayfa', + scopeDesign: 'Tüm sayfalar', + imagePrint: 'Yazdır', batchNoticeFmt: '{n} etiket yazdırır (veri satırı başına bir).', batchNoticeQtyFmt: '{n} etiket yazdırır ({rows} satır × satır başına {q}).', tabNetwork: 'Ağ (IP)', @@ -1862,6 +1870,12 @@ const tr = { errorGeneric: 'ZPL gönderilemedi', httpsWarning: 'Sayfa HTTPS üzerinde, doğrudan IP yazdırma tarayıcı tarafından engellenebilir (karma içerik).', tabUsb: 'USB', + wayNetwork: 'TCP üzerinden doğrudan yazıcıya gönderir. Yanıtı da okur, böylece durum, önizleme ve rozet çalışır.', + wayNetworkWeb: 'Kodu yazıcı portuna gönderir. Tarayıcı yanıt almaz, bu yüzden hiçbir şey geri okunmaz.', + wayBrowserPrint: 'Tarayıcıyı çalıştıran bilgisayardaki Zebra Browser Print üzerinden gider.', + wayLocal: 'İşi ham veri olarak işletim sisteminin kuyruğuna teslim eder. Kuyruk onu gösterir, ama hiçbir şey yanıt okumaz.', + wayUsb: 'Bağlı aygıta doğrudan gönderir ve yanıt okur.', + wayQueryAddress: 'Kontrol her zaman yazıcı adresi üzerinden sorar.', usbPermissionDenied: "USB yazıcıya erişilemiyor. İzin vermek için Erişimi ayarla'ya tıklayın.", usbSetupAccess: 'Erişimi ayarla', usbSetupFlatpak: 'Flatpak içinde uygulama udev kuralını kendi başına yükleyemez. ZPLab deposundan 70-zplab.rules dosyasını ana makineye yükleyin, ardından yazıcıyı yeniden takın.', diff --git a/src/locales/zh-hans.ts b/src/locales/zh-hans.ts index 8a674f86..4086cba2 100644 --- a/src/locales/zh-hans.ts +++ b/src/locales/zh-hans.ts @@ -988,7 +988,6 @@ const zhHans = { exportZpl: '导出 ZPL', menuInitError: '无法加载应用程序菜单。请重新启动应用。', importCsvData: '导入 CSV 数据', - sendToZebraBatchFmt: '发送到 Zebra({n} 个标签)', exportBatchZplFmt: '导出批量 ZPL ({n} 个标签)', exportPdf: '导出 PDF', exportBatchPdfFmt: '导出批量 PDF ({n} 个标签)', @@ -1011,8 +1010,7 @@ const zhHans = { deletePageConfirm: '删除当前页面?', openDesign: '打开设计', saveDesign: '保存设计', - print: '打印为图片(浏览器)', - sendToZebra: '发送到 Zebra 打印机', + print: '打印', propertiesTab: '属性', layersTab: '图层', fontsTab: '字体', @@ -1839,6 +1837,16 @@ const zhHans = { }, zebraPrint: { heading: '发送到 Zebra 打印机', + outputHeading: '输出', + kindZpl: 'ZPL', + kindImage: '图像', + kindPdf: 'PDF', + kindZplHint: '将代码发送到打印机,由打印机自行绘制标签。', + kindImageHint: '在普通打印机上将当前标签打印为图像。', + kindPdfHint: '将渲染后的标签保存为 PDF 文件。', + scopeBatch: '每条数据行一页', + scopeDesign: '所有页', + imagePrint: '打印', batchNoticeFmt: '打印 {n} 个标签(每条数据行一个)。', batchNoticeQtyFmt: '打印 {n} 个标签({rows} 行 × 每行 {q} 个)。', tabNetwork: '网络 (IP)', @@ -1862,6 +1870,12 @@ const zhHans = { errorGeneric: '发送 ZPL 失败', httpsWarning: '页面在 HTTPS 上,浏览器可能阻止直接 IP 打印(混合内容)。', tabUsb: 'USB', + wayNetwork: '通过 TCP 直接发送到打印机。它也会回读,因此状态、预览和徽章都能正常使用。', + wayNetworkWeb: '将代码发送到打印机端口。浏览器收不到回复,因此不会回读。', + wayBrowserPrint: '通过运行浏览器的计算机上的 Zebra Browser Print 发送。', + wayLocal: '将任务以原始数据的形式交给操作系统的队列。队列会显示它,但没有任何回读。', + wayUsb: '直接发送到已连接的设备并回读。', + wayQueryAddress: '检查始终通过打印机地址查询。', usbPermissionDenied: '无法访问 USB 打印机。点击「设置访问权限」以授予权限。', usbSetupAccess: '设置访问权限', usbSetupFlatpak: '在 Flatpak 中,应用无法自行安装 udev 规则。请在主机上从 ZPLab 仓库安装 70-zplab.rules,然后重新插拔打印机。', diff --git a/src/locales/zh-hant.ts b/src/locales/zh-hant.ts index c03e23e2..61e98c2c 100644 --- a/src/locales/zh-hant.ts +++ b/src/locales/zh-hant.ts @@ -988,7 +988,6 @@ const zhHant = { exportZpl: '匯出 ZPL', menuInitError: '無法載入應用程式選單。請重新啟動應用程式。', importCsvData: '匯入 CSV 資料', - sendToZebraBatchFmt: '傳送至 Zebra({n} 個標籤)', exportBatchZplFmt: '匯出批次 ZPL ({n} 個標籤)', exportPdf: '匯出 PDF', exportBatchPdfFmt: '匯出批次 PDF ({n} 個標籤)', @@ -1011,8 +1010,7 @@ const zhHant = { deletePageConfirm: '刪除目前頁面?', openDesign: '開啟設計', saveDesign: '儲存設計', - print: '列印為圖片(瀏覽器)', - sendToZebra: '傳送至 Zebra 印表機', + print: '列印', propertiesTab: '屬性', layersTab: '圖層', fontsTab: '字體', @@ -1839,6 +1837,16 @@ const zhHant = { }, zebraPrint: { heading: '傳送至 Zebra 印表機', + outputHeading: '輸出', + kindZpl: 'ZPL', + kindImage: '圖像', + kindPdf: 'PDF', + kindZplHint: '將程式碼傳送至印表機,由印表機自行繪製標籤。', + kindImageHint: '在一般印表機上將目前標籤列印為圖像。', + kindPdfHint: '將渲染後的標籤儲存為 PDF 檔案。', + scopeBatch: '每筆資料一頁', + scopeDesign: '所有頁面', + imagePrint: '列印', batchNoticeFmt: '列印 {n} 個標籤(每筆資料一個)。', batchNoticeQtyFmt: '列印 {n} 個標籤({rows} 列 × 每列 {q} 個)。', tabNetwork: '網路 (IP)', @@ -1862,6 +1870,12 @@ const zhHant = { errorGeneric: '傳送 ZPL 失敗', httpsWarning: '頁面在 HTTPS 上,瀏覽器可能封鎖直接 IP 列印(混合內容)。', tabUsb: 'USB', + wayNetwork: '透過 TCP 直接傳送至印表機。它也會回讀,因此狀態、預覽和徽章都能正常運作。', + wayNetworkWeb: '將程式碼傳送至印表機連接埠。瀏覽器收不到回覆,因此不會回讀。', + wayBrowserPrint: '透過執行瀏覽器的電腦上的 Zebra Browser Print 傳送。', + wayLocal: '將工作以原始資料的形式交給作業系統的佇列。佇列會顯示它,但沒有任何回讀。', + wayUsb: '直接傳送至已連接的裝置並回讀。', + wayQueryAddress: '檢查永遠透過印表機位址查詢。', usbPermissionDenied: '無法存取 USB 印表機。點按「設定存取權限」以授予權限。', usbSetupAccess: '設定存取權限', usbSetupFlatpak: '在 Flatpak 中,應用程式無法自行安裝 udev 規則。請在主機上從 ZPLab 儲存庫安裝 70-zplab.rules,然後重新插拔印表機。', diff --git a/src/store/slices/uiSlice.ts b/src/store/slices/uiSlice.ts index ddcf6f25..a24fd53e 100644 --- a/src/store/slices/uiSlice.ts +++ b/src/store/slices/uiSlice.ts @@ -1,4 +1,5 @@ import type { StateCreator } from 'zustand'; +import { DEFAULT_OUTPUT_CHOICE, type OutputChoice, type OutputSource } from '../../lib/outputChoice'; import type { Unit } from '@zplab/core/lib/units'; import type { ViewRotation } from '@zplab/core/registry/rotation'; import { fallbackTranslations, loadLocale, type LocaleCode, type Translations } from '../../locales'; @@ -180,9 +181,11 @@ export interface UiSlice { /** Reference for the "Align" section toggle; see AlignSelectionRef. Transient. */ alignRef: AlignSelectionRef; printerSettingsTab: PrinterSettingsTab | null; - /** Cross-component trigger for the "send to Zebra" dialog. `null` - * keeps the dialog closed. */ - zebraPrintSource: 'label' | 'setupScript' | null; + /** Cross-component trigger for the output dialog. `null` keeps it closed. */ + zebraPrintSource: OutputSource | null; + /** What the dialog proposes when it opens again. Out of the persisted set on purpose: the user + * asked for a memory that lasts the session and starts neutral after a restart. */ + outputChoice: OutputChoice; /** Barcode object id whose GS1 content builder modal is open; null = closed. */ gs1BuilderObjectId: string | null; /** Object id whose typed-content builder modal is open; null = closed. @@ -250,8 +253,9 @@ export interface UiSlice { endRfidPositionPick: () => void; setAlignRef: (ref: AlignSelectionRef) => void; setPrinterSettingsTab: (tab: PrinterSettingsTab | null) => void; - openZebraPrint: (source: 'label' | 'setupScript') => void; + openZebraPrint: (source: OutputSource) => void; closeZebraPrint: () => void; + setOutputChoice: (patch: Partial) => void; openGs1Builder: (objectId: string) => void; closeGs1Builder: () => void; openContentBuilder: (objectId: string) => void; @@ -341,6 +345,7 @@ export const createUiSlice: StateCreator = (set, ge alignRef: 'selection', printerSettingsTab: null, zebraPrintSource: null, + outputChoice: DEFAULT_OUTPUT_CHOICE, gs1BuilderObjectId: null, contentBuilderObjectId: null, variableBuilderObjectId: null, @@ -524,6 +529,7 @@ export const createUiSlice: StateCreator = (set, ge setPrinterSettingsTab: (tab) => set({ printerSettingsTab: tab, pickingRfidPosition: false }), openZebraPrint: (source) => set({ zebraPrintSource: source }), closeZebraPrint: () => set({ zebraPrintSource: null }), + setOutputChoice: (patch) => set((state) => ({ outputChoice: { ...state.outputChoice, ...patch } })), openGs1Builder: (objectId) => set({ gs1BuilderObjectId: objectId }), closeGs1Builder: () => set({ gs1BuilderObjectId: null }), openContentBuilder: (objectId) => set({ contentBuilderObjectId: objectId }), diff --git a/src/test/zebraPrint.test.ts b/src/test/zebraPrint.test.ts index 5e656af4..a4bdc7cb 100644 --- a/src/test/zebraPrint.test.ts +++ b/src/test/zebraPrint.test.ts @@ -6,7 +6,6 @@ import { sendViaNetwork, type BrowserPrintDevice, } from "../lib/zebraPrint"; -import { buildPrintHtml, buildLoadingHtml } from "../lib/printPreview"; // ── helpers ────────────────────────────────────────────────────────────────── @@ -174,26 +173,3 @@ describe("sendViaNetwork", () => { }); }); }); - -// ── printPreview pure functions ─────────────────────────────────────────────── - -describe("buildPrintHtml", () => { - it("embeds the image URL as src", () => { - const html = buildPrintHtml("blob:http://localhost/abc"); - expect(html).toContain('src="blob:http://localhost/abc"'); - }); - - it("triggers window.print and window.close on image load", () => { - const html = buildPrintHtml("blob:x"); - expect(html).toContain("window.print()"); - expect(html).toContain("window.close()"); - }); -}); - -describe("buildLoadingHtml", () => { - it("returns a non-empty HTML string with loading indicator", () => { - const html = buildLoadingHtml(); - expect(html).toContain(" Date: Fri, 9 Oct 2026 16:51:14 +0200 Subject: [PATCH 2/2] feat(printer): the output dialog asks printer or file, not raster or vector --- PRIVACY.md | 2 +- README.md | 11 +- src/components/AppShell.tsx | 49 +-- src/components/Canvas/LabelCanvas.tsx | 25 +- src/components/Fonts/AddFontDialog.tsx | 4 +- src/components/Fonts/FontManager.tsx | 6 +- src/components/Output/DatasetNotice.tsx | 37 +++ .../Output/OutputDialog.notices.test.tsx | 104 ++++++ src/components/Output/OutputDialog.test.tsx | 301 +++++++++++++----- .../Output/OutputDialog.testkit.tsx | 56 ++++ src/components/Output/OutputDialog.tsx | 268 ++++++++++------ .../Output/OutputDialog.web.test.tsx | 43 +++ src/components/Output/PrinterChip.test.tsx | 2 +- src/components/Output/PrinterChip.tsx | 7 +- src/components/Output/SystemPrintBody.tsx | 23 ++ src/components/Output/ZPLOutput.tsx | 26 +- ....test.tsx => ZebraWayPanel.check.test.tsx} | 10 +- ...test.tsx => ZebraWayPanel.target.test.tsx} | 31 +- .../Output/ZebraWayPanel.testkit.tsx | 20 ++ .../{ZplSendPanel.tsx => ZebraWayPanel.tsx} | 210 +++++------- ...sb.test.tsx => ZebraWayPanel.usb.test.tsx} | 24 +- .../Output/ZplSendPanel.batchNotice.test.tsx | 92 ------ .../PrinterSettings/PrintTargetTab.tsx | 14 +- .../PrinterSettings/PrinterSettingsModal.tsx | 4 +- .../PrinterSettings/StoredFontsTab.tsx | 9 +- src/components/ui/SegmentedStrip.tsx | 37 +++ src/hooks/useLabelaryConsent.tsx | 32 ++ src/hooks/useNativeMenu.rebuild.test.tsx | 10 +- src/hooks/usePrintWays.ts | 34 ++ src/hooks/useSendNotices.ts | 18 ++ src/hooks/useStoredObjectHover.ts | 2 +- src/hooks/useZplImportExport.test.tsx | 2 +- src/hooks/useZplImportExport.ts | 52 ++- src/index.css | 6 +- src/lib/menuModel.test.ts | 42 ++- src/lib/menuModel.ts | 38 +-- src/lib/menuSignature.test.ts | 3 +- src/lib/outputChoice.test.ts | 88 +++-- src/lib/outputChoice.ts | 64 ++-- src/lib/printSheet.test.ts | 35 +- src/lib/printSheet.ts | 30 +- src/lib/printTarget.test.ts | 13 +- src/lib/printTarget.ts | 18 +- src/lib/printWayText.ts | 25 +- src/lib/saveLabelImage.ts | 13 + src/lib/sendZpl.test.ts | 51 +++ src/lib/sendZpl.ts | 23 ++ src/locales/ar.ts | 44 +-- src/locales/bg.ts | 44 +-- src/locales/cs.ts | 44 +-- src/locales/da.ts | 44 +-- src/locales/de.ts | 46 +-- src/locales/el.ts | 44 +-- src/locales/en.ts | 44 +-- src/locales/es.ts | 44 +-- src/locales/et.ts | 44 +-- src/locales/fa.ts | 44 +-- src/locales/fi.ts | 44 +-- src/locales/fr.ts | 44 +-- src/locales/he.ts | 44 +-- src/locales/hr.ts | 44 +-- src/locales/hu.ts | 44 +-- src/locales/it.ts | 44 +-- src/locales/ja.ts | 44 +-- src/locales/ko.ts | 44 +-- src/locales/lt.ts | 44 +-- src/locales/lv.ts | 44 +-- src/locales/nl.ts | 44 +-- src/locales/no.ts | 44 +-- src/locales/pl.ts | 44 +-- src/locales/pt.ts | 44 +-- src/locales/ro.ts | 44 +-- src/locales/sk.ts | 44 +-- src/locales/sl.ts | 44 +-- src/locales/sr.ts | 44 +-- src/locales/sv.ts | 44 +-- src/locales/tr.ts | 44 +-- src/locales/zh-hans.ts | 44 +-- src/locales/zh-hant.ts | 44 +-- src/store/slices/uiSlice.outputWay.test.ts | 24 ++ src/store/slices/uiSlice.ts | 27 +- src/test/privacyMenuPaths.test.ts | 13 + 82 files changed, 2166 insertions(+), 1322 deletions(-) create mode 100644 src/components/Output/DatasetNotice.tsx create mode 100644 src/components/Output/OutputDialog.notices.test.tsx create mode 100644 src/components/Output/OutputDialog.testkit.tsx create mode 100644 src/components/Output/OutputDialog.web.test.tsx create mode 100644 src/components/Output/SystemPrintBody.tsx rename src/components/Output/{ZplSendPanel.check.test.tsx => ZebraWayPanel.check.test.tsx} (83%) rename src/components/Output/{ZplSendPanel.target.test.tsx => ZebraWayPanel.target.test.tsx} (50%) create mode 100644 src/components/Output/ZebraWayPanel.testkit.tsx rename src/components/Output/{ZplSendPanel.tsx => ZebraWayPanel.tsx} (58%) rename src/components/Output/{ZplSendPanel.usb.test.tsx => ZebraWayPanel.usb.test.tsx} (78%) delete mode 100644 src/components/Output/ZplSendPanel.batchNotice.test.tsx create mode 100644 src/components/ui/SegmentedStrip.tsx create mode 100644 src/hooks/useLabelaryConsent.tsx create mode 100644 src/hooks/usePrintWays.ts create mode 100644 src/hooks/useSendNotices.ts create mode 100644 src/lib/saveLabelImage.ts create mode 100644 src/lib/sendZpl.test.ts create mode 100644 src/lib/sendZpl.ts create mode 100644 src/store/slices/uiSlice.outputWay.test.ts create mode 100644 src/test/privacyMenuPaths.test.ts diff --git a/PRIVACY.md b/PRIVACY.md index bc7f88b0..9bb3f99b 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -12,7 +12,7 @@ ZPLab is a label designer that runs on your device. It has no user accounts, no ZPLab only connects to the following destinations: - **Printers and data sources you configure.** Labels go to the printer you choose, over the network or USB, or through the system print spooler or the Zebra Browser Print agent on your computer. Variable data is read from the files, databases or servers you connect. -- **Labelary rendering.** Labelary is the default renderer for previews, **Print as Image** and PDF export. ZPLab asks for your consent before the first request to the public service. After that, each of these actions sends the label's ZPL to `api.labelary.com`, including values filled in from connected data sources. Labelary is an independent third-party service with its own [terms and privacy practices](https://labelary.com/service.html). If you set your own endpoint, requests go there instead. You can withdraw your consent or disable Labelary entirely under **File → Settings… → App → Preview**. In the desktop app, a connected printer can render instead. +- **Labelary rendering.** Labelary is the default renderer for previews, the **Other printer** way under **File → Output** and PDF export. ZPLab asks for your consent before the first request to the public service. After that, each of these actions sends the label's ZPL to `api.labelary.com`, including values filled in from connected data sources. Labelary is an independent third-party service with its own [terms and privacy practices](https://labelary.com/service.html). If you set your own endpoint, requests go there instead. You can withdraw your consent or disable Labelary entirely under **File → Settings… → App → Preview**. In the desktop app, a connected printer can render instead. - **Updates.** A build downloaded from GitHub checks `github.com` for a new version at every start and downloads an update from GitHub's release hosts only when you choose to install it. The Store version and other packaged installs update through their package and skip this check. - **MCP server, optional.** When you start it, the server listens on `127.0.0.1` only and requires a token that the app generates. An assistant you connect can read and edit the open label, so its content goes to whatever service that assistant uses. - **The web version.** Your browser loads `app.zplab.org` from GitHub Pages, which is covered by [GitHub's privacy statement](https://docs.github.com/site-policy/privacy-policies/github-general-privacy-statement). diff --git a/README.md b/README.md index 613d2d9f..053270ee 100644 --- a/README.md +++ b/README.md @@ -81,8 +81,9 @@ Select several objects with Shift-click or a lasso. Position and size changes ap The **ZPL** panel at the bottom shows the generated ZPL, **Copy** puts it on the clipboard. On desktop the panel names the configured printer. A click shows its state and offers to read its configuration. The canvas is ZPLab's rendering of the label. A second rendering comes from the preview renderer under **File → Settings… → App → Preview**: the connected printer (desktop) or, with consent, the third-party service [Labelary](https://labelary.com/). **Preview** shows it for comparison. - **File → Export ZPL:** saves a `.zpl` file, or `.prn` where the save dialog offers file types -- **File → Print:** opens the output dialog, where you choose what the label becomes. **ZPL** sends the code over the network, through the Zebra Browser Print agent in the browser, or through the system spooler or USB on desktop. On desktop, **Check printer** asks the printer for its state over the network or USB. **Image** renders the current label and prints it through the system print dialog. **PDF** saves the design as a file. -- **File → Export PDF:** saves one PDF page per label page, rendered by the preview renderer. With the renderer off, ZPLab captures the canvas, so the PDF holds the current page only. A batch PDF needs the renderer and holds one page per data row. +- **File → Output:** opens the output dialog with two destinations. + - **Print** sends the code to a Zebra over the network, through the Zebra Browser Print agent in the browser, or through the system spooler or USB on desktop. Its **Other printer** way renders the label and hands it to the operating system's print dialog, which reaches any printer with a driver. On desktop, **Check printer** asks the printer for its state over the network or USB. + - **File** writes a **PDF** with one page per label page, rendered by the preview renderer. With the renderer off, ZPLab captures the canvas, so the PDF holds the current page only. The **Scope** option **One label per data row** needs the renderer and holds one page per row. **PNG** saves the canvas drawing of the current label. ### 5. Save the design @@ -138,7 +139,7 @@ The **Fonts** tab uploads TrueType fonts and assigns their `^CW` alias. On the d ### Batch printing -**File → Import CSV data** (web) or **File → Connect data** (desktop) loads a CSV. Assign a column to each variable in the mapping dialog. **File → Export batch ZPL** or **File → Send to Zebra Printer** then produce one label per row. Export names the row count, the send item names the labels the printer will produce. +**File → Import CSV data** (web) or **File → Connect data** (desktop) loads a CSV. Assign a column to each variable in the mapping dialog. **File → Export batch ZPL** writes one label per row and names the row count. **File → Output** sends the same rows to the printer, and the dialog names the labels it will produce. The batch stores the page as a format on the printer and recalls it per row. A page that names a stored format keeps that name when the name itself is at most 8 characters. Longer names fall back to `R:LBL.ZPL`. @@ -150,11 +151,11 @@ On desktop, **Connect data** also reads an Excel worksheet. **File → Settings - The **Objects** group selects the fonts and graphics the **Setup Script** uploads. - It lists cached images and deletes the unused ones. -- On desktop it reads what the printer holds, so every row says where its file stands and a bar shows how full each drive is. Show a stored graphic or let the printer draw a font sample. Delete objects on the printer. +- On desktop it reads what the printer holds, so every row says where its file stands. A bar shows how full each drive is. Show a stored graphic or let the printer draw a font sample. Delete objects on the printer. - Saved designs and label exports exclude **Setup Script** values such as printer name and locale. - **Clear** resets the **Setup Script** values and keeps the uploads. -The **App → Printer** tab sets how labels are sent and to which printer. **Send to Zebra Printer** opens with that method selected. The **App → Preview** tab selects the renderer: the connected printer (desktop), Labelary, or off. It takes a premium Labelary endpoint and API key. The key is stored in the OS keychain on desktop and in browser storage on the web. +The **App → Printer** tab sets how labels are sent and to which printer. **File → Output** opens with that method selected. The **App → Preview** tab selects the renderer: the connected printer (desktop), Labelary, or off. It takes a premium Labelary endpoint and API key. The key is stored in the OS keychain on desktop and in browser storage on the web. ### MCP server diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index f2ebe854..94df63dc 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, type ComponentType, type SVGProps } from "react"; +import { useEffect, useRef, type ComponentType, type SVGProps } from "react"; import { DndContext, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; import { makePaletteCollision } from "../dnd/collision"; import { ObjectPalette } from "./Palette/ObjectPalette"; @@ -39,7 +39,7 @@ import { MoonIcon, GlobeAltIcon, } from "@heroicons/react/16/solid"; -import { useLabelStore, useHistory, selectLabelaryNoticeRequired, selectEffectivePreviewProvider, selectEditorFrozen, selectSourceEditing, selectSourceEditDirty, selectDocumentEmits } from "../store/labelStore"; +import { useLabelStore, useHistory, selectEffectivePreviewProvider, selectEditorFrozen, selectSourceEditing, selectSourceEditDirty, selectDocumentEmits } from "../store/labelStore"; import { datasetTimestamp } from "@zplab/core/types/DataSource"; import { isCurrentDataContext, settleDatasetReplace } from "../store/datasetActions"; import { ConfirmDialog } from "./ui/ConfirmDialog"; @@ -50,7 +50,6 @@ import type { MenuHandlers } from "../hooks/useNativeMenu"; import { isDesktopShell, isMacDesktop } from "../lib/platform"; import { acceptAttr, DESIGN_FILTER, CSV_FILTER } from "../lib/fileDialogs"; import { openExternal, REPO_URL } from "../lib/openExternal"; -import { LabelaryNoticeModal } from "./Output/LabelaryNoticeModal"; import { PdfExportDialog } from "./Output/PdfExportDialog"; import { usePdfExport } from "../hooks/usePdfExport"; import { PrinterSettingsModal } from "./PrinterSettings/PrinterSettingsModal"; @@ -79,13 +78,11 @@ const MENU_ICONS: Partial s.restoreReplacedDesign); const dismissReplacedDesign = useLabelStore((s) => s.dismissReplacedDesign); const sourceEditDirty = useLabelStore(selectSourceEditDirty); - const noticeRequired = useLabelStore(selectLabelaryNoticeRequired); const renderer = useLabelStore(selectEffectivePreviewProvider); const pageCount = useLabelStore((s) => s.pages.length); - // The Labelary notice gates every Labelary render, so the action waits here until the user continues. - const [afterNotice, setAfterNotice] = useState<(() => void) | null>(null); - // Hands back what the action returns, so a caller can wait for it. Nothing comes back while the - // notice is still open, because the work has not started yet. - const withNotice = (action: () => T): T | undefined => { - if (renderer === "labelary" && noticeRequired) { - setAfterNotice(() => () => void action()); - return undefined; - } - return action(); - }; const appUpdate = useLabelStore((s) => s.appUpdate); const checkForAppUpdate = useLabelStore((s) => s.checkForAppUpdate); const installAppUpdate = useLabelStore((s) => s.installAppUpdate); @@ -197,14 +182,15 @@ export function AppShell() { openZplImport, closeZplImport, outputSource, - openZebraPrint, - closeZebraPrint, + openOutput, + closeOutput, currentZpl, handleDownload, handleExportBatch, canBatchExport, batchRowCount, handlePrint, + handleExportPng, } = useZplImportExport(canvasRef); // The panel may grow until the main row has no height left. const mainRowRef = useRef(null); @@ -213,8 +199,7 @@ export function AppShell() { const rightPanel = useCollapsiblePanel("zpl-panel-right"); // One menu model for both surfaces: the DOM dropdown in the web header, and the native OS menu on - // the desktop, where the header row is not rendered at all. The output dialog reads the same - // flags, so its first level and these entries agree on what the document can produce. + // the desktop, where the header row is not rendered at all. const menuFlags: MenuFlags = { hasObjects, documentEmits, @@ -237,13 +222,11 @@ export function AppShell() { settings: () => setPrinterSettingsTab("appSettings"), exportZpl: handleDownload, exportBatch: handleExportBatch, - exportPdf: () => withNotice(exportPdf), - exportBatchPdf: () => withNotice(exportBatchPdf), openDesign: handleOpen, saveDesign: handleSave, importCsv: openCsvPicker, connectData: openConnectWizard, - print: openZebraPrint, + output: openOutput, undo: () => undo(), redo: () => redo(), github: () => openExternal(REPO_URL), @@ -575,18 +558,10 @@ export function AppShell() { zpl={currentZpl} source={outputSource} facts={menuFlags} - onClose={closeZebraPrint} - onPrintImage={() => withNotice(handlePrint)} - onExportPdf={(scope) => withNotice(scope === "batch" ? exportBatchPdf : exportPdf)} - /> - )} - {afterNotice && ( - setAfterNotice(null)} - onContinue={() => { - setAfterNotice(null); - afterNotice(); - }} + onClose={closeOutput} + onPrintImage={handlePrint} + onExportPdf={(scope) => (scope === "batch" ? exportBatchPdf() : exportPdf())} + onExportPng={handleExportPng} /> )} {pdfProgress && } diff --git a/src/components/Canvas/LabelCanvas.tsx b/src/components/Canvas/LabelCanvas.tsx index 4ffaff4b..c95a4f8e 100644 --- a/src/components/Canvas/LabelCanvas.tsx +++ b/src/components/Canvas/LabelCanvas.tsx @@ -85,7 +85,7 @@ import { zplForSelection } from "../../lib/zplForSelection"; import { finishZplExport } from "../../lib/exportZpl"; import { generateMultiPageZPL } from "@zplab/core/lib/zplGenerator"; import { captureLabelBlob, captureRegionBlob, copyPngToClipboard, CAPTURE_CHROME } from "../../lib/canvasImage"; -import { saveFile, saveErrorMessage, PNG_FILTER } from "../../lib/fileDialogs"; +import { saveLabelImage } from "../../lib/saveLabelImage"; import { printerPreviewLayout } from "../../lib/printerPreview"; /** Object types offered by the context menu's "Add object here". */ @@ -156,6 +156,11 @@ interface Props { onViewRotationChange: (rotation: ViewRotation) => void; } +/** Nothing drawn yet is nothing to capture, so the context menu and the output dialog get a click that + * does nothing. */ +const labelCapture = (group: Konva.Group | null, paper: Konva.Rect | null) => + group ? captureLabelBlob(group, paper) : Promise.resolve(null); + export interface LabelCanvasHandle { alignSelection: (op: AlignOp, ref: AlignRef) => void; distributeSelection: (axis: DistributeAxis) => void; @@ -163,6 +168,8 @@ export interface LabelCanvasHandle { convertObjectPositionType: (id: string, target: "FO" | "FT") => void; /** The label paper as drawn, at exactly the given dots, so a raster of it lines up with a printer render. */ captureLabelDots: (dots: { width: number; height: number }) => Promise; + /** The label as the canvas draws it, the capture behind the image export. */ + captureLabel: () => Promise; } export const LabelCanvas = forwardRef(function LabelCanvas({ @@ -257,8 +264,6 @@ export const LabelCanvas = forwardRef(function LabelCa clipboard, variables, pages, - setUserError, - clearUserError, } = useLabelStore(); // Everything on canvas is drawn in the current page's dot scale; // designLabel stays the MODEL's design scope for the whole-document emit. @@ -955,6 +960,7 @@ export const LabelCanvas = forwardRef(function LabelCa const group = rotationGroupRef.current; return group ? captureLabelBlob(group, labelPaperRef.current, { pixelRatio: label.dpmm / scale, dots }) : Promise.resolve(null); }, + captureLabel: () => labelCapture(rotationGroupRef.current, labelPaperRef.current), }; }, [updateObjects, updateObject, label.dpmm, scale], @@ -1260,7 +1266,7 @@ export const LabelCanvas = forwardRef(function LabelCa const switchTypeLocked = !!singleForSwitch && (!!singleForSwitch.locked || hasLockedAncestor(objects, singleForSwitch.id)); - const captureLabel = () => (rotationGroupRef.current ? captureLabelBlob(rotationGroupRef.current, labelPaperRef.current) : Promise.resolve(null)); + const captureLabel = () => labelCapture(rotationGroupRef.current, labelPaperRef.current); // The region comes from the model like the selection frame: groups have no node, a sample barcode's // node is chrome, and a line's node is its hit stroke. Frame px are stage px once the view rotation is 0. const captureSelection = () => { @@ -1279,13 +1285,6 @@ export const LabelCanvas = forwardRef(function LabelCa }); void copyPngToClipboard(blob).catch(() => undefined); }; - const exportImage = async (pending: Promise, filename: string) => { - const blob = await pending; - if (!blob) return; - await saveFile(blob, { filename, filters: [PNG_FILTER] }) - .then((wrote) => wrote && clearUserError()) - .catch(() => setUserError(saveErrorMessage)); - }; const dispatch = { copy: copySelectedObjects, cut: () => { @@ -1307,9 +1306,9 @@ export const LabelCanvas = forwardRef(function LabelCa void copyText(finishZplExport(generateMultiPageZPL(designLabel, pages, variables))); }, copyImageSelected: () => copyImage(captureSelection()), - exportImageSelected: () => exportImage(captureSelection(), `${(sel.length === 1 ? findObjectById(objects, sel[0] ?? "")?.type : undefined) ?? "selection"}.png`), + exportImageSelected: () => void saveLabelImage(captureSelection(), `${(sel.length === 1 ? findObjectById(objects, sel[0] ?? "")?.type : undefined) ?? "selection"}.png`), copyImageLabel: () => copyImage(captureLabel()), - exportImageLabel: () => exportImage(captureLabel(), "label.png"), + exportImageLabel: () => void saveLabelImage(captureLabel(), "label.png"), selectAll: () => selectObjects(objects.map((o) => o.id)), switchType: (type: string) => { if (singleForSwitch) { diff --git a/src/components/Fonts/AddFontDialog.tsx b/src/components/Fonts/AddFontDialog.tsx index 633a4814..e8ba925f 100644 --- a/src/components/Fonts/AddFontDialog.tsx +++ b/src/components/Fonts/AddFontDialog.tsx @@ -12,8 +12,8 @@ import { ariaDisabledCls, inputCls, labelCls } from '../ui/formStyles'; import { DialogShell } from '../ui/DialogShell'; interface AddFontDialogProps { - /** 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. */ + /** The picked bytes, undefined when the dialog closes without adding. The caller caches them, 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; diff --git a/src/components/Fonts/FontManager.tsx b/src/components/Fonts/FontManager.tsx index ea96cb0a..23b17843 100644 --- a/src/components/Fonts/FontManager.tsx +++ b/src/components/Fonts/FontManager.tsx @@ -118,6 +118,7 @@ export function FontManager() { }; // The alias has to meet a cache that already holds the file, or the row lands under the manual mappings. + // The alias is assigned here, so the user lands on a row already wired to ^CW and the canvas. const addPicked = async ({ path, bytes }: { path: string; bytes: Uint8Array }) => { await loadFontBytes(bytes, path); const taken = (customFonts ?? []).map((m) => m.alias).filter(Boolean); @@ -177,11 +178,6 @@ export function FontManager() { {adding && ( { - // 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 (picked) void addPicked(picked); setAdding(false); }} diff --git a/src/components/Output/DatasetNotice.tsx b/src/components/Output/DatasetNotice.tsx new file mode 100644 index 00000000..aaec3415 --- /dev/null +++ b/src/components/Output/DatasetNotice.tsx @@ -0,0 +1,37 @@ +import type { ReactNode } from "react"; +import { useT } from "../../hooks/useT"; +import { formatTemplate } from "../../lib/formatTemplate"; +import { useLabelStore, selectBatchInputs, selectBatchPrintCount } from "../../store/labelStore"; + +const NoticeLine = ({ children }: { children: ReactNode }) => ( +

{children}

+); + +export function DatasetNotice() { + const t = useT(); + const rows = useLabelStore((s) => selectBatchInputs(s)?.dataset.rows.length ?? null); + // A per-label ^PQ multiplies every recall, so the honest count is rows times quantity. + const printQuantity = useLabelStore((s) => s.label.printQuantity ?? 1); + const batchCount = useLabelStore(selectBatchPrintCount); + if (rows === null) return null; + return ( + + {printQuantity > 1 + ? formatTemplate(t.zebraPrint.batchNoticeQtyFmt, { n: String(batchCount), rows: String(rows), q: String(printQuantity) }) + : formatTemplate(t.zebraPrint.batchNoticeFmt, { n: String(rows) })} + + ); +} + +/** A mapped dataset reaches the system way as the active row alone. */ +export function ActiveRowNotice() { + const t = useT(); + const rows = useLabelStore((s) => selectBatchInputs(s)?.dataset.rows.length ?? null); + const activeRowIndex = useLabelStore((s) => s.dataset?.activeRowIndex ?? 0); + if (rows === null) return null; + return ( + + {formatTemplate(t.zebraPrint.systemActiveRowOnlyFmt, { n: String(activeRowIndex + 1), rows: String(rows) })} + + ); +} diff --git a/src/components/Output/OutputDialog.notices.test.tsx b/src/components/Output/OutputDialog.notices.test.tsx new file mode 100644 index 00000000..1ddf85cc --- /dev/null +++ b/src/components/Output/OutputDialog.notices.test.tsx @@ -0,0 +1,104 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; +import { cleanup, act } from "@testing-library/react"; +import { useLabelStore } from "../../store/labelStore"; +import { formatTemplate } from "../../lib/formatTemplate"; +import { fallbackTranslations as en } from "../../locales"; +import { BASE_STATE, loc, showDialog } from "./OutputDialog.testkit"; + +vi.mock("../../lib/platform", async (importOriginal) => ({ ...(await importOriginal>()), isDesktopShell: true })); +vi.mock("../../lib/localPrint", async (importOriginal) => ({ + ...(await importOriginal>()), + listLocalPrinters: vi.fn().mockResolvedValue([]), +})); +vi.mock("../../lib/usbPrint", async (importOriginal) => ({ + ...(await importOriginal>()), + listUsbPrinters: vi.fn().mockResolvedValue([]), +})); + +afterEach(cleanup); + +const dataset = { + headers: ["sku"], + rows: [["A1"], ["B2"], ["C3"]], + source: { kind: "csv" as const, filename: "t.csv", importedAt: "", encoding: "utf-8", delimiter: ",", rowCount: 3 }, + activeRowIndex: 0, +}; +const variables = [{ id: "v1", name: "sku", fnNumber: 1, defaultValue: "" }]; +const columnMapping = { bindings: { v1: "sku" }, headerSnapshot: ["sku"] }; +const text = { id: "t", type: "text", x: 10, y: 10, rotation: 0, props: { content: "A", fontHeight: 30 } } as never; + +const batchNotice = () => formatTemplate(loc.batchNoticeFmt, { n: "3" }); + +beforeEach(() => { + act(() => { + useLabelStore.setState({ + ...BASE_STATE, + outputChoice: { kind: "print", printWay: null, fileFormat: "pdf", pdfScope: "design" }, + previewProvider: "printer", + pages: [{ objects: [text] }], + currentPageIndex: 0, + variables: [], + label: { widthMm: 50, heightMm: 30, dpmm: 8 }, + }); + }); +}); + +describe("the notices above the ways", () => { + it("counts the labels a mapped dataset prints", () => { + act(() => { + useLabelStore.setState({ dataset, columnMapping, variables }); + }); + expect(showDialog().container.textContent).toContain(batchNotice()); + }); + + it("multiplies by the per-label print quantity, because ^PQ rides every recall", () => { + act(() => { + useLabelStore.setState({ dataset, columnMapping, variables, label: { widthMm: 50, heightMm: 30, dpmm: 8, printQuantity: 4 } }); + }); + const expected = formatTemplate(loc.batchNoticeQtyFmt, { n: "12", rows: "3", q: "4" }); + + expect(showDialog().container.textContent).toContain(expected); + }); + + it("names the format a batch job stores, because the job carries its own template", () => { + act(() => { + useLabelStore.setState({ dataset, columnMapping, variables }); + }); + expect(showDialog().container.textContent).toContain( + formatTemplate(en.output.storesSomeFormatFmt, { path: "R:LBL.ZPL" }), + ); + }); + + it("says nothing about the dataset for a setup script", () => { + act(() => { + useLabelStore.setState({ dataset, columnMapping, variables }); + }); + expect(showDialog({ source: "setupScript" }).container.textContent).not.toContain(batchNotice()); + }); + + it("names what the document stores on the printer, from the document and not from the job" , () => { + act(() => { + useLabelStore.setState({ pages: [{ objects: [text], storedFormatPath: "R:LBL.ZPL" }] }); + }); + const r = showDialog({ zpl: () => "^XA^XZ" }); + + expect(r.container.textContent).toContain(formatTemplate(en.output.storesFormatFmt, { path: "R:LBL.ZPL" })); + }); + + it("names a page that only recalls its format", () => { + act(() => { + useLabelStore.setState({ pages: [{ objects: [text], storedFormatPath: "E:JOB.ZPL", storedFormatDelivery: "printer" }] }); + }); + expect(showDialog().container.textContent).toContain(formatTemplate(en.output.recallOnlyPrinterFmt, { path: "E:JOB.ZPL" })); + }); + + it("leaves the printer's own settings unremarked", () => { + act(() => { + useLabelStore.setState({ pages: [{ objects: [text], storedFormatPath: "R:LBL.ZPL" }] }); + }); + const r = showDialog({ source: "setupScript" }); + + expect(r.container.textContent).not.toContain(formatTemplate(en.output.storesFormatFmt, { path: "R:LBL.ZPL" })); + }); +}); diff --git a/src/components/Output/OutputDialog.test.tsx b/src/components/Output/OutputDialog.test.tsx index 5db32553..aeb05182 100644 --- a/src/components/Output/OutputDialog.test.tsx +++ b/src/components/Output/OutputDialog.test.tsx @@ -1,156 +1,165 @@ // @vitest-environment jsdom import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; -import { render, cleanup, act, fireEvent } from "@testing-library/react"; -import { OutputDialog } from "./OutputDialog"; +import { cleanup, act, fireEvent } from "@testing-library/react"; import { useLabelStore } from "../../store/labelStore"; -import { DEFAULT_OUTPUT_CHOICE, type OutputFacts } from "../../lib/outputChoice"; -import { DEFAULT_PRINT_TARGET } from "../../lib/printTarget"; import { fallbackTranslations as en } from "../../locales"; +import { formatTemplate } from "../../lib/formatTemplate"; +import { BASE_STATE, batchScope, everything, loc, printButton, showDialog } from "./OutputDialog.testkit"; -afterEach(cleanup); - -const loc = en.zebraPrint; +// The printer ways and the printer render only exist in the desktop shell. +vi.mock("../../lib/platform", async (importOriginal) => ({ ...(await importOriginal>()), isDesktopShell: true })); +vi.mock("../../lib/localPrint", async (importOriginal) => ({ + ...(await importOriginal>()), + listLocalPrinters: vi.fn().mockResolvedValue([]), +})); +vi.mock("../../lib/usbPrint", async (importOriginal) => ({ + ...(await importOriginal>()), + listUsbPrinters: vi.fn().mockResolvedValue([]), +})); -const everything: OutputFacts = { - hasObjects: true, - documentEmits: true, - sourceEditing: false, - canBatchExport: true, - canBatchPdf: true, - pdfCurrentPageOnly: false, - batchRowCount: 3, -}; +afterEach(cleanup); beforeEach(() => { act(() => { useLabelStore.setState({ - zebraPrintSource: "label", - outputChoice: DEFAULT_OUTPUT_CHOICE, - printTarget: { ...DEFAULT_PRINT_TARGET, host: "172.17.17.175" }, + ...BASE_STATE, + outputChoice: { kind: "print", printWay: null, fileFormat: "pdf", pdfScope: "design" }, + previewProvider: "printer", }); }); }); -const show = (over: Partial[0]> = {}) => { - const props = { - zpl: () => "^XA^XZ", - source: "label" as const, - facts: everything, - onClose: vi.fn(), - onPrintImage: vi.fn(() => undefined), - onExportPdf: vi.fn(), - ...over, - }; - return { ...render(), props }; -}; - describe("OutputDialog", () => { - it("opens on the ways to the printer and offers the two other kinds", () => { - const r = show(); + it("opens on the ways to the printer and offers the file next to them", () => { + const r = showDialog(); - expect(r.getByText(loc.kindImage)).toBeTruthy(); - expect(r.getByText(loc.kindPdf)).toBeTruthy(); - // The ZPL kind shows the send panel, address field and all. + expect(r.getByText(loc.kindFile)).toBeTruthy(); + expect(r.getByText(loc.tabSystem)).toBeTruthy(); expect(r.getByDisplayValue("172.17.17.175")).toBeTruthy(); }); + it("marks the kind showing as the current one", () => { + const r = showDialog(); + + expect(r.getByText(loc.kindFile).getAttribute("aria-current")).toBeNull(); + expect(r.getAllByText(loc.kindPrint)[0]!.getAttribute("aria-current")).toBe("page"); + }); + it("proposes the kind chosen last when it opens again", () => { - const first = show(); + const first = showDialog(); act(() => { - fireEvent.click(first.getByText(loc.kindPdf)); + fireEvent.click(first.getByText(loc.kindFile)); }); - expect(useLabelStore.getState().outputChoice.kind).toBe("pdf"); + expect(useLabelStore.getState().outputChoice.kind).toBe("file"); cleanup(); - expect(show().getByText(en.app.exportPdf)).toBeTruthy(); + expect(showDialog().getByText(en.app.exportPdf)).toBeTruthy(); }); it("puts a setup script on the printer and leaves nothing to choose", () => { - const r = show({ source: "setupScript" }); + const r = showDialog({ source: "setupScript" }); - // Printer settings are nothing to put on paper, so the first level has one entry and stays away. - expect(r.queryByText(loc.kindImage)).toBeNull(); - expect(r.queryByText(loc.kindPdf)).toBeNull(); + expect(r.queryByText(loc.kindFile)).toBeNull(); + // A setup script draws nothing, so the way that renders is not on offer either. + expect(r.queryByText(loc.tabSystem)).toBeNull(); expect(r.getByText(loc.heading)).toBeTruthy(); }); - it("hides the rendered kinds for a document with nothing to draw", () => { - // A config-only overlay emits code but renders a blank sheet, as the menu entries already say. - const r = show({ facts: { ...everything, hasObjects: false } }); + it("titles itself as the menu entry names it", () => { + expect(showDialog().getByText(loc.outputHeading)).toBeTruthy(); + }); + + it("hides what needs drawing for a document with nothing to draw", () => { + const r = showDialog({ facts: { ...everything, hasObjects: false } }); - expect(r.queryByText(loc.kindImage)).toBeNull(); - expect(r.queryByText(loc.kindPdf)).toBeNull(); + expect(r.queryByText(loc.kindFile)).toBeNull(); + expect(r.queryByText(loc.tabSystem)).toBeNull(); expect(r.getByDisplayValue("172.17.17.175")).toBeTruthy(); }); it("offers the batch only where a dataset and a renderer meet", () => { act(() => { - useLabelStore.getState().setOutputChoice({ kind: "pdf" }); + useLabelStore.getState().setOutputChoice({ kind: "file" }); }); - expect(show().getByText(loc.scopeBatch)).toBeTruthy(); + expect(showDialog().getByText(batchScope(3))).toBeTruthy(); cleanup(); // Without a renderer the batch export returns without a file, so it is not on offer. - const alone = show({ facts: { ...everything, canBatchPdf: false } }); - expect(alone.queryByText(loc.scopeBatch)).toBeNull(); - expect(alone.queryByText(loc.scopeDesign)).toBeNull(); + const alone = showDialog({ facts: { ...everything, canBatchPdf: false } }); + expect(alone.queryByText(batchScope(3))).toBeNull(); + // One scope is no choice, so nothing asks for it. + expect(alone.queryByText(loc.scopeHeading)).toBeNull(); + expect(alone.container.querySelector("input[type=radio]")).toBeNull(); }); it("keeps a batch it cannot offer, so a dataset brings the choice back", () => { act(() => { - useLabelStore.getState().setOutputChoice({ kind: "pdf", pdfScope: "batch" }); + useLabelStore.getState().setOutputChoice({ kind: "file", pdfScope: "batch" }); }); - show({ facts: { ...everything, canBatchExport: false } }); + showDialog({ facts: { ...everything, canBatchExport: false } }); expect(useLabelStore.getState().outputChoice.pdfScope).toBe("batch"); }); it("names on the button what the export covers", () => { act(() => { - useLabelStore.getState().setOutputChoice({ kind: "pdf" }); + useLabelStore.getState().setOutputChoice({ kind: "file" }); }); - expect(show({ facts: { ...everything, pdfCurrentPageOnly: true } }).getByText(en.app.exportPdfCurrentPage)).toBeTruthy(); + expect(showDialog({ facts: { ...everything, pdfCurrentPageOnly: true } }).getByText(en.app.exportPdfCurrentPage)).toBeTruthy(); }); - it("commits the user to as many pages as the menu entry would", () => { + it("names the ten thousand pages on the scope, not on the button", () => { act(() => { - useLabelStore.getState().setOutputChoice({ kind: "pdf", pdfScope: "batch" }); + useLabelStore.getState().setOutputChoice({ kind: "file", pdfScope: "batch" }); }); - const r = show({ facts: { ...everything, batchRowCount: 10_000 } }); + const r = showDialog({ facts: { ...everything, batchRowCount: 10_000 } }); - // A ten thousand page render may not start without saying so, as the menu entry does not. - expect(r.getByText(en.app.exportBatchPdfFmt.replace("{n}", "10000"))).toBeTruthy(); + // A ten thousand page render may not start without saying so. + expect(r.getByText(batchScope(10_000))).toBeTruthy(); + expect(r.getByText(en.app.exportPdf)).toBeTruthy(); }); - it("steps aside at once when the work has not started", () => { - const r = show(); + it("asks for consent before the third-party render and stays open meanwhile", async () => { act(() => { - fireEvent.click(r.getByText(loc.kindImage)); + useLabelStore.setState({ previewProvider: "labelary", labelaryNoticeAcknowledged: false }); }); + const r = showDialog(); act(() => { - fireEvent.click(r.getByText(loc.imagePrint)); + fireEvent.click(r.getByText(loc.tabSystem)); + }); + act(() => { + fireEvent.click(printButton(r.container)); }); - // Nothing came back, so a notice is waiting to be answered and this modal is in its way. - expect(r.props.onClose).toHaveBeenCalled(); + // The waiting state must be the same on the first print as on every later one. + expect(r.getByText(en.output.previewNoticeTitle)).toBeTruthy(); + expect(r.props.onClose).not.toHaveBeenCalled(); + expect(r.props.onPrintImage).not.toHaveBeenCalled(); + + await act(async () => { + fireEvent.click(r.getByText(en.output.previewNoticeAcknowledge)); + }); expect(r.props.onPrintImage).toHaveBeenCalled(); + expect(useLabelStore.getState().labelaryNoticeAcknowledged).toBe(true); }); - it("stays and shows the wait while the label renders", async () => { + it("stays and says that the label is rendering", async () => { let finish: () => void = () => undefined; const onPrintImage = vi.fn(() => new Promise((resolve) => (finish = resolve))); - const r = show({ onPrintImage }); + const r = showDialog({ onPrintImage }); act(() => { - fireEvent.click(r.getByText(loc.kindImage)); + fireEvent.click(r.getByText(loc.tabSystem)); }); act(() => { - fireEvent.click(r.getByText(loc.imagePrint)); + fireEvent.click(printButton(r.container)); }); // Several seconds over the network, and a closed dialog would leave the click unanswered. expect(r.props.onClose).not.toHaveBeenCalled(); - expect((r.getByText(loc.imagePrint) as HTMLButtonElement).disabled).toBe(true); - expect(r.container.querySelector(".animate-pulse")).toBeTruthy(); + expect(printButton(r.container).disabled).toBe(true); + const line = r.getByText(loc.systemRendering); + expect(line.getAttribute("aria-live")).toBe("polite"); + expect(r.container.querySelector("[aria-busy]")).toBeTruthy(); await act(async () => { finish(); @@ -158,31 +167,151 @@ describe("OutputDialog", () => { expect(r.props.onClose).toHaveBeenCalled(); }); + it("locks the system way's print only while a read holds the channel the render needs", () => { + act(() => { + useLabelStore.setState({ printerReading: "~HI" }); + }); + const r = showDialog(); + act(() => { + fireEvent.click(r.getByText(loc.tabSystem)); + }); + expect(printButton(r.container).disabled).toBe(true); + + cleanup(); + // A Labelary render never touches the printer, so a read in flight is none of its business. + act(() => { + useLabelStore.setState({ previewProvider: "labelary", printerReading: "~HI" }); + }); + const web = showDialog(); + act(() => { + fireEvent.click(web.getByText(loc.tabSystem)); + }); + expect(printButton(web.container).disabled).toBe(false); + }); + + it("keeps the system way out of the address form it has no use for", () => { + const r = showDialog(); + act(() => { + fireEvent.click(r.getByText(loc.tabSystem)); + }); + + expect(r.queryByDisplayValue("172.17.17.175")).toBeNull(); + expect(r.getByText(loc.tabSystem).getAttribute("aria-current")).toBe("page"); + expect(useLabelStore.getState().printTarget.transport).toBe("network"); + }); + + it("writes the Zebra way to the print target and leaves it alone for the system way", () => { + // USB is not offered in this build, so any write the strip makes to the target would show. + act(() => { + useLabelStore.getState().setPrintTarget({ transport: "usb" }); + }); + const r = showDialog(); + act(() => { + fireEvent.click(r.getByText(loc.tabSystem)); + }); + expect(useLabelStore.getState().outputChoice.printWay).toBe("system"); + expect(useLabelStore.getState().printTarget.transport).toBe("usb"); + + act(() => { + fireEvent.click(r.getByText(loc.tabNetwork)); + }); + expect(useLabelStore.getState().outputChoice.printWay).toBeNull(); + expect(useLabelStore.getState().printTarget.transport).toBe("network"); + }); + it("hands the PDF export the scope that is showing", () => { act(() => { - useLabelStore.getState().setOutputChoice({ kind: "pdf" }); + useLabelStore.getState().setOutputChoice({ kind: "file" }); }); - const r = show(); + const r = showDialog(); act(() => { - fireEvent.click(r.getByText(loc.scopeBatch)); + fireEvent.click(r.getByText(batchScope(3))); }); act(() => { - fireEvent.click(r.getByText(en.app.exportBatchPdfFmt.replace("{n}", "3"))); + fireEvent.click(r.getByText(en.app.exportPdf)); }); expect(r.props.onExportPdf).toHaveBeenCalledWith("batch"); }); - it("reads the code only for the kind that sends it", () => { + it("saves a PNG from the canvas and remembers the format", () => { + const r = showDialog(); + act(() => { + fireEvent.click(r.getByText(loc.kindFile)); + }); + act(() => { + fireEvent.click(r.getByText(loc.formatPng)); + }); + // The PDF scope is the PDF's own question, so the PNG body does not ask it. + expect(r.queryByText(batchScope(3))).toBeNull(); + + act(() => { + fireEvent.click(r.getByText(en.app.exportPng)); + }); + expect(r.props.onExportPng).toHaveBeenCalled(); + expect(r.props.onClose).toHaveBeenCalled(); + expect(useLabelStore.getState().outputChoice.fileFormat).toBe("png"); + }); + + it("says on the system way which row of a mapped dataset prints, and names no batch total", () => { + const activeRowLine = (n: string, rows: string) => formatTemplate(loc.systemActiveRowOnlyFmt, { n, rows }); + act(() => { + useLabelStore.getState().setOutputChoice({ printWay: "system" }); + }); + expect(showDialog().container.textContent).not.toContain(activeRowLine("1", "2")); + + cleanup(); + act(() => { + useLabelStore.setState({ + dataset: { + headers: ["sku"], + rows: [["A1"], ["B2"]], + source: { kind: "csv", filename: "t.csv", importedAt: "", encoding: "utf-8", delimiter: ",", rowCount: 2 }, + activeRowIndex: 1, + }, + columnMapping: { bindings: { v1: "sku" }, headerSnapshot: ["sku"] }, + variables: [{ id: "v1", name: "sku", fnNumber: 1, defaultValue: "" }], + }); + }); + const shown = showDialog().container.textContent; + + expect(shown).toContain(activeRowLine("2", "2")); + expect(shown).not.toContain(formatTemplate(loc.batchNoticeFmt, { n: "2" })); + }); + + it("keeps the batch total on a Zebra way, which sends every row", () => { + act(() => { + useLabelStore.setState({ + dataset: { + headers: ["sku"], + rows: [["A1"], ["B2"]], + source: { kind: "csv", filename: "t.csv", importedAt: "", encoding: "utf-8", delimiter: ",", rowCount: 2 }, + activeRowIndex: 0, + }, + columnMapping: { bindings: { v1: "sku" }, headerSnapshot: ["sku"] }, + variables: [{ id: "v1", name: "sku", fnNumber: 1, defaultValue: "" }], + }); + }); + expect(showDialog().container.textContent).toContain(formatTemplate(loc.batchNoticeFmt, { n: "2" })); + }); + + it("reads the code only where it is sent", () => { const zpl = vi.fn(() => "^XA^XZ"); - const r = show({ zpl }); - expect(zpl).toHaveBeenCalled(); + const r = showDialog({ zpl }); + expect(zpl).not.toHaveBeenCalled(); - zpl.mockClear(); act(() => { - fireEvent.click(r.getByText(loc.kindPdf)); + fireEvent.click(r.getByText(loc.kindFile)); }); - // A mapped dataset regenerates the whole batch, which the PDF and image bodies never read. expect(zpl).not.toHaveBeenCalled(); + + cleanup(); + act(() => { + useLabelStore.getState().setOutputChoice({ kind: "print", printWay: "system" }); + }); + const system = vi.fn(() => "^XA^XZ"); + showDialog({ zpl: system }); + + expect(system).not.toHaveBeenCalled(); }); }); diff --git a/src/components/Output/OutputDialog.testkit.tsx b/src/components/Output/OutputDialog.testkit.tsx new file mode 100644 index 00000000..b5bf50bc --- /dev/null +++ b/src/components/Output/OutputDialog.testkit.tsx @@ -0,0 +1,56 @@ +import { render } from "@testing-library/react"; +import { vi } from "vitest"; +import { OutputDialog } from "./OutputDialog"; +import { type OutputFacts } from "../../lib/outputChoice"; +import { DEFAULT_PRINT_TARGET } from "../../lib/printTarget"; +import { fallbackTranslations as en } from "../../locales"; + +export const loc = en.zebraPrint; + +export const everything: OutputFacts = { + hasObjects: true, + documentEmits: true, + sourceEditing: false, + canBatchExport: true, + canBatchPdf: true, + pdfCurrentPageOnly: false, + batchRowCount: 3, +}; + +/** Everything the dialog needs to open on the printer ways, with an address to send to. */ +export const BASE_STATE = { + outputSource: "label" as const, + printTarget: { ...DEFAULT_PRINT_TARGET, host: "172.17.17.175" }, + printerReading: undefined, + thirdParty: { labelary: true }, + dataset: null, + columnMapping: null, + labelaryNoticeAcknowledged: true, +}; + +// The kind segment and the system way's button read the same word, so the body's is the later one. +export function printButton(container: HTMLElement): HTMLButtonElement { + const button = [...container.querySelectorAll("button")] + .filter((b) => b.textContent === loc.kindPrint) + .at(-1); + if (button === undefined) throw new Error("no print button in the dialog"); + return button; +} + +export const batchScope = (n: number) => + loc.scopeCountFmt.replace("{label}", loc.scopeBatch).replace("{n}", String(n)); + +export const showDialog = (over: Partial[0]> = {}) => { + const props = { + zpl: () => "^XA^XZ", + source: "label" as const, + facts: everything, + onClose: vi.fn(), + onPrintImage: vi.fn(() => Promise.resolve()), + onExportPdf: vi.fn(), + onExportPng: vi.fn(), + ...over, + }; + // The shell portals into the body, so the body is the container the assertions read. + return { ...render(), container: document.body, props }; +}; diff --git a/src/components/Output/OutputDialog.tsx b/src/components/Output/OutputDialog.tsx index 08c34109..e025245e 100644 --- a/src/components/Output/OutputDialog.tsx +++ b/src/components/Output/OutputDialog.tsx @@ -2,37 +2,60 @@ import { XMarkIcon } from "@heroicons/react/16/solid"; import { useState } from "react"; import { useT } from "../../hooks/useT"; import { DialogShell } from "../ui/DialogShell"; -import { Tooltip } from "../ui/Tooltip"; import { RadioOption } from "../ui/RadioOption"; +import { SegmentedStrip, type Segment } from "../ui/SegmentedStrip"; import { primaryButtonCls, sectionHeadingCls } from "../ui/formStyles"; -import { useLabelStore } from "../../store/labelStore"; +import { useLabelStore, selectPrinterReading, selectEffectivePreviewProvider } from "../../store/labelStore"; import { formatTemplate } from "../../lib/formatTemplate"; +import { isDesktopShell } from "../../lib/platform"; +import { needsRawChannel, type PrintWay } from "../../lib/printTarget"; +import { wayHint, wayLabel } from "../../lib/printWayText"; import { effectiveKind, effectiveScope, offeredKinds, offeredScopes, + type FileFormat, type OutputFacts, type OutputKind, type OutputSource, type PdfScope, } from "../../lib/outputChoice"; -import { ZplSendPanel } from "./ZplSendPanel"; +import { useLabelaryConsent } from "../../hooks/useLabelaryConsent"; +import { usePrintWays } from "../../hooks/usePrintWays"; +import { useSendNotices } from "../../hooks/useSendNotices"; +import { ActiveRowNotice, DatasetNotice } from "./DatasetNotice"; +import { SystemPrintBody } from "./SystemPrintBody"; +import { ZebraWayPanel } from "./ZebraWayPanel"; interface Props { - /** Read only where it is shown, because a batch regenerates megabytes on every click elsewhere. */ + /** Read at the click, because a mapped dataset regenerates the code for every data row. */ zpl: () => string; source: OutputSource; facts: OutputFacts; onClose: () => void; - /** The running print, or nothing when a notice has to be answered before the work starts. */ - onPrintImage: () => Promise | undefined; + onPrintImage: () => Promise; onExportPdf: (scope: PdfScope) => void; + onExportPng: () => void; } -/** The one place the label leaves the app. The first level is what it becomes, the second how, and +function ImpactNotices() { + const notices = useSendNotices(); + if (notices.length === 0) return null; + return ( +
+ {notices.map((n) => ( +

+ {n} +

+ ))} +
+ ); +} + +/** The one place the label leaves the app. The first level is where it goes, the second how, and * the dialog proposes both again next time. */ -export function OutputDialog({ zpl, source, facts, onClose, onPrintImage, onExportPdf }: Props) { +export function OutputDialog({ zpl, source, facts, onClose, onPrintImage, onExportPdf, onExportPng }: Props) { const t = useT(); const loc = t.zebraPrint; const choice = useLabelStore((s) => s.outputChoice); @@ -44,113 +67,164 @@ export function OutputDialog({ zpl, source, facts, onClose, onPrintImage, onExpo const choosable = kinds.length > 1; const scopes = offeredScopes(facts); const scope = effectiveScope(choice.pdfScope, scopes); - // One scope is no choice, and then the action's own name says what it covers. const scopeChoosable = scopes.length > 1; - const kindLabels: Record = { zpl: loc.kindZpl, image: loc.kindImage, pdf: loc.kindPdf }; - const kindHints: Record = { zpl: loc.kindZplHint, image: loc.kindImageHint, pdf: loc.kindPdfHint }; - const scopeLabels: Record = { design: loc.scopeDesign, batch: loc.scopeBatch }; - - // The modal would sit over the print dialog and the renderer's progress, so it steps aside first. - const leaveAnd = (action: () => void) => () => { - onClose(); - action(); + const kindText: Record> = { + print: { key: "print", label: loc.kindPrint, hint: loc.kindPrintHint }, + file: { key: "file", label: loc.kindFile, hint: loc.kindFileHint }, }; + const formatSegments: Segment[] = [ + { key: "pdf", label: loc.formatPdf, hint: loc.formatPdfHint }, + { key: "png", label: loc.formatPng, hint: loc.formatPngHint }, + ]; + const scopeLabels: Record = { + design: loc.scopeDesign, + batch: formatTemplate(loc.scopeCountFmt, { label: loc.scopeBatch, n: String(facts.batchRowCount) }), + }; + + const ways = usePrintWays(source, facts); + const waySegments: Segment[] = ways.offered.map((key) => ({ + key, + label: wayLabel(loc, key), + hint: wayHint(loc, key, isDesktopShell), + })); // The render takes seconds over the network, and the dialog stays to show that it is working. const [rendering, setRendering] = useState(false); - const printImage = () => { - const started = onPrintImage(); - if (!started) return onClose(); - setRendering(true); - void started.then(onClose, onClose); + const checking = useLabelStore(selectPrinterReading); + const renderer = useLabelStore(selectEffectivePreviewProvider); + const { gate, notice } = useLabelaryConsent(); + + const printImage = () => + gate(() => { + setRendering(true); + void onPrintImage().then(onClose, onClose); + }); + // The PDF progress dialog takes over from here, and this modal would sit over it. + const exportPdf = () => + gate(() => { + onClose(); + onExportPdf(scope); + }); + // The save dialog takes over, and the canvas draws the PNG without asking a renderer. + const exportPng = () => { + onClose(); + onExportPng(); }; return ( - -
- - {choosable ? loc.outputHeading : loc.heading} - - -
- - {/* Pills, not the underlined tabs below them, so the two levels do not read as one strip. */} - {choosable && ( -
- {kinds.map((key) => ( - - - - ))} + <> + +
+ + {source === "setupScript" ? loc.heading : loc.outputHeading} + +
- )} - {/* The head stays put, the ways and their bodies scroll, like every other modal here. */} -
- {kind === "zpl" && } + {choosable && ( +
+ kindText[key])} + current={kind} + onPick={(next) => setOutputChoice({ kind: next })} + /> +
+ )} + + {kind === "print" && ( +
+ {/* The ways strip stays out of the scroll, so only the body of the chosen way moves. */} +
+ {source === "label" && ( + <> + {ways.way === "system" ? : } + {/* These are the exact bytes sent, which the dialog may never have shown. */} + {ways.way !== "system" && } + + )} +
+ {loc.wayHeading} +
+ +
+
+
- {kind === "image" && ( -
-

{loc.kindImageHint}

- {/* Stands where the rendered label will be, like the placeholder in the font dialog. */} - {rendering &&