953 lines
42 KiB
Vue
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>
|