toolshed/frontend/src/views/Print.vue
2026-08-17 13:04:06 +02:00

380 lines
14 KiB
Vue

<template>
<BaseLayout>
<main class="content">
<div class="container-fluid p-0">
<h1 class="h3 mb-3">Print a label</h1>
<div v-if="error" class="alert alert-danger" role="alert">{{ error }}</div>
<div v-if="!usbSupported" class="alert alert-warning">
This browser can't talk to USB label printers. Open this page in Chrome, Edge or Opera
over https:// (or http://localhost).
</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</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="mb-3">
<label class="form-label">QR code content</label>
<input type="text" class="form-control" v-model="value"
placeholder="https://example.com/…" autofocus>
</div>
<div class="label-preview mb-3" v-show="value">
<canvas ref="labelCanvas"></canvas>
</div>
<div class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label">Copies</label>
<input type="number" class="form-control copies-input"
v-model.number="copies" min="1" max="20">
</div>
<div class="col-auto ms-auto">
<button class="btn btn-primary" :disabled="!canPrint || busy"
@click="print">
<b-icon-printer class="me-1"></b-icon-printer>
Print
</button>
</div>
</div>
</template>
</div>
</div>
</div>
<div class="col-lg-5 mb-4">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Printer</h5>
</div>
<div class="card-body">
<p v-if="!devices.length" class="text-muted">
No label printer paired yet.
</p>
<ul v-else class="list-group mb-3">
<li v-for="d in deviceRows" :key="d.index"
class="list-group-item d-flex justify-content-between align-items-center">
<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 || connected !== null" @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>
</main>
</BaseLayout>
</template>
<script>
import * as BIcons from "bootstrap-icons-vue";
import {markRaw, nextTick} from "vue";
import QRCode from "qrcode";
import BaseLayout from "@/components/BaseLayout.vue";
import {MultiPrinterBlob, canvasToBitmap, bitmapToCanvas} from "../../vendor/weblabel.js";
const BLOB_URL = new URL("../../vendor/libweblabel.js", import.meta.url).href;
const MAX_ZOOM = 4; /* never magnify the preview more than this */
const TRAILING_PADDING_PX = 3; /* blank columns after the cut, same idea as the leading margin */
/* A quiet zone narrower than the spec's usual 4 modules: the printer's own
feed margin already keeps the code clear of the tape edge and the cut. */
const QUIET_ZONE_MODULES = 2;
/* Sizing a label needs numbers only the driver can supply. */
function tapeFromStatus(status) {
const printAreaPx = status?.tape?.printAreaPx;
const dpi = status?.printer?.dpi;
if (!(printAreaPx > 0) || !(dpi > 0)) {
return null;
}
return {
printAreaPx,
dpi,
mediaWidthMm: status.tape.mediaWidthMm,
printLengthPx: status.tape.printLengthPx > 0 ? status.tape.printLengthPx : 0,
/* Brother's documented margin for the mounted tape, in raster columns. */
leadPx: status.tape.marginsMm
? Math.round(status.tape.marginsMm * dpi / 25.4)
: TRAILING_PADDING_PX,
};
}
/*
Draw the QR code as large as the tape allows, centered in a square, with an
integer number of pixels per module so it stays crisp at printer
resolution rather than blurring at a fractional scale.
*/
function drawQrLabel(canvas, qr, tape) {
const modules = qr.modules.size + QUIET_ZONE_MODULES * 2;
const maxLength = tape.printLengthPx
? tape.printLengthPx - tape.leadPx - TRAILING_PADDING_PX
: Infinity;
const scale = Math.floor(Math.min(tape.printAreaPx, maxLength) / modules);
if (!(scale >= 1)) {
throw new Error("This text needs a bigger QR code than the tape allows — "
+ "try a shorter value or a wider tape.");
}
const square = modules * scale;
const width = tape.printLengthPx || (square + tape.leadPx + TRAILING_PADDING_PX);
canvas.width = width;
canvas.height = tape.printAreaPx;
const ctx = canvas.getContext("2d", {willReadFrequently: true});
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, width, canvas.height);
const left = tape.leadPx + Math.floor((width - tape.leadPx - TRAILING_PADDING_PX - square) / 2);
const top = Math.floor((canvas.height - square) / 2);
ctx.fillStyle = "#000";
for (let row = 0; row < qr.modules.size; row++) {
for (let col = 0; col < qr.modules.size; col++) {
if (qr.modules.get(row, col)) {
ctx.fillRect(
left + (col + QUIET_ZONE_MODULES) * scale,
top + (row + QUIET_ZONE_MODULES) * scale,
scale, scale,
);
}
}
}
}
export default {
name: "Print",
components: {
BaseLayout,
...BIcons
},
data() {
return {
usbSupported: true,
blobReady: false,
busy: false,
error: null,
devices: [],
connected: null,
value: "",
copies: 1,
};
},
computed: {
deviceRows() {
if (!this.blob) {
return [];
}
return this.devices.map((d, index) => {
const info = this.blob.findDevice(d.vendorId, d.productId);
return {
index,
name: info ? info.name : (d.productName || "Unknown device"),
supported: Boolean(info && info.supported),
reason: info ? (info.unsupportedReason || "unsupported") : "no driver for this device",
isConnected: d === this.connected,
};
});
},
canPrint() {
return Boolean(this.value && this.tape && this.labelBitmap && !this.busy);
},
},
watch: {
value() {
this.redraw();
},
},
methods: {
async guard(fn) {
this.error = null;
this.busy = true;
try {
await fn();
} catch (e) {
this.error = e.message;
} finally {
this.busy = false;
}
},
async refreshDevices() {
this.devices = (await navigator.usb.getDevices()).map((d) => markRaw(d));
/* A printer unplugged while open is off the list: the handle it was
opened through is gone, so drop it rather than keep the label
card open on a connection that no longer exists. */
if (this.connected !== null && !this.devices.includes(this.connected)) {
this.connected = null;
this.tape = null;
}
},
requestPrinter() {
this.guard(async () => {
try {
await navigator.usb.requestDevice({filters: this.blob.usbFilters()});
} catch (e) {
if (e.name === "NotFoundError") {
return;
}
throw e;
}
await this.refreshDevices();
});
},
connect(index) {
this.guard(async () => {
const device = this.devices[index];
this.blob.setDevices([device]);
await this.blob.open(device.vendorId, device.productId);
this.connected = device;
const status = await this.blob.status();
this.tape = this.blob.can("print") ? tapeFromStatus(status) : null;
await nextTick();
this.redraw();
});
},
disconnect() {
this.guard(async () => {
await this.blob.close();
this.connected = null;
this.tape = null;
});
},
redraw() {
this.labelBitmap = null;
if (!this.tape || !this.value) {
return;
}
const canvas = this.$refs.labelCanvas;
if (!canvas) {
return;
}
try {
const qr = QRCode.create(this.value);
drawQrLabel(canvas, qr, this.tape);
} catch (e) {
this.error = e.message;
return;
}
this.error = null;
const bitmap = canvasToBitmap(canvas);
bitmapToCanvas(canvas, bitmap);
this.labelBitmap = bitmap;
this.fitZoom(canvas);
},
/* Fit the preview to its card without ever needing a horizontal
scrollbar for a label this small, magnifying short labels up to
MAX_ZOOM rather than showing them at native (tiny) size. */
fitZoom(canvas) {
const available = canvas.parentElement.clientWidth;
if (!(available > 0)) {
return;
}
const zoom = Math.min(MAX_ZOOM, available / canvas.width);
canvas.style.width = `${canvas.width * zoom}px`;
canvas.style.height = `${canvas.height * zoom}px`;
canvas.style.imageRendering = zoom >= 1 ? "pixelated" : "auto";
},
print() {
this.guard(async () => {
const copies = Math.max(1, Math.min(20, Number(this.copies) || 1));
await this.blob.printBitmap(this.labelBitmap, {copies});
});
},
handleResize() {
clearTimeout(this.resizeTimer);
this.resizeTimer = setTimeout(() => this.redraw(), 100);
},
onUsbChange() {
this.guard(() => this.refreshDevices());
},
},
created() {
this.blob = null;
this.tape = null;
this.labelBitmap = null;
this.resizeTimer = null;
},
async mounted() {
if (!("usb" in navigator)) {
this.usbSupported = false;
return;
}
try {
this.blob = await MultiPrinterBlob.load(BLOB_URL);
} catch (e) {
this.error = `Could not load the printer driver: ${e.message}`;
return;
}
this.blobReady = true;
navigator.usb.addEventListener("connect", this.onUsbChange);
navigator.usb.addEventListener("disconnect", this.onUsbChange);
window.addEventListener("resize", this.handleResize);
await this.guard(() => this.refreshDevices());
},
beforeUnmount() {
if ("usb" in navigator) {
navigator.usb.removeEventListener("connect", this.onUsbChange);
navigator.usb.removeEventListener("disconnect", this.onUsbChange);
}
window.removeEventListener("resize", this.handleResize);
clearTimeout(this.resizeTimer);
},
}
</script>
<style scoped>
.label-preview {
overflow-x: auto;
padding: .75rem;
background: rgba(127, 127, 127, .08);
border-radius: .35rem;
text-align: center;
}
.label-preview canvas {
display: inline-block;
background: #fff;
box-shadow: 0 0 0 1px rgba(127, 127, 127, .5);
}
.copies-input {
width: 5.5rem;
}
</style>