toolshed/frontend/src/views/Print.vue
2026-08-24 19:08:23 +02:00

953 lines
42 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="warnings.length" class="alert alert-warning" role="alert">
<div v-for="(w, i) in warnings" :key="i">{{ w.message }}</div>
</div>
<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
Opera over https:// (or http://localhost) to print straight from here, or use one of the
alternatives below.
</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 class="card mb-3 border-warning">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">Experimental: new font candidates</h5>
<small class="text-muted">for judging legibility - not part of the real label pipeline</small>
</div>
<div class="card-body">
<p class="text-muted small mb-3">
Nine pixel/bitmap-style fonts found in frontend/public, rendered live from the "Text"
field above at a range of sizes so they can be judged the same way Tom Thumb/Silkscreen
were. <strong>CodersCrux</strong> and <strong>712Serif</strong> fail to load as web
fonts at all - Chromium's OTS sanitizer rejects their cmap table - so their cells below
are actually just the browser's fallback serif, not a rendering bug in this page.
<strong>Chava</strong> is uppercase-only, same dealbreaker as PICO-8.
<strong>6pxExpert</strong> only has pixel glyphs for digits; its letters are the same
fallback serif.
</p>
<div v-for="cfg in candidateFonts" :key="cfg.family" class="mb-3">
<div class="fw-bold small mb-1">{{ cfg.family }}</div>
<div class="d-flex flex-wrap gap-3">
<div v-for="size in candidateSizes" :key="cfg.family + '-' + size" class="text-center">
<div class="small text-muted mb-1">{{ size }}px</div>
<canvas :ref="el => setCandidateCanvasRef(cfg.family + '-' + size, el)"
class="pixel-test-canvas"></canvas>
</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="selectedContent">
<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="selectedContent">
<div class="ruler-v">
<div class="ruler-v-corner"></div>
<div class="ruler ruler-v-ticks"
:style="{height: (tape.mediaWidthMm * tapePxPerMm) + 'px'}">
<span v-for="t in verticalRulerTicks" :key="'tick-' + t.mm"
class="tick" :class="{'tick-major': t.major}"
:style="{top: t.pos + 'px'}"></span>
<span v-for="t in verticalRulerTicks.filter(t => t.major)"
:key="'label-' + t.mm" class="tick-label"
:style="{top: t.pos + 'px'}">{{ t.mm }}</span>
</div>
</div>
<div class="preview-track">
<div class="ruler ruler-h"
:style="{width: (printedWidthPx * zoom) + 'px'}">
<span v-for="t in horizontalRulerTicks" :key="'tick-' + t.mm"
class="tick" :class="{'tick-major': t.major}"
:style="{left: t.pos + 'px'}"></span>
<span v-for="t in horizontalRulerTicks.filter(t => t.major)"
:key="'label-' + t.mm" class="tick-label"
:style="{left: t.pos + 'px'}">{{ t.mm }}</span>
</div>
<!-- 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"
:style="{height: (tape.mediaWidthMm * tapePxPerMm) + 'px'}">
<div class="label-preview">
<canvas ref="labelCanvas"></canvas>
</div>
</div>
</div>
</div>
<div class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label">Orientation</label>
<div class="btn-group" role="group">
<input type="radio" class="btn-check" id="orientation-along"
autocomplete="off" value="along" v-model="orientation">
<label class="btn btn-outline-secondary" for="orientation-along">
Along tape
</label>
<input type="radio" class="btn-check" id="orientation-across"
autocomplete="off" value="across" v-model="orientation">
<label class="btn btn-outline-secondary" for="orientation-across">
Across tape
</label>
</div>
</div>
<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" @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 class="row">
<div class="col-12">
<label-layout-preview :fields="fields" :value="selectedTemplate" :tape="tape"
:recent-template-ids="recentTemplateIds"
@input="selectedTemplate = $event"></label-layout-preview>
</div>
</div>
</div>
</main>
</BaseLayout>
</template>
<script>
import * as BIcons from "bootstrap-icons-vue";
import {markRaw, nextTick} from "vue";
import {mapActions, mapGetters} from "vuex";
import BaseLayout from "@/components/BaseLayout.vue";
import LabelLayoutPreview from "@/components/LabelLayoutPreview.vue";
import printerManager from "@/printerManager.js";
import {MultiPrinterBlob, canvasToBitmap, bitmapToCanvas} from "../../vendor/weblabel.js";
import {tapeFromStatus, drawLabel, drawFallbackLabel, buildLabelContent, buildLabelFields, preloadQrEncoder} from "@/label.js";
import {LABEL_TEMPLATES, BASE_VARS, DERIVED_VARS, withDerivedVars, templateContent} from "@/label-layouts.js";
import {shortenedRoute} from "@/router";
// Print.vue-local calculated fields on top of label-layouts.js's DERIVED_VARS. See
// docs/implementation.md#calculated-short-link-fields.
const SHORT_URL_VAR = "shortUrl";
// See docs/implementation.md#calculated-short-link-fields.
const SHORT_ID_VAR = "shortId";
// Served unbundled so its wasm sibling stays resolvable. See docs/implementation.md#libweblabel-served-unbundled.
const BLOB_URL = "/vendor/libweblabel.js";
// localStorage key for the most-recently-printed template ids (see rememberPrintedTemplate/loadRecentTemplateIds), same naming style as cameraManager.js's recentCameraIds.
const RECENT_TEMPLATES_KEY = "recentLabelTemplateIds";
// How many recently-printed templates LabelLayoutPreview.vue bubbles to the front of the grid.
const MAX_RECENT_TEMPLATES = 4;
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 */
// Tick spacing tiers, coarser the longer the ruler runs. See docs/implementation.md#ruler-tier-selection.
const RULER_TIERS = [
{aboveMm: 0, tickMm: 1, majorEveryMm: 5},
{aboveMm: 100, tickMm: 1, majorEveryMm: 10},
{aboveMm: 500, tickMm: 5, majorEveryMm: 25},
];
export default {
name: "Print",
components: {
BaseLayout,
LabelLayoutPreview,
...BIcons
},
props: {
// {kind, components} from the ?kind=…&… query params (router.js's /print route builds this prop); buildLabelContent turns it into the text field below.
prefill: {
type: Object,
default: null
}
},
data() {
return {
usbSupported: true,
blobReady: false,
busy: false,
error: null,
devices: [],
connected: null,
tape: null,
labelBitmap: null,
// Tracked reactively (not read off the canvas) so the mm ruler can recompute tick positions when either changes (see fitZoom/redraw).
zoom: 1,
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.
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.
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.
varValues: {
...Object.fromEntries(BASE_VARS.map(v => [v, ""])),
text: buildLabelContent(this.prefill),
// Defaults to this page's own origin; editable since any frontend can resolve any handle, so a label needn't point back at this one.
webdomain: window.location.origin,
...buildLabelFields(this.prefill),
},
copies: 1,
selectedTemplate: LABEL_TEMPLATES[0].id,
// Ids of the last MAX_RECENT_TEMPLATES distinct templates printed/downloaded, most recent first. See rememberPrintedTemplate/loadRecentTemplateIds.
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.
orientation: "along",
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",
// Nine pixel/bitmap font candidates for legibility testing only (see the disabled card above); not used by label.js's real PIXEL_FONT_TIERS.
candidateFonts: [
{family: "Pixelon"},
{family: "Pixelbasel"},
{family: "Chava"},
{family: "CodersCrux"},
{family: "712Serif"},
{family: "6pxNormal"},
{family: "6pxExpert"},
{family: "Jersey10"},
{family: "Jersey15"},
],
candidateSizes: [5, 6, 7, 8, 10, 12, 16, 20],
candidateZoom: 4,
};
},
computed: {
...mapGetters(["identityIdByHandle"]),
// Plain passthroughs of the form's base/derived vars, keeping the template from importing
// label-layouts.js just for these. SHORT_URL_VAR/SHORT_ID_VAR exclusion: see
// docs/implementation.md#calculated-short-link-fields.
baseVars() {
return BASE_VARS.filter(v => v !== SHORT_URL_VAR && v !== SHORT_ID_VAR);
},
derivedVars() {
return [...Object.keys(DERIVED_VARS), SHORT_ID_VAR, SHORT_URL_VAR];
},
// Named content fields the templates draw from, dropping blank values. See docs/implementation.md#fields-computed-dropping-blank-values.
fields() {
const base = {};
for (const v of BASE_VARS) {
if (this.varValues[v]) {
base[v] = this.varValues[v];
}
}
const derived = withDerivedVars(base);
const shortId = this.shortId(derived);
if (shortId) {
derived[SHORT_ID_VAR] = shortId;
if (derived.webdomain) {
derived[SHORT_URL_VAR] = derived.webdomain + "/" + shortId;
}
}
return derived;
},
currentTemplate() {
return LABEL_TEMPLATES.find(t => t.id === this.selectedTemplate) || LABEL_TEMPLATES[0];
},
selectedContent() {
return templateContent(this.currentTemplate, this.fields);
},
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.tape && this.labelBitmap && !this.busy);
},
// 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() {
return this.tape ? (this.tape.dpi / 25.4) * this.zoom : 0;
},
// Physical mm length each ruler axis must cover; see horizontal/verticalRulerTicks for what each measures.
horizontalTotalMm() {
return (this.tape && this.printedWidthPx) ? this.printedWidthPx / (this.tape.dpi / 25.4) : 0;
},
verticalTotalMm() {
return this.tape ? this.tape.mediaWidthMm : 0;
},
// Shared tier keyed off whichever axis is longer. See docs/implementation.md#ruler-tier-selection.
rulerTier() {
return RULER_TIERS.filter(t => Math.max(this.horizontalTotalMm, this.verticalTotalMm) >= t.aboveMm)
.at(-1);
},
// Ticks along the tape's printed length, feed margins included (still real tape).
horizontalRulerTicks() {
if (!this.tape || !this.printedWidthPx) {
return [];
}
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.
verticalRulerTicks() {
return this.tape ? this.rulerTicks(this.verticalTotalMm) : [];
},
// e.g. "(5px, 23px)", or "" so it appends cleanly onto the tape-width <small> with nothing shown.
textSizesSummary() {
if (!this.textSizesPx.length) {
return "";
}
return ` (${this.textSizesPx.map(px => Math.round(px) + "px").join(", ")})`;
},
},
watch: {
varValues: {
handler() {
if (this.usbSupported) {
this.redraw();
} else {
this.redrawFallback();
}
this.drawCandidateFontTests();
},
deep: true,
},
selectedTemplate() {
if (this.usbSupported) {
this.redraw();
} else {
this.redrawFallback();
}
},
orientation() {
if (this.usbSupported) {
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).
tape: {
handler() {
this.redraw();
},
flush: 'post'
},
},
methods: {
...mapActions(["fetchIdMap"]),
// camelCase -> Title Case (e.g. "itemHandle" -> "Item Handle") so a new template var needs no hand-written label.
varLabel(v) {
return v.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, c => c.toUpperCase());
},
// Builds the bare short-id.js token for the current fields. See docs/implementation.md#shortid-resolution.
shortId(f) {
if (!f.userHandle) {
return null;
}
const owner_identity_id = this.identityIdByHandle[f.userHandle];
if (owner_identity_id === undefined) {
return null;
}
if (f.itemId) {
return shortenedRoute({kind: "item", owner_identity_id, item_local_id: f.itemId}).slice(1);
}
if (f.locationId) {
return shortenedRoute({
kind: "storage_location", owner_identity_id, storage_location_id: f.locationId
}).slice(1);
}
return null;
},
// Ticks 0..totalMm via tapePxPerMm, shared by both ruler computeds; spacing comes from the shared rulerTier, so both rulers coarsen together.
rulerTicks(totalMm) {
const {tickMm, majorEveryMm} = this.rulerTier;
const ticks = [];
for (let mm = 0; mm <= totalMm; mm += tickMm) {
ticks.push({mm, pos: mm * this.tapePxPerMm, major: mm % majorEveryMm === 0});
}
return ticks;
},
// Reads the recently-printed template ids back from localStorage; same try/catch shape as
// cameraManager.js's getRecentCameras since either a disabled/full localStorage shouldn't
// break printing.
loadRecentTemplateIds() {
try {
const saved = localStorage.getItem(RECENT_TEMPLATES_KEY);
return saved ? JSON.parse(saved) : [];
} catch (e) {
return [];
}
},
// Moves `id` to the front of the recent-templates list (deduping any earlier occurrence),
// capped to MAX_RECENT_TEMPLATES, so LabelLayoutPreview.vue's grid always bubbles the
// last four printed/downloaded layouts to the top.
rememberPrintedTemplate(id) {
const updated = [id, ...this.recentTemplateIds.filter(t => t !== id)].slice(0, MAX_RECENT_TEMPLATES);
this.recentTemplateIds = updated;
try {
localStorage.setItem(RECENT_TEMPLATES_KEY, JSON.stringify(updated));
} catch (e) {
// Best-effort only - a disabled/full localStorage shouldn't block printing.
}
},
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 printerManager.enumerateDevices()).map((d) => markRaw(d));
/* A printer unplugged while open drops from the list: its handle is gone, so close the card rather than keep it open on a dead connection. */
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();
});
},
async closeConnection() {
if (this.connected) {
await this.blob.close();
this.connected = null;
this.tape = null;
}
},
// Only one open connection at a time: connecting a different printer disconnects the current one first.
async connectToDevice(device) {
await this.closeConnection();
this.blob.setDevices([device]);
await this.blob.open(device.vendorId, device.productId);
this.connected = device;
printerManager.savePreferredPrinter(device);
const status = await this.blob.status();
// Setting `tape` alone is enough to redraw - see the tape watcher above.
this.tape = this.blob.can("print") ? tapeFromStatus(status) : null;
},
connect(index) {
this.guard(() => this.connectToDevice(this.devices[index]));
},
// Auto-connects to the most-recently-used printer once it's paired and visible again (page
// load, or plugged back in - see onUsbChange), mirroring cameraManager's preferred-camera
// auto-select. Gated on "nothing connected yet" since a manual connect() to a different
// paired printer shouldn't be silently overridden by a later device-list refresh.
autoConnectPreferred() {
if (this.connected) {
return Promise.resolve();
}
const preferred = printerManager.findPreferredDevice(this.devices);
return preferred ? this.connectToDevice(preferred) : Promise.resolve();
},
disconnect() {
this.guard(() => this.closeConnection());
},
redraw() {
this.labelBitmap = null;
const content = this.selectedContent;
if (!this.tape || !content) {
return;
}
const canvas = this.$refs.labelCanvas;
if (!canvas) {
return;
}
this.resizeObserver.observe(canvas.parentElement);
let textSizesPx, warnings;
try {
({textSizesPx, warnings} = drawLabel(canvas, this.tape, content, this.orientation));
} catch (e) {
this.error = e.message;
return;
}
this.error = null;
this.textSizesPx = textSizesPx;
this.warnings = warnings;
const bitmap = canvasToBitmap(canvas);
bitmapToCanvas(canvas, bitmap);
this.labelBitmap = bitmap;
this.printedWidthPx = canvas.width;
this.zoom = this.fitZoom(canvas);
},
redrawFallback() {
this.fallbackReady = false;
const content = this.selectedContent;
if (!content) {
return;
}
const canvas = this.$refs.fallbackCanvas;
if (!canvas) {
return;
}
this.resizeObserver.observe(canvas.parentElement);
let warnings;
try {
({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() {
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();
this.rememberPrintedTemplate(this.selectedTemplate);
},
// Fits the preview to its card, magnifying up to MAX_ZOOM/MAX_PREVIEW_HEIGHT_PX. See docs/implementation.md#fit-zoom-preview-scaling.
fitZoom(canvas) {
const available = canvas.parentElement.clientWidth;
if (!(available > 0)) {
return 1;
}
const rawZoom = Math.min(MAX_ZOOM, available / canvas.width, MAX_PREVIEW_HEIGHT_PX / canvas.height);
// Floors rather than rounds/ceils when magnifying, to keep image-rendering:pixelated crisp. See docs/implementation.md#fit-zoom-preview-scaling.
const zoom = rawZoom >= 1 ? Math.max(1, Math.floor(rawZoom)) : rawZoom;
canvas.style.width = `${canvas.width * zoom}px`;
canvas.style.height = `${canvas.height * zoom}px`;
canvas.style.imageRendering = zoom >= 1 ? "pixelated" : "auto";
return zoom;
},
print() {
this.guard(async () => {
const copies = Math.max(1, Math.min(20, Number(this.copies) || 1));
await this.blob.printBitmap(this.labelBitmap, {copies});
this.rememberPrintedTemplate(this.selectedTemplate);
});
},
// Re-fits the resized container's canvas (window resize, sidebar toggle, font load, etc.); debounced since ResizeObserver can fire in bursts.
handleContainerResize(entries) {
clearTimeout(this.resizeTimer);
this.resizeTimer = setTimeout(() => {
for (const entry of entries) {
const canvas = entry.target.querySelector("canvas");
if (canvas) {
this.zoom = this.fitZoom(canvas);
}
}
}, 100);
},
onUsbChange() {
this.guard(async () => {
await this.refreshDevices();
await this.autoConnectPreferred();
});
},
// Works around Vue's refInFor array-collecting behavior for :ref in v-for, same as LabelLayoutPreview.vue's setTemplateCanvasRef.
setCandidateCanvasRef(key, el) {
if (el) {
this.candidateCanvases[key] = el;
} else {
delete this.candidateCanvases[key];
}
},
// Renders the "Text" field at exact size/family with no layout snapping, so the font itself is judged; ink-centered vertically (like label.js's drawTextLeaf) so unreliable metrics don't clip.
drawCandidateCell(canvas, family, fontPx) {
if (!canvas) {
return;
}
const text = this.varValues.text;
const ctx = canvas.getContext("2d");
ctx.font = `${fontPx}px "${family}"`;
const {width, actualBoundingBoxAscent: up, actualBoundingBoxDescent: down} = ctx.measureText(text);
canvas.width = Math.ceil(width) + 4;
canvas.height = Math.ceil(up + down) + 8;
// Re-set font/alignment - changing canvas.width/height above resets the 2D context.
ctx.font = `${fontPx}px "${family}"`;
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#000";
ctx.fillText(text, 2, (canvas.height + up - down) / 2);
canvas.style.width = `${canvas.width * this.candidateZoom}px`;
canvas.style.height = `${canvas.height * this.candidateZoom}px`;
},
// Draws every (candidateFonts x candidateSizes) cell - see the card in the template.
async drawCandidateFontTests() {
await Promise.all(this.candidateFonts
.map(({family}) => document.fonts.load(`20px "${family}"`).catch(() => {})));
for (const {family} of this.candidateFonts) {
for (const size of this.candidateSizes) {
const canvas = this.candidateCanvases[`${family}-${size}`];
this.drawCandidateCell(canvas, family, size);
}
}
},
},
created() {
this.blob = null;
this.resizeObserver = null;
this.resizeTimer = null;
this.candidateCanvases = {};
this.recentTemplateIds = this.loadRecentTemplateIds();
},
async mounted() {
this.drawCandidateFontTests();
// Not awaited: resolving late just means shortUrl briefly shows "—" instead of blocking the unrelated printer/wasm setup below.
this.fetchIdMap().catch(e => {
this.error = e.message;
});
this.resizeObserver = new ResizeObserver(this.handleContainerResize);
// Loaded concurrently with MultiPrinterBlob below rather than serialized. See docs/implementation.md#concurrent-wasm-loading.
const qrReady = preloadQrEncoder();
if (!("usb" in navigator)) {
this.usbSupported = false;
await Promise.all([nextTick(), qrReady]);
this.redrawFallback();
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);
await qrReady;
await this.guard(async () => {
await this.refreshDevices();
await this.autoConnectPreferred();
});
},
beforeUnmount() {
if ("usb" in navigator) {
navigator.usb.removeEventListener("connect", this.onUsbChange);
navigator.usb.removeEventListener("disconnect", this.onUsbChange);
}
this.resizeObserver.disconnect();
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;
max-height: 300px;
}
.label-preview canvas {
display: inline-block;
background: #fff;
//box-shadow: 0 0 0 1px rgba(127, 127, 127, .5);
}
/* The tape-fed preview's mm ruler: horizontal track above canvas, vertical to its left, both ticked in real physical mm. See docs/implementation.md#mm-ruler-layout. */
.preview-row {
display: flex;
align-items: flex-start;
}
/* Ruler/canvas container deliberately never scrollable. See docs/implementation.md#preview-track-no-scroll-invariant. */
.preview-track {
flex: 1 1 auto;
min-width: 0;
}
/* Nested override: .label-preview must neither scroll nor center its canvas here. See docs/implementation.md#label-preview-override-in-preview-track. */
.preview-track .label-preview {
overflow-x: visible;
text-align: left;
padding: 0;
}
/* Tape's full physical width; canvas is narrower/centered, faint tint marks the real unprintable margin. See docs/implementation.md#tape-full-print-margin. */
.tape-full {
display: flex;
flex-direction: column;
justify-content: center;
background: rgba(127, 127, 127, .08);
border-radius: .35rem;
}
.ruler-v {
flex: none;
width: 2.2rem;
color: rgba(127, 127, 127, .9);
}
/* Matches .ruler-h's height so the vertical ruler's tick 0 lines up with the canvas's top edge. */
.ruler-v-corner {
height: 1.6rem;
}
.ruler-v-ticks {
position: relative;
}
.ruler-h {
position: relative;
height: 1.6rem;
color: rgba(127, 127, 127, .9);
}
.ruler .tick {
position: absolute;
background: currentColor;
}
/* Ticks anchor to the edge nearest the canvas and grow outward (pointing at the label); mm labels sit on the opposite outer edge. */
.ruler-v-ticks .tick {
right: 0;
width: .4rem;
height: 1px;
}
.ruler-v-ticks .tick-major {
width: .7rem;
}
.ruler-h .tick {
bottom: 0;
width: 1px;
height: .4rem;
}
.ruler-h .tick-major {
height: .7rem;
}
.tick-label {
position: absolute;
font-size: .6rem;
line-height: 1;
white-space: nowrap;
}
/* transform (not a fixed em nudge) centers the label on the tick's mm position regardless of text size. */
.ruler-v-ticks .tick-label {
left: 0;
transform: translateY(-50%);
}
.ruler-h .tick-label {
top: 0;
transform: translateX(-50%);
}
.copies-input {
width: 5.5rem;
}
.pixel-test-canvas {
display: block;
background: #fff;
box-shadow: 0 0 0 1px rgba(127, 127, 127, .5);
image-rendering: pixelated;
}
.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>