toolshed/frontend/src/components/LabelLayoutPreview.vue
2026-08-24 21:35:51 +02:00

328 lines
15 KiB
Vue

<template>
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Label layout</h5>
</div>
<div class="card-body">
<div class="mb-3 btn-group" role="group">
<input type="radio" class="btn-check" id="layout-filter-all"
autocomplete="off" value="all" v-model="activeTag">
<label class="btn btn-outline-secondary" for="layout-filter-all">All</label>
<template v-for="tag in labelTags" :key="tag">
<input type="radio" class="btn-check" :id="'layout-filter-' + tag"
autocomplete="off" :value="tag" v-model="activeTag">
<label class="btn btn-outline-secondary" :for="'layout-filter-' + tag">
{{ tagLabel(tag) }}
</label>
</template>
</div>
<div class="template-grid d-flex flex-wrap align-items-start">
<div v-for="t in labelTemplates" :key="t.id" class="template-option d-flex flex-column text-center"
:class="{'template-option-disabled': !isSelectable(t)}"
:title="unavailableReason(t)"
role="button" @click="isSelectable(t) && $emit('input', t.id)">
<div class="template-thumb-wrap">
<canvas :ref="el => setTemplateCanvasRef(t.id, el)"
class="img-thumbnail template-thumb-canvas"
:class="{'border-primary': value === t.id}"></canvas>
<span v-if="t.id in failed" class="template-thumb-warning" :title="failed[t.id].message">
{{ failed[t.id].short }}
</span>
<span v-else-if="t.id in warned" class="template-thumb-warning template-thumb-warning-soft"
:title="warned[t.id].map(w => w.message).join(' ')">
{{ warned[t.id].map(w => w.short).join(", ") }}
</span>
</div>
<div class="small"
:class="{'fw-bold text-primary': value === t.id}">
{{ t.name }}
<span v-for="tag in t.tags" :key="tag" class="badge bg-secondary">{{ tagLabel(tag) }}</span>
</div>
<div class="small text-muted">{{ t.description }}</div>
<!-- Debugging aid: every QR-family/text leaf's raw size numbers, shown regardless
of whether either tripped a warning above - see label.js's qrInfo/textInfo. -->
<div v-if="t.id in qrInfo || t.id in textInfo" class="small text-secondary">
<div v-for="(info, i) in qrInfo[t.id]" :key="'qr' + i">
mm-per-mod: {{ info.moduleMm.toFixed(2) }}, px-per-mod: {{ info.scale }}
</div>
<div v-for="(info, i) in textInfo[t.id]" :key="'text' + i">
text-mm: {{ info.fontMm.toFixed(2) }}, text-px: {{ info.fontPx.toFixed(1) }}
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* This build pins Bootstrap 4 (no gap-* utilities, those are 5.1+) - hence plain CSS gap here. */
.template-grid {
gap: 1rem;
}
.template-option {
width: 20rem;
}
.template-option-disabled {
opacity: .45;
cursor: not-allowed;
}
.template-option .badge {
font-size: .65rem;
vertical-align: middle;
margin-left: .35rem;
}
.template-thumb-wrap {
position: relative;
}
.template-thumb-canvas {
display: block;
width: 100%;
height: 10rem;
/* Canvas draws at whatever size fits its content (see redraw/drawFallbackLabel); object-fit
scales it into the thumbnail box like it would an <img>, no manual zoom math needed. */
object-fit: contain;
background: #fff;
}
/* Corner badge flagging *why* a layout failed to render - a capacity error (label.js's "bigger
code than the tape allows"/"doesn't fit on this tape") or label-layouts.js's templateContent
throwing "missing data" because a field it needs isn't filled in yet (see redraw()'s catch).
Shows the short label directly (e.g. "too big", "missing data") rather than just an icon, so the
reason reads at a glance; the full sentence is still the title tooltip. Layered on top of the
greyed-out/unselectable state isSelectable/unavailableReason already apply for the same case. */
.template-thumb-warning {
position: absolute;
top: .35rem;
right: .35rem;
max-width: calc(100% - .7rem);
padding: .15rem .45rem;
border-radius: 1rem;
background: rgba(220, 53, 69, .9);
color: #fff;
font-size: .7rem;
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
box-shadow: 0 0 0 1px rgba(0, 0, 0, .15);
cursor: help;
}
/* Soft variant for a template that rendered fine but tripped one of label.js's scan-reliability
thresholds (MIN_RECOMMENDED_QR_PX_PER_MODULE/MIN_RECOMMENDED_QR_MODULE_MM) - still selectable,
just flagged, so amber rather than the hard-failure badge's red. */
.template-thumb-warning-soft {
background: rgba(255, 193, 7, .9);
color: #000;
}
</style>
<script>
import {drawLabel, drawFallbackLabel, preloadQrEncoder} from "@/label.js";
import {LABEL_TEMPLATES, LABEL_TAGS, templateIsAvailable, templateContent} from "@/label-layouts.js";
export default {
name: "LabelLayoutPreview",
props: {
// Named content fields templates draw from (see label.js's buildLabelFields), kept in
// sync by the parent. A field's absence (not just empty) means a template needing it is
// unavailable.
fields: {
type: Object,
required: true
},
// The selected template id.
value: {
type: String,
required: true
},
// Ids of the most-recently printed/downloaded templates, most recent first (see
// Print.vue's rememberPrintedTemplate); bubbled to the front of the grid below.
recentTemplateIds: {
type: Array,
default: () => []
},
// The connected printer's tape/resolution (see label.js's tapeFromStatus), or null when
// no printer is connected. When present, redraw() renders every thumbnail with drawLabel
// at this tape's real dpi/printArea/length instead of drawFallbackLabel's fixed reference
// - so the grid's px/mm-per-module numbers and any length-limit failures reflect what
// would actually come out of the connected printer.
tape: {
type: Object,
default: null
}
},
model: {
prop: "value",
event: "input"
},
emits: ["input"],
data() {
return {
// Keyed by template id, holding the {short, message} error from its most recent
// redraw() failure (e.g. text too long, or too many QR modules for the thumbnail's
// fixed reference size - see label.js's snapQrToCrispSize/encodeQr). Absent, not just
// falsy, for a template that last drew fine, so `t.id in failed` matches "has one".
failed: {},
// Keyed by template id, holding the array of {short, message} scan-reliability
// warnings (see label.js's drawFallbackLabel) from its most recent successful
// redraw() - present only when that render tripped
// MIN_RECOMMENDED_QR_PX_PER_MODULE/MIN_RECOMMENDED_QR_MODULE_MM.
warned: {},
// Keyed by template id, holding every QR-family leaf's raw {scale, moduleMm} from its
// most recent successful redraw(), regardless of whether it warned - a debugging aid
// shown unconditionally below each thumbnail.
qrInfo: {},
// Same idea as qrInfo, but every text leaf's raw {fontPx, fontMm} (see label.js's
// checkTextSizes).
textInfo: {},
// Which LABEL_TAGS entry (or "all") the grid below is narrowed to.
activeTag: "all",
};
},
computed: {
labelTags() {
return LABEL_TAGS;
},
// LABEL_TEMPLATES with recentTemplateIds' entries pulled to the front (most recent
// first, everything else following in its original order), narrowed to activeTag, then
// with anything currently in `failed` (not printable - see redraw()'s catch) stably
// sorted to the back regardless of recency/tag order, so real errors don't crowd out
// layouts that actually work.
labelTemplates() {
const recent = this.recentTemplateIds
.map(id => LABEL_TEMPLATES.find(t => t.id === id))
.filter(Boolean);
const recentIds = new Set(recent.map(t => t.id));
const ordered = [...recent, ...LABEL_TEMPLATES.filter(t => !recentIds.has(t.id))];
const filtered = this.activeTag === "all" ? ordered : ordered.filter(t => t.tags?.includes(this.activeTag));
return [...filtered].sort((a, b) => (a.id in this.failed ? 1 : 0) - (b.id in this.failed ? 1 : 0));
}
},
watch: {
fields() {
this.redraw();
},
// Switching filters mounts fresh canvas elements for thumbnails hidden until now (see
// labelTemplates); nextTick waits for those refs to land before drawing into them.
activeTag() {
this.$nextTick(() => this.redraw());
},
// Printer connect/disconnect/switch - every thumbnail's canvas already exists regardless
// of tape (unlike Print.vue's own tape-fed preview), so no flush:'post' is needed here.
tape() {
this.redraw();
}
},
methods: {
// "internal" -> "Internal", for both the filter buttons and each thumbnail's badge.
tagLabel(tag) {
return tag.charAt(0).toUpperCase() + tag.slice(1);
},
setTemplateCanvasRef(id, el) {
if (el) {
this.templateCanvases[id] = el;
} else {
delete this.templateCanvases[id];
}
},
isAvailable(t) {
return templateIsAvailable(t, this.fields);
},
// Greyed out (and unclickable) either because a needed field isn't filled in yet
// (isAvailable) or its content failed to render (see redraw()'s catch) - either way
// there's nothing a click could select that would actually show anything.
isSelectable(t) {
return this.isAvailable(t) && !(t.id in this.failed);
},
// The disabled thumbnail's tooltip - empty once it's selectable again.
unavailableReason(t) {
if (!this.isAvailable(t)) {
return "Not available - fill in the fields this layout needs above.";
}
return this.failed[t.id]?.message ?? "";
},
// .template-thumb-canvas's object-fit:contain scales the just-drawn bitmap into its fixed
// CSS box (getBoundingClientRect, unlike canvas.width/height, reflects that box - object-fit
// doesn't change it); when that's a magnification, switch to nearest-neighbor so the
// print-accurate pixel edges stay crisp instead of blurring, same call Print.vue's own
// fitZoom makes for its explicitly-sized preview.
applyImageRendering(canvas) {
const {width: boxWidth, height: boxHeight} = canvas.getBoundingClientRect();
const scale = Math.min(boxWidth / canvas.width, boxHeight / canvas.height);
canvas.style.imageRendering = scale >= 1 ? "pixelated" : "auto";
},
// Live per-template thumbnails. Renders each with drawLabel at the connected printer's
// real tape/resolution when one's connected (see the `tape` prop), so the numbers/errors
// shown match what would actually print; falls back to drawFallbackLabel's fixed
// reference size otherwise. Either way, still illustrative previews via CSS object-fit,
// not the to-be-printed-accurate canvas Print.vue's own main preview is.
redraw() {
for (const t of LABEL_TEMPLATES) {
const canvas = this.templateCanvases[t.id];
if (!canvas) {
continue;
}
try {
const content = templateContent(t, this.fields);
const {warnings, qrInfo, textInfo} = this.tape
? drawLabel(canvas, this.tape, content, "along")
: drawFallbackLabel(canvas, content, "along");
this.applyImageRendering(canvas);
delete this.failed[t.id];
if (warnings.length) {
this.warned[t.id] = warnings;
} else {
delete this.warned[t.id];
}
if (qrInfo.length) {
this.qrInfo[t.id] = qrInfo;
} else {
delete this.qrInfo[t.id];
}
if (textInfo.length) {
this.textInfo[t.id] = textInfo;
} else {
delete this.textInfo[t.id];
}
} catch (e) {
// Grey the thumbnail out (see isSelectable/unavailableReason) rather than
// leaving it blank - selecting a template that can't render here would only
// hand Print.vue's own redraw the exact same failure.
canvas.width = 1;
canvas.height = 1;
this.failed[t.id] = {short: e.short ?? "error", message: e.message};
delete this.warned[t.id];
delete this.qrInfo[t.id];
delete this.textInfo[t.id];
}
}
}
},
created() {
// Keyed by template id, populated by setTemplateCanvasRef() - a plain :ref="t.id" string
// in v-for still gets Vue's refInFor array-collecting behavior despite distinct names per
// iteration, turning this.$refs[t.id] into a one-element array; a function ref avoids that.
this.templateCanvases = {};
},
async mounted() {
// See Print.vue's mounted() - every thumbnail here has a qr/mqr/rmqr leaf, so nothing's
// worth drawing before the wasm resource resolves.
await preloadQrEncoder();
this.redraw();
}
}
</script>