380 lines
14 KiB
Vue
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>
|