This commit is contained in:
j3d1 2026-08-27 22:16:43 +02:00
parent 7276750c66
commit 341967a611

View file

@ -4,124 +4,37 @@
<div class="container-fluid p-0"> <div class="container-fluid p-0">
<h1 class="h3 mb-3">Print a label</h1> <h1 class="h3 mb-3">Print a label</h1>
<div v-if="error" class="alert alert-danger" role="alert">{{ error }}</div> <!-- Left: what to print. Right: how it looks and how it prints, kept in view via .print-sticky. -->
<div class="row">
<div v-if="warnings.length" class="alert alert-warning" role="alert"> <div class="col-lg-7 mb-4">
<div v-for="(w, i) in warnings" :key="i">{{ w.message }}</div> <div class="print-sticky">
</div> <div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">Preview</h5>
<small class="text-muted">{{ previewTape.mediaWidthMm }} mm tape{{
textSizesSummary
}}<span v-if="!tape"> (example)</span></small>
</div>
<div class="card-body">
<div v-if="!usbSupported" class="alert alert-warning"> <div v-if="error" class="alert alert-danger" role="alert">{{ error }}</div>
This browser can't talk to USB label printers directly. Open this page in Chrome, Edge or
Opera over https:// (or http://localhost) to print straight from here, or use one of the
alternatives below.
</div>
<div class="row mb-3"> <div v-if="warnings.length" class="alert alert-warning" role="alert">
<div class="col-lg-8"> <div v-for="(w, i) in warnings" :key="i">{{ w.message }}</div>
<div class="card h-100">
<div class="card-header">
<h5 class="card-title mb-0">Label content</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div :class="v === 'text' ? 'col-12' : 'col-md-6'" v-for="v in baseVars" :key="v">
<label class="form-label">{{ varLabel(v) }}</label>
<textarea v-if="v === 'text'" class="form-control" rows="3"
v-model="varValues[v]"
placeholder="https://example.com/…"></textarea>
<input v-else type="text" class="form-control" v-model="varValues[v]">
</div> </div>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card h-100">
<div class="card-header">
<h5 class="card-title mb-0">Calculated</h5>
</div>
<div class="card-body">
<div v-for="v in derivedVars" :key="v" class="mb-2">
<div class="small text-muted">{{ varLabel(v) }}</div>
<div class="text-break">{{ fields[v] || "—" }}</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="!usbSupported" class="row"> <div v-if="!usbSupported" class="alert alert-warning">
<div class="col-lg-7"> This browser can't pair with USB label printers directly. Open this page in
<div class="card"> Chrome, Edge or
<div class="card-header"> Opera over https:// for that - you can still build and download labels
<h5 class="card-title mb-0">Label preview</h5> as PNGs below.
</div>
<div class="card-body">
<div class="label-preview mb-3" v-show="fallbackReady">
<canvas ref="fallbackCanvas"></canvas>
</div>
<div class="mb-3">
<label class="form-label">Orientation</label>
<div class="btn-group d-block" role="group">
<input type="radio" class="btn-check" id="orientation-along-fallback"
autocomplete="off" value="along" v-model="orientation">
<label class="btn btn-outline-secondary" for="orientation-along-fallback">
Along tape
</label>
<input type="radio" class="btn-check" id="orientation-across-fallback"
autocomplete="off" value="across" v-model="orientation">
<label class="btn btn-outline-secondary" for="orientation-across-fallback">
Across tape
</label>
</div> </div>
</div>
<button class="btn btn-primary" :disabled="!fallbackReady" @click="downloadPng">
<b-icon-download class="me-1"></b-icon-download>
Download label as PNG
</button>
</div>
</div>
</div>
<div class="col-lg-5 mb-4">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Print from the command line</h5>
</div>
<div class="card-body">
<p class="text-muted">
Download the PNG above, then send it to a label printer with its CLI tool.
</p>
<p class="mb-1"><strong>Brother QL-series</strong></p>
<pre class="code-block"><code>{{ brotherCliExample }}</code></pre>
<p class="mb-1"><strong>Niimbot</strong></p>
<pre class="code-block"><code>{{ niimbotCliExample }}</code></pre>
</div>
</div>
</div>
</div>
<div v-else class="row">
<div class="col-lg-7">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">Label</h5>
<small v-if="tape" class="text-muted">{{ tape.mediaWidthMm }} mm tape{{
textSizesSummary
}}</small>
</div>
<div class="card-body">
<p v-if="!tape" class="text-muted">
Connect a printer to preview and print a label.
</p>
<template v-else>
<div class="preview-row mb-3" v-show="labelBitmap"> <div class="preview-row mb-3" v-show="labelBitmap">
<div class="ruler-v"> <div class="ruler-v">
<div class="ruler-v-corner"></div> <div class="ruler-v-corner"></div>
<div class="ruler ruler-v-ticks" <div class="ruler ruler-v-ticks"
:style="{height: (tape.mediaWidthMm * tapePxPerMm) + 'px'}"> :style="{height: (previewTape.mediaWidthMm * tapePxPerMm) + 'px'}">
<span v-for="t in verticalRulerTicks" :key="'tick-' + t.mm" <span v-for="t in verticalRulerTicks" :key="'tick-' + t.mm"
class="tick" :class="{'tick-major': t.major}" class="tick" :class="{'tick-major': t.major}"
:style="{top: t.pos + 'px'}"></span> :style="{top: t.pos + 'px'}"></span>
@ -142,7 +55,7 @@
</div> </div>
<!-- Tape's full physical width; canvas is narrower/centered, margin is real tape. See docs/implementation.md#tape-full-print-margin. --> <!-- Tape's full physical width; canvas is narrower/centered, margin is real tape. See docs/implementation.md#tape-full-print-margin. -->
<div class="tape-full" <div class="tape-full"
:style="{height: (tape.mediaWidthMm * tapePxPerMm) + 'px'}"> :style="{height: (previewTape.mediaWidthMm * tapePxPerMm) + 'px'}">
<div class="label-preview"> <div class="label-preview">
<canvas ref="labelCanvas"></canvas> <canvas ref="labelCanvas"></canvas>
</div> </div>
@ -169,60 +82,97 @@
<div class="col-auto"> <div class="col-auto">
<label class="form-label">Copies</label> <label class="form-label">Copies</label>
<input type="number" class="form-control copies-input" <input type="number" class="form-control copies-input"
v-model.number="copies" min="1" max="20"> v-model.number="copies" min="1" max="20" :disabled="!connected">
</div>
<div class="col-auto" v-if="chainSupported">
<label class="form-label d-block" for="chain-mode">Chain mode</label>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch"
id="chain-mode" v-model="chainMode">
</div>
</div>
<div class="col-auto" v-if="!connected">
<label class="form-label">Tape height (px)</label>
<input type="number" class="form-control copies-input"
v-model.number="simulatedHeightPx" min="8" max="1000">
</div> </div>
<div class="col-auto ms-auto"> <div class="col-auto ms-auto">
<button class="btn btn-primary" :disabled="!canPrint || busy" <button class="btn btn-primary" :disabled="!canAct"
@click="print"> @click="connected ? print() : downloadPng()">
<b-icon-printer class="me-1"></b-icon-printer> <b-icon-printer v-if="connected" class="me-1"></b-icon-printer>
Print <b-icon-download v-else class="me-1"></b-icon-download>
{{ connected ? "Print" : "Download PNG" }}
</button> </button>
</div> </div>
</div> </div>
</template>
<div
class="printer-bar d-flex flex-wrap align-items-center chip-row mt-3 pt-3 border-top">
<span v-for="d in deviceRows" :key="d.index" class="printer-chip"
:class="{'printer-chip-connected': d.isConnected, 'printer-chip-disabled': !d.supported}"
:title="!d.supported ? d.reason : ''">
{{ d.name }}
<button v-if="d.isConnected && !d.isPngExport"
class="btn btn-link btn-sm p-0 ms-1"
:disabled="busy" @click="disconnect()">disconnect</button>
<button v-else-if="!d.isConnected && d.supported"
class="btn btn-link btn-sm p-0 ms-1"
:disabled="busy" @click="connect(d.index)">connect</button>
</span>
<button v-if="usbSupported" class="btn btn-sm btn-outline-primary ms-auto"
:disabled="busy || !blobReady" @click="requestPrinter">
<b-icon-plug class="me-1"></b-icon-plug>
Pair new printer
</button>
</div>
<details class="cli-details mt-3 pt-3 border-top">
<summary class="small text-muted">Print from the command line instead</summary>
<div class="mt-2">
<p class="mb-1"><strong>Brother QL-series</strong></p>
<pre class="code-block"><code>{{ brotherCliExample }}</code></pre>
<p class="mb-1"><strong>Niimbot</strong></p>
<pre class="code-block"><code>{{ niimbotCliExample }}</code></pre>
</div>
</details>
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="col-lg-5 mb-4"> <div class="col-lg-5 mb-4">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h5 class="card-title mb-0">Printer</h5> <h5 class="card-title mb-0">Content</h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<p v-if="!devices.length" class="text-muted"> <div class="row g-3">
No label printer paired yet. <div :class="v === 'text' ? 'col-12' : 'col-sm-6'" v-for="v in baseVars" :key="v">
</p> <label class="form-label">{{ varLabel(v) }}</label>
<ul v-else class="list-group mb-3"> <textarea v-if="v === 'text'" class="form-control" rows="3"
<li v-for="d in deviceRows" :key="d.index" v-model="varValues[v]"
class="list-group-item d-flex justify-content-between align-items-center"> placeholder="https://example.com/…"></textarea>
<div> <input v-else type="text" class="form-control" v-model="varValues[v]">
<div>{{ d.name }}</div> </div>
<small v-if="d.isConnected" class="text-success">connected</small> </div>
<small v-else-if="!d.supported" class="text-muted">{{ d.reason }}</small>
<div class="calculated-fields mt-3 pt-3 border-top">
<div class="small text-uppercase text-muted mb-2">Calculated</div>
<div class="d-flex flex-wrap chip-row">
<div v-for="v in derivedVars" :key="v" class="calculated-field">
<div class="small text-muted">{{ varLabel(v) }}</div>
<div class="text-break">{{ fields[v] || "—" }}</div>
</div> </div>
<button v-if="d.isConnected" class="btn btn-sm btn-outline-secondary" </div>
:disabled="busy" @click="disconnect()"> </div>
Disconnect
</button>
<button v-else-if="d.supported" class="btn btn-sm btn-outline-primary"
:disabled="busy" @click="connect(d.index)">
Connect
</button>
</li>
</ul>
<button class="btn btn-primary" :disabled="busy || !blobReady" @click="requestPrinter">
<b-icon-plug class="me-1"></b-icon-plug>
Pair new printer
</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-lg-12 mb-4">
<label-layout-preview :fields="fields" :value="selectedTemplate" :tape="tape" <label-layout-preview :fields="fields" :value="selectedTemplate" :tape="previewTape"
:recent-template-ids="recentTemplateIds" :recent-template-ids="recentTemplateIds" class="mb-3"
@input="selectedTemplate = $event"></label-layout-preview> @input="selectedTemplate = $event"></label-layout-preview>
</div> </div>
</div> </div>
@ -243,7 +193,6 @@ import {MultiPrinterBlob, canvasToBitmap, bitmapToCanvas} from "../../vendor/web
import { import {
tapeFromStatus, tapeFromStatus,
drawLabel, drawLabel,
drawFallbackLabel,
buildLabelContent, buildLabelContent,
buildLabelFields, buildLabelFields,
preloadQrEncoder, preloadQrEncoder,
@ -268,6 +217,14 @@ const MAX_RECENT_TEMPLATES = 4;
const MAX_ZOOM = 4; /* never magnify the preview more than this */ const MAX_ZOOM = 4; /* never magnify the preview more than this */
const MAX_PREVIEW_HEIGHT_PX = 300; /* never let the on-screen preview grow taller than this */ const MAX_PREVIEW_HEIGHT_PX = 300; /* never let the on-screen preview grow taller than this */
// A plausible printer profile for the preview/ruler when the PNG Export "printer" (see deviceRows) is the active one; its height is user-adjustable (see simulatedHeightPx), everything else about it isn't.
const SIMULATED_DPI = 180;
const SIMULATED_DEFAULT_HEIGHT_PX = 72; /* ~9mm at SIMULATED_DPI */
const SIMULATED_MIN_HEIGHT_PX = 8;
const SIMULATED_MAX_HEIGHT_PX = 1000;
const SIMULATED_LEAD_PX = Math.round(2 * SIMULATED_DPI / 25.4); /* blank runway before content, same idea as tapeFromStatus's marginsMm */
// deviceRows' sentinel index for the always-present PNG Export row - never a real index into `devices`.
const PNG_EXPORT_INDEX = -1;
// Tick spacing tiers, coarser the longer the ruler runs. See docs/implementation.md#ruler-tier-selection. // Tick spacing tiers, coarser the longer the ruler runs. See docs/implementation.md#ruler-tier-selection.
const RULER_TIERS = [ const RULER_TIERS = [
{aboveMm: 0, tickMm: 1, majorEveryMm: 5}, {aboveMm: 0, tickMm: 1, majorEveryMm: 5},
@ -297,7 +254,8 @@ export default {
}, },
data() { data() {
return { return {
usbSupported: true, // Whether this browser can pair with real USB printers at all; when false, deviceRows below only ever has its PNG Export row.
usbSupported: "usb" in navigator,
blobReady: false, blobReady: false,
busy: false, busy: false,
error: null, error: null,
@ -311,7 +269,7 @@ export default {
printedWidthPx: 0, printedWidthPx: 0,
// Each "text" leaf's effective font size (see label.js's drawLabel), shown beside the tape width so a too-small-to-render field reads as a suspiciously tiny number rather than silently absent. // Each "text" leaf's effective font size (see label.js's drawLabel), shown beside the tape width so a too-small-to-render field reads as a suspiciously tiny number rather than silently absent.
textSizesPx: [], textSizesPx: [],
// Scan-reliability messages from label.js's drawLabel/drawFallbackLabel (too few px per QR module, or too small in mm) - the label still rendered/prints fine, just flagged as a risk. // Scan-reliability messages from label.js's drawLabel (too few px per QR module, or too small in mm) - the label still rendered/prints fine, just flagged as a risk.
warnings: [], warnings: [],
// One input per BASE_VARS entry; derived vars (userHandle/itemUrl/itemHandle) are calculated-only (see the `fields` computed), never stored here. Prefilled from query params but left editable. queryFields is applied last so an explicit ?text=... etc. always wins over a `kind` builder's computed value. // One input per BASE_VARS entry; derived vars (userHandle/itemUrl/itemHandle) are calculated-only (see the `fields` computed), never stored here. Prefilled from query params but left editable. queryFields is applied last so an explicit ?text=... etc. always wins over a `kind` builder's computed value.
@ -324,13 +282,16 @@ export default {
...Object.fromEntries(BASE_VARS.filter(v => v in this.queryFields).map(v => [v, this.queryFields[v]])), ...Object.fromEntries(BASE_VARS.filter(v => v in this.queryFields).map(v => [v, this.queryFields[v]])),
}, },
copies: 1, copies: 1,
// Only sent to a real printer whose driver reports the "chain" capability (see chainSupported) - harmless to leave set when switching to a driver that ignores it.
chainMode: false,
// PNG Export's stand-in for a real tape's mediaWidthMm - there's no physical tape to measure, so this is the one thing about previewTape's simulated profile the user can adjust.
simulatedHeightPx: SIMULATED_DEFAULT_HEIGHT_PX,
selectedTemplate: LABEL_TEMPLATES[0].id, selectedTemplate: LABEL_TEMPLATES[0].id,
// Ids of the last MAX_RECENT_TEMPLATES distinct templates printed/downloaded, most recent first. See rememberPrintedTemplate/loadRecentTemplateIds. // Ids of the last MAX_RECENT_TEMPLATES distinct templates printed/downloaded, most recent first. See rememberPrintedTemplate/loadRecentTemplateIds.
recentTemplateIds: [], recentTemplateIds: [],
// "along" reads along the tape's feed direction (usual case, width-constrained); "across" turns 90deg on that same width instead. See label.js's drawLabel/drawFallbackLabel. // "along" reads along the tape's feed direction (usual case, width-constrained); "across" turns 90deg on that same width instead. See label.js's drawLabel.
orientation: "along", orientation: "along",
fallbackReady: false,
// TODO: replace with the real commands for our printers. // TODO: replace with the real commands for our printers.
brotherCliExample: "brother_ql --model QL-000 --printer usb://0000:0000 print --label 00 label.png", brotherCliExample: "brother_ql --model QL-000 --printer usb://0000:0000 print --label 00 label.png",
niimbotCliExample: "niimprint --model b00 --conn usb print --density 3 --image label.png", niimbotCliExample: "niimprint --model b00 --conn usb print --density 3 --image label.png",
@ -371,11 +332,22 @@ export default {
currentTemplate() { currentTemplate() {
return LABEL_TEMPLATES.find(t => t.id === this.selectedTemplate) || LABEL_TEMPLATES[0]; return LABEL_TEMPLATES.find(t => t.id === this.selectedTemplate) || LABEL_TEMPLATES[0];
}, },
// Always includes the virtual PNG Export row, so the printer list (and the preview built from
// whichever row is active) looks the same whether or not this browser/session has any real
// USB printer paired - PNG Export is simply the active row whenever no real device is.
deviceRows() { deviceRows() {
const pngRow = {
index: PNG_EXPORT_INDEX,
name: "PNG Export",
supported: true,
reason: "",
isConnected: !this.connected,
isPngExport: true,
};
if (!this.blob) { if (!this.blob) {
return []; return [pngRow];
} }
return this.devices.map((d, index) => { const real = this.devices.map((d, index) => {
const info = this.blob.findDevice(d.vendorId, d.productId); const info = this.blob.findDevice(d.vendorId, d.productId);
return { return {
index, index,
@ -385,20 +357,41 @@ export default {
isConnected: d === this.connected, isConnected: d === this.connected,
}; };
}); });
return [pngRow, ...real];
}, },
canPrint() { // Print/Download button and Copies input are only meaningful with a real printer connected; PNG Export can always export once a bitmap's rendered.
return Boolean(this.tape && this.labelBitmap && !this.busy); canAct() {
return Boolean(this.labelBitmap && !this.busy);
},
// Whether the connected printer's driver honors chain mode - some (e.g. Brother's QL line) don't. See vendor/weblabel.js's MultiPrinterBlob.can.
chainSupported() {
return Boolean(this.connected && this.blob && this.blob.can("chain", this.connected));
},
// The connected printer's tape, or PNG Export's stand-in profile (its height user-adjustable via simulatedHeightPx) so the ruler/preview always have something realistic to show.
previewTape() {
if (this.tape) {
return this.tape;
}
const printAreaPx = Math.min(SIMULATED_MAX_HEIGHT_PX,
Math.max(SIMULATED_MIN_HEIGHT_PX, Number(this.simulatedHeightPx) || SIMULATED_DEFAULT_HEIGHT_PX));
return {
dpi: SIMULATED_DPI,
mediaWidthMm: Math.round(printAreaPx / SIMULATED_DPI * 25.4 * 10) / 10,
printAreaPx,
printLengthPx: 0,
leadPx: SIMULATED_LEAD_PX,
};
}, },
// px per real mm at current zoom; meaningful only for the tape-fed preview (the no-webusb fallback has no real tape/dpi, so no ruler). // px per real mm at current zoom; meaningful only for the tape-fed preview (the no-webusb fallback has no real tape/dpi, so no ruler).
tapePxPerMm() { tapePxPerMm() {
return this.tape ? (this.tape.dpi / 25.4) * this.zoom : 0; return (this.previewTape.dpi / 25.4) * this.zoom;
}, },
// Physical mm length each ruler axis must cover; see horizontal/verticalRulerTicks for what each measures. // Physical mm length each ruler axis must cover; see horizontal/verticalRulerTicks for what each measures.
horizontalTotalMm() { horizontalTotalMm() {
return (this.tape && this.printedWidthPx) ? this.printedWidthPx / (this.tape.dpi / 25.4) : 0; return this.printedWidthPx ? this.printedWidthPx / (this.previewTape.dpi / 25.4) : 0;
}, },
verticalTotalMm() { verticalTotalMm() {
return this.tape ? this.tape.mediaWidthMm : 0; return this.previewTape.mediaWidthMm;
}, },
// Shared tier keyed off whichever axis is longer. See docs/implementation.md#ruler-tier-selection. // Shared tier keyed off whichever axis is longer. See docs/implementation.md#ruler-tier-selection.
rulerTier() { rulerTier() {
@ -407,14 +400,14 @@ export default {
}, },
// Ticks along the tape's printed length, feed margins included (still real tape). // Ticks along the tape's printed length, feed margins included (still real tape).
horizontalRulerTicks() { horizontalRulerTicks() {
if (!this.tape || !this.printedWidthPx) { if (!this.printedWidthPx) {
return []; return [];
} }
return this.rulerTicks(this.horizontalTotalMm); return this.rulerTicks(this.horizontalTotalMm);
}, },
// Ticks across the tape's full width, not just the printable area. See docs/implementation.md#tape-full-print-margin. // Ticks across the tape's full width, not just the printable area. See docs/implementation.md#tape-full-print-margin.
verticalRulerTicks() { verticalRulerTicks() {
return this.tape ? this.rulerTicks(this.verticalTotalMm) : []; return this.rulerTicks(this.verticalTotalMm);
}, },
// e.g. "(5px, 23px)", or "" so it appends cleanly onto the tape-width <small> with nothing shown. // e.g. "(5px, 23px)", or "" so it appends cleanly onto the tape-width <small> with nothing shown.
textSizesSummary() { textSizesSummary() {
@ -427,29 +420,20 @@ export default {
watch: { watch: {
varValues: { varValues: {
handler() { handler() {
if (this.usbSupported) { this.redraw();
this.redraw();
} else {
this.redrawFallback();
}
}, },
deep: true, deep: true,
}, },
selectedTemplate() { selectedTemplate() {
if (this.usbSupported) { this.redraw();
this.redraw();
} else {
this.redrawFallback();
}
}, },
orientation() { orientation() {
if (this.usbSupported) { this.redraw();
this.redraw();
} else {
this.redrawFallback();
}
}, },
// Catches printer connect/disconnect/switch; flush:'post' since the canvas only exists once `tape` is truthy (template's v-if). simulatedHeightPx() {
this.redraw();
},
// Catches printer connect/disconnect/switch, redrawing with the real tape or falling back to previewTape's simulated one.
tape: { tape: {
handler() { handler() {
this.redraw(); this.redraw();
@ -517,7 +501,7 @@ export default {
} }
}, },
rememberPrintedTemplate(id) { rememberPrintedTemplate(id) {
const updated = [id, ...this.recentTemplateIds.filter(t => t !== id)].slice(0, MAX_RECENT_TEMPLATES); const updated = [id, ...this.recentTemplateIds.filter(t => t !== id)].slice(0, MAX_RECENT_TEMPLATES);
this.recentTemplateIds = updated; this.recentTemplateIds = updated;
try { try {
@ -583,6 +567,10 @@ export default {
}, },
connect(index) { connect(index) {
if (index === PNG_EXPORT_INDEX) {
this.guard(() => this.closeConnection());
return;
}
this.guard(() => this.connectToDevice(this.devices[index])); this.guard(() => this.connectToDevice(this.devices[index]));
}, },
@ -604,9 +592,6 @@ export default {
redraw() { redraw() {
this.labelBitmap = null; this.labelBitmap = null;
if (!this.tape) {
return;
}
const canvas = this.$refs.labelCanvas; const canvas = this.$refs.labelCanvas;
if (!canvas) { if (!canvas) {
return; return;
@ -615,7 +600,7 @@ export default {
let textSizesPx, warnings; let textSizesPx, warnings;
try { try {
const content = templateContent(this.currentTemplate, this.fields); const content = templateContent(this.currentTemplate, this.fields);
({textSizesPx, warnings} = drawLabel(canvas, this.tape, content, this.orientation)); ({textSizesPx, warnings} = drawLabel(canvas, this.previewTape, content, this.orientation));
} catch (e) { } catch (e) {
this.error = e.message; this.error = e.message;
return; return;
@ -630,29 +615,8 @@ export default {
this.zoom = this.fitZoom(canvas); this.zoom = this.fitZoom(canvas);
}, },
redrawFallback() {
this.fallbackReady = false;
const canvas = this.$refs.fallbackCanvas;
if (!canvas) {
return;
}
this.resizeObserver.observe(canvas.parentElement);
let warnings;
try {
const content = templateContent(this.currentTemplate, this.fields);
({warnings} = drawFallbackLabel(canvas, content, this.orientation));
} catch (e) {
this.error = e.message;
return;
}
this.error = null;
this.warnings = warnings;
this.fallbackReady = true;
this.fitZoom(canvas);
},
downloadPng() { downloadPng() {
const canvas = this.$refs.fallbackCanvas; const canvas = this.$refs.labelCanvas;
if (!canvas) { if (!canvas) {
return; return;
} }
@ -681,7 +645,7 @@ export default {
print() { print() {
this.guard(async () => { this.guard(async () => {
const copies = Math.max(1, Math.min(20, Number(this.copies) || 1)); const copies = Math.max(1, Math.min(20, Number(this.copies) || 1));
await this.blob.printBitmap(this.labelBitmap, {copies}); await this.blob.printBitmap(this.labelBitmap, {copies, chain: this.chainMode});
this.rememberPrintedTemplate(this.selectedTemplate); this.rememberPrintedTemplate(this.selectedTemplate);
}); });
}, },
@ -723,22 +687,23 @@ export default {
// Same reasoning - drawTextLeaf's bitmap-font tiers (see label.js's fontTierFor) need this // Same reasoning - drawTextLeaf's bitmap-font tiers (see label.js's fontTierFor) need this
// loaded before the first real draw, same as qrReady below. // loaded before the first real draw, same as qrReady below.
const fontsReady = Promise.all([qrReady, preloadPixelFontRenderer()]); const fontsReady = Promise.all([qrReady, preloadPixelFontRenderer()]);
if (!("usb" in navigator)) { const blobLoad = this.usbSupported
this.usbSupported = false; ? MultiPrinterBlob.load(BLOB_URL).catch(e => {
await Promise.all([nextTick(), fontsReady]); this.error = `Could not load the printer driver: ${e.message}`;
this.redrawFallback(); return null;
return; })
} : Promise.resolve(null);
try { await fontsReady;
this.blob = await MultiPrinterBlob.load(BLOB_URL); // Draws PNG Export's simulated tape right away rather than waiting on the driver/device enumeration below; the tape watcher redraws again with a real one if/once autoConnectPreferred finds it.
} catch (e) { await nextTick();
this.error = `Could not load the printer driver: ${e.message}`; this.redraw();
this.blob = await blobLoad;
if (!this.blob) {
return; return;
} }
this.blobReady = true; this.blobReady = true;
navigator.usb.addEventListener("connect", this.onUsbChange); navigator.usb.addEventListener("connect", this.onUsbChange);
navigator.usb.addEventListener("disconnect", this.onUsbChange); navigator.usb.addEventListener("disconnect", this.onUsbChange);
await fontsReady;
await this.guard(async () => { await this.guard(async () => {
await this.refreshDevices(); await this.refreshDevices();
await this.autoConnectPreferred(); await this.autoConnectPreferred();
@ -756,6 +721,44 @@ export default {
</script> </script>
<style scoped> <style scoped>
/* Sticks the preview/print card in view while the left column scrolls; only above `lg`, where the columns are actually side by side. */
@media (min-width: 992px) {
.print-sticky {
position: sticky;
top: 1rem;
}
}
.chip-row {
gap: .5rem;
}
.calculated-field {
min-width: 6rem;
}
.printer-chip {
display: inline-flex;
align-items: center;
border: 1px solid var(--bs-gray-300);
border-radius: 1rem;
padding: .15rem .35rem .15rem .7rem;
font-size: .85rem;
}
.printer-chip-connected {
border-color: var(--bs-success);
color: var(--bs-success);
}
.printer-chip-disabled {
color: var(--bs-gray-600);
}
.cli-details summary {
cursor: pointer;
}
.label-preview { .label-preview {
overflow-x: auto; overflow-x: auto;
padding: .75rem; padding: .75rem;