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

View file

@ -4,6 +4,20 @@
<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>
<!-- Left: what to print. Right: how it looks and how it prints, kept in view via .print-sticky. -->
<div class="row">
<div class="col-lg-7 mb-4">
<div class="print-sticky">
<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="error" class="alert alert-danger" role="alert">{{ error }}</div> <div v-if="error" class="alert alert-danger" role="alert">{{ error }}</div>
<div v-if="warnings.length" class="alert alert-warning" role="alert"> <div v-if="warnings.length" class="alert alert-warning" role="alert">
@ -11,117 +25,16 @@
</div> </div>
<div v-if="!usbSupported" class="alert alert-warning"> <div v-if="!usbSupported" class="alert alert-warning">
This browser can't talk to USB label printers directly. Open this page in Chrome, Edge or This browser can't pair with USB label printers directly. Open this page in
Opera over https:// (or http://localhost) to print straight from here, or use one of the Chrome, Edge or
alternatives below. Opera over https:// for that - you can still build and download labels
as PNGs below.
</div> </div>
<div class="row mb-3">
<div class="col-lg-8">
<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 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 class="col-lg-7">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Label preview</h5>
</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>
<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> <div
</div> class="printer-bar d-flex flex-wrap align-items-center chip-row mt-3 pt-3 border-top">
</div> <span v-for="d in deviceRows" :key="d.index" class="printer-chip"
<div class="col-lg-5 mb-4"> :class="{'printer-chip-connected': d.isConnected, 'printer-chip-disabled': !d.supported}"
<div class="card"> :title="!d.supported ? d.reason : ''">
<div class="card-header"> {{ d.name }}
<h5 class="card-title mb-0">Printer</h5> <button v-if="d.isConnected && !d.isPngExport"
</div> class="btn btn-link btn-sm p-0 ms-1"
<div class="card-body"> :disabled="busy" @click="disconnect()">disconnect</button>
<p v-if="!devices.length" class="text-muted"> <button v-else-if="!d.isConnected && d.supported"
No label printer paired yet. class="btn btn-link btn-sm p-0 ms-1"
</p> :disabled="busy" @click="connect(d.index)">connect</button>
<ul v-else class="list-group mb-3"> </span>
<li v-for="d in deviceRows" :key="d.index" <button v-if="usbSupported" class="btn btn-sm btn-outline-primary ms-auto"
class="list-group-item d-flex justify-content-between align-items-center"> :disabled="busy || !blobReady" @click="requestPrinter">
<div>
<div>{{ d.name }}</div>
<small v-if="d.isConnected" class="text-success">connected</small>
<small v-else-if="!d.supported" class="text-muted">{{ d.reason }}</small>
</div>
<button v-if="d.isConnected" class="btn btn-sm btn-outline-secondary"
:disabled="busy" @click="disconnect()">
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> <b-icon-plug class="me-1"></b-icon-plug>
Pair new printer Pair new printer
</button> </button>
</div> </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> </div>
</details>
</div>
</div>
</div>
</div>
<div class="col-lg-5 mb-4">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Content</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div :class="v === 'text' ? 'col-12' : 'col-sm-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 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>
</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();
@ -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.redrawFallback();
return;
}
try {
this.blob = await MultiPrinterBlob.load(BLOB_URL);
} catch (e) {
this.error = `Could not load the printer driver: ${e.message}`; this.error = `Could not load the printer driver: ${e.message}`;
return null;
})
: Promise.resolve(null);
await fontsReady;
// 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.
await nextTick();
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;