328 lines
15 KiB
Vue
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>
|