stash
This commit is contained in:
parent
d32e718454
commit
312f2f6460
8 changed files with 1567 additions and 6 deletions
|
|
@ -10,6 +10,8 @@ import store from './store';
|
|||
|
||||
import _nacl from 'js-nacl';
|
||||
|
||||
|
||||
|
||||
const app = createApp(App).use(store).use(BootstrapIconsPlugin);
|
||||
|
||||
_nacl.instantiate((nacl) => {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import StorageLocationEdit from '@/views/StorageLocationEdit.vue';
|
|||
import Admin from '@/views/Admin.vue';
|
||||
import Swatch from '@/views/Swatch.vue';
|
||||
import Files from '@/views/Files.vue';
|
||||
import Print from "@/views/Print.vue";
|
||||
import Workflows from '@/views/Workflows.vue';
|
||||
import WorkflowDetail from '@/views/WorkflowDetail.vue';
|
||||
|
||||
|
|
@ -67,7 +68,7 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
|
|||
}, {path: '/admin',
|
||||
component: Admin,
|
||||
meta: {requiresAuth: true}
|
||||
}, {path: '/swatch', component: Swatch, meta: {requiresAuth: true}}, {
|
||||
}, {path: '/swatch', component: Swatch, meta: {requiresAuth: true}}, {path: '/print', component: Print, meta: {requiresAuth: true}}, {
|
||||
path: '/search/:query',
|
||||
component: Search,
|
||||
meta: {requiresAuth: true},
|
||||
|
|
|
|||
380
frontend/src/views/Print.vue
Normal file
380
frontend/src/views/Print.vue
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
<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.mjs";
|
||||
|
||||
const BLOB_URL = new URL("../../vendor/libweblabel.mjs", 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue