This commit is contained in:
j3d1 2026-08-17 18:28:01 +02:00
parent 25cef95711
commit 7d7730354e
8 changed files with 381 additions and 39 deletions

View file

@ -7,8 +7,51 @@
<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).
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 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="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="fallbackCanvas"></canvas>
</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">
@ -172,12 +215,49 @@ function drawQrLabel(canvas, qr, tape) {
}
}
const FALLBACK_SCALE_PX = 8; /* pixels per QR module in the no-webusb preview/PNG */
const FALLBACK_QUIET_ZONE_MODULES = 4; /* the spec's usual quiet zone - there's no printer feed margin to lean on here */
/* Same idea as drawQrLabel, but without a real device to ask for tape dimensions: just a
plain square QR code, sized for a PNG someone downloads and prints some other way. */
function drawQrSquare(canvas, qr) {
const modules = qr.modules.size + FALLBACK_QUIET_ZONE_MODULES * 2;
const size = modules * FALLBACK_SCALE_PX;
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d", {willReadFrequently: true});
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, size, size);
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(
(col + FALLBACK_QUIET_ZONE_MODULES) * FALLBACK_SCALE_PX,
(row + FALLBACK_QUIET_ZONE_MODULES) * FALLBACK_SCALE_PX,
FALLBACK_SCALE_PX, FALLBACK_SCALE_PX,
);
}
}
}
}
export default {
name: "Print",
components: {
BaseLayout,
...BIcons
},
props: {
// Prefilled from ?text= when arriving from e.g. an item's "Print label" button
// (see InventoryDetail.vue) - the router turns that query param into this prop
// (router.js's /print route), rather than the component reading $route directly.
prefill: {
type: String,
default: null
}
},
data() {
return {
usbSupported: true,
@ -188,8 +268,13 @@ export default {
devices: [],
connected: null,
value: "",
value: this.prefill || "",
copies: 1,
fallbackReady: false,
// TODO: replace with the real commands for our printers.
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",
};
},
computed: {
@ -214,7 +299,11 @@ export default {
},
watch: {
value() {
this.redraw();
if (this.usbSupported) {
this.redraw();
} else {
this.redrawFallback();
}
},
},
methods: {
@ -299,6 +388,38 @@ export default {
this.fitZoom(canvas);
},
redrawFallback() {
this.fallbackReady = false;
if (!this.value) {
return;
}
const canvas = this.$refs.fallbackCanvas;
if (!canvas) {
return;
}
try {
const qr = QRCode.create(this.value);
drawQrSquare(canvas, qr);
} catch (e) {
this.error = e.message;
return;
}
this.error = null;
this.fallbackReady = true;
this.fitZoom(canvas);
},
downloadPng() {
const canvas = this.$refs.fallbackCanvas;
if (!canvas) {
return;
}
const link = document.createElement("a");
link.download = "label.png";
link.href = canvas.toDataURL("image/png");
link.click();
},
/* 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. */
@ -322,7 +443,13 @@ export default {
handleResize() {
clearTimeout(this.resizeTimer);
this.resizeTimer = setTimeout(() => this.redraw(), 100);
this.resizeTimer = setTimeout(() => {
if (this.usbSupported) {
this.redraw();
} else {
this.redrawFallback();
}
}, 100);
},
onUsbChange() {
@ -336,8 +463,11 @@ export default {
this.resizeTimer = null;
},
async mounted() {
window.addEventListener("resize", this.handleResize);
if (!("usb" in navigator)) {
this.usbSupported = false;
await nextTick();
this.redrawFallback();
return;
}
try {
@ -349,7 +479,6 @@ export default {
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() {
@ -381,4 +510,13 @@ export default {
.copies-input {
width: 5.5rem;
}
.code-block {
background: rgba(127, 127, 127, .08);
border-radius: .35rem;
padding: .75rem;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
}
</style>