This commit is contained in:
j3d1 2026-08-24 19:08:23 +02:00
parent ed04d98bf1
commit aa94c92000
10 changed files with 873 additions and 296 deletions

View file

@ -4,19 +4,51 @@
<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)">
<canvas :ref="el => setTemplateCanvasRef(t.id, el)"
class="img-thumbnail template-thumb-canvas"
:class="{'border-primary': value === t.id}"></canvas>
<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>
@ -38,6 +70,16 @@
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%;
@ -47,11 +89,44 @@
object-fit: contain;
background: #fff;
}
/* Corner badge flagging *why* a layout that's otherwise available (fields filled in) still
failed to render - e.g. label.js's "bigger code than the tape allows" or "doesn't fit on this
tape" errors (see redraw()'s catch). Shows the short label directly (e.g. "too big") rather than
just an icon, so the reason reads at a glance; the full sentence is still the title tooltip. Not
shown for the more common "fields not filled in yet" case (see isSelectable/unavailableReason)
since that's already conveyed by the greyed-out thumbnail. */
.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 {drawFallbackLabel, preloadQrEncoder} from "@/label.js";
import {LABEL_TEMPLATES, templateIsAvailable, templateContent} from "@/label-layouts.js";
import {drawLabel, drawFallbackLabel, preloadQrEncoder} from "@/label.js";
import {LABEL_TEMPLATES, LABEL_TAGS, templateIsAvailable, templateContent} from "@/label-layouts.js";
export default {
name: "LabelLayoutPreview",
@ -73,6 +148,15 @@ export default {
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: {
@ -82,30 +166,67 @@ export default {
emits: ["input"],
data() {
return {
// Keyed by template id, holding the error message 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). Absent, not just falsy, for a
// template that last drew fine, so `t.id in failed` matches "has a message".
// 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.
// 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));
return [...recent, ...LABEL_TEMPLATES.filter(t => !recentIds.has(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;
@ -130,12 +251,25 @@ export default {
if (!this.isAvailable(t)) {
return "Not available - fill in the fields this layout needs above.";
}
return this.failed[t.id] ?? "";
return this.failed[t.id]?.message ?? "";
},
// Live per-template thumbnails. Always uses the content-fit fallback renderer (not the
// tape-fed one) regardless of printer connection - illustrative previews via CSS
// object-fit, not the to-be-printed-accurate canvas the main preview is.
// .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];
@ -147,18 +281,42 @@ export default {
canvas.width = 1;
canvas.height = 1;
delete this.failed[t.id];
delete this.warned[t.id];
delete this.qrInfo[t.id];
delete this.textInfo[t.id];
continue;
}
try {
drawFallbackLabel(canvas, content, "along");
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] = e.message;
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];
}
}
}