stash
This commit is contained in:
parent
8622e488a4
commit
eaae7c286a
3 changed files with 75 additions and 32 deletions
|
|
@ -4,15 +4,15 @@
|
||||||
<h5 class="card-title mb-0">Label layout</h5>
|
<h5 class="card-title mb-0">Label layout</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex flex-wrap gap-3">
|
<div class="template-grid d-flex flex-wrap align-items-start">
|
||||||
<div v-for="t in labelTemplates" :key="t.id" class="template-option text-center"
|
<div v-for="t in labelTemplates" :key="t.id" class="template-option d-flex flex-column text-center"
|
||||||
:class="{'template-option-disabled': !isAvailable(t)}"
|
:class="{'template-option-disabled': !isAvailable(t)}"
|
||||||
:title="isAvailable(t) ? '' : 'Not available - open this page from an item to fill in the fields this layout needs.'"
|
:title="isAvailable(t) ? '' : 'Not available - open this page from an item to fill in the fields this layout needs.'"
|
||||||
role="button" @click="isAvailable(t) && $emit('input', t.id)">
|
role="button" @click="isAvailable(t) && $emit('input', t.id)">
|
||||||
<canvas :ref="el => setTemplateCanvasRef(t.id, el)"
|
<canvas :ref="el => setTemplateCanvasRef(t.id, el)"
|
||||||
class="img-thumbnail template-thumb-canvas"
|
class="img-thumbnail template-thumb-canvas"
|
||||||
:class="{'border-primary': value === t.id}"></canvas>
|
:class="{'border-primary': value === t.id}"></canvas>
|
||||||
<div class="small mt-1"
|
<div class="small"
|
||||||
:class="{'fw-bold text-primary': value === t.id}">
|
:class="{'fw-bold text-primary': value === t.id}">
|
||||||
{{ t.name }}
|
{{ t.name }}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -24,6 +24,12 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* This build pins Bootstrap 4 (no gap-* utilities, those are Bootstrap 5.1+), so the spacing
|
||||||
|
here is plain CSS gap rather than a Bootstrap gap-N class. */
|
||||||
|
.template-grid {
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.template-option {
|
.template-option {
|
||||||
width: 20rem;
|
width: 20rem;
|
||||||
}
|
}
|
||||||
|
|
@ -46,7 +52,7 @@
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import {LABEL_TEMPLATES, drawFallbackLabel} from "@/label-drawing.js";
|
import {LABEL_TEMPLATES, drawFallbackLabel, templateIsAvailable, templateContent} from "@/label-drawing.js";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "LabelLayoutPreview",
|
name: "LabelLayoutPreview",
|
||||||
|
|
@ -89,14 +95,7 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
isAvailable(t) {
|
isAvailable(t) {
|
||||||
return [t.qr, t.text].filter(Boolean).every(key => this.fields[key] !== undefined);
|
return templateIsAvailable(t, this.fields);
|
||||||
},
|
|
||||||
|
|
||||||
contentFor(t) {
|
|
||||||
return {
|
|
||||||
qr: t.qr ? this.fields[t.qr] : null,
|
|
||||||
text: t.text ? this.fields[t.text] : null,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/* Live per-template thumbnails. Always uses the content-fit fallback renderer (rather
|
/* Live per-template thumbnails. Always uses the content-fit fallback renderer (rather
|
||||||
|
|
@ -109,7 +108,7 @@ export default {
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const content = this.contentFor(t);
|
const content = templateContent(t, this.fields);
|
||||||
if (!this.isAvailable(t) || (!content.qr && !content.text)) {
|
if (!this.isAvailable(t) || (!content.qr && !content.text)) {
|
||||||
canvas.width = 1;
|
canvas.width = 1;
|
||||||
canvas.height = 1;
|
canvas.height = 1;
|
||||||
|
|
|
||||||
|
|
@ -66,8 +66,9 @@ function drawQrLabel(canvas, qr, tape) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Each template names which field (see label-content.js's buildLabelFields) feeds its QR code
|
// Each template names which field (see label-content.js's buildLabelFields) feeds its QR code
|
||||||
// and/or its printed text - `null` means that half of the layout is skipped. A template is only
|
// and/or its printed text - `null` means that half of the layout is skipped. `text` can also be
|
||||||
// selectable once every field it names is actually available (see LabelLayoutPreview.vue).
|
// an array of field names, one per printed line, for a stacked multi-line layout. A template is
|
||||||
|
// only selectable once every field it names is actually available (see templateIsAvailable).
|
||||||
export const LABEL_TEMPLATES = [
|
export const LABEL_TEMPLATES = [
|
||||||
{id: "qr", name: "QR code only", description: "Just the code - smallest label, prints fastest.",
|
{id: "qr", name: "QR code only", description: "Just the code - smallest label, prints fastest.",
|
||||||
qr: "value", text: null},
|
qr: "value", text: null},
|
||||||
|
|
@ -83,25 +84,58 @@ export const LABEL_TEMPLATES = [
|
||||||
description: "Just the owning user's handle, as text only."},
|
description: "Just the owning user's handle, as text only."},
|
||||||
{id: "item-id", name: "Item ID", qr: null, text: "itemId",
|
{id: "item-id", name: "Item ID", qr: null, text: "itemId",
|
||||||
description: "Just the bare item id, as text only."},
|
description: "Just the bare item id, as text only."},
|
||||||
|
{id: "owner-id-text", name: "Owner + item ID", qr: null, text: ["userHandle", "itemId"],
|
||||||
|
description: "The owner's handle and the item id, as two lines of text - no code."},
|
||||||
{id: "item-url-qr-handle", name: "Item URL + handle", qr: "itemUrl", text: "itemHandle",
|
{id: "item-url-qr-handle", name: "Item URL + handle", qr: "itemUrl", text: "itemHandle",
|
||||||
description: "Scannable item URL, with the item's compact handle printed alongside."},
|
description: "Scannable item URL, with the item's compact handle printed alongside."},
|
||||||
{id: "item-url-qr-owner", name: "Item URL + owner", qr: "itemUrl", text: "userHandle",
|
{id: "item-url-qr-owner", name: "Item URL + owner", qr: "itemUrl", text: "userHandle",
|
||||||
description: "Scannable item URL, with the owner's handle printed alongside."},
|
description: "Scannable item URL, with the owner's handle printed alongside."},
|
||||||
{id: "item-url-qr-id", name: "Item URL + item ID", qr: "itemUrl", text: "itemId",
|
{id: "item-url-qr-id", name: "Item URL + item ID", qr: "itemUrl", text: "itemId",
|
||||||
description: "Scannable item URL, with the bare item id printed alongside."},
|
description: "Scannable item URL, with the bare item id printed alongside."},
|
||||||
|
{id: "item-url-qr-owner-id", name: "Item URL + owner + ID", qr: "itemUrl", text: ["userHandle", "itemId"],
|
||||||
|
description: "Scannable item URL, with the owner's handle and the item id on two lines alongside."},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// keysOf/templateIsAvailable/templateContent are the single place that understands the `qr`/
|
||||||
|
// `text` field-name shape above (including `text` sometimes being an array) - both
|
||||||
|
// LabelLayoutPreview.vue's thumbnail grid and Print.vue's big preview resolve a template through
|
||||||
|
// these rather than each re-implementing the same lookup.
|
||||||
|
function keysOf(spec) {
|
||||||
|
if (!spec) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Array.isArray(spec) ? spec : [spec];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function templateIsAvailable(t, fields) {
|
||||||
|
return [...keysOf(t.qr), ...keysOf(t.text)].every(key => fields[key] !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Resolves a template's field names against actual field values. `text` comes back as an array
|
||||||
|
whenever the template's `text` spec is an array (multi-line), or a plain string otherwise -
|
||||||
|
drawLabel/drawFallbackLabel below accept either. */
|
||||||
|
export function templateContent(t, fields) {
|
||||||
|
const resolve = (spec) => {
|
||||||
|
if (!spec) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Array.isArray(spec) ? spec.map(key => fields[key]) : fields[spec];
|
||||||
|
};
|
||||||
|
return {qr: resolve(t.qr), text: resolve(t.text)};
|
||||||
|
}
|
||||||
|
|
||||||
function measureAtHeight(ctx, text, px) {
|
function measureAtHeight(ctx, text, px) {
|
||||||
ctx.font = `${px}px sans-serif`;
|
ctx.font = `${px}px sans-serif`;
|
||||||
return ctx.measureText(text).width;
|
return ctx.measureText(text).width;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Picks the largest integer font size (down to a floor) that fits `text` on one line within
|
/* Picks the largest integer font size (down to a floor) that fits every one of `lines` within
|
||||||
maxWidth - this is a label, not a paragraph, so we shrink to fit rather than wrap. */
|
maxWidth, stacked within maxHeight - this is a label, not a paragraph, so each line shrinks to
|
||||||
function fitTextSize(ctx, text, maxWidth, maxHeight) {
|
fit rather than wrapping. */
|
||||||
|
function fitTextSize(ctx, lines, maxWidth, maxHeight) {
|
||||||
const minPx = 8;
|
const minPx = 8;
|
||||||
let px = Math.max(minPx, Math.floor(maxHeight));
|
let px = Math.max(minPx, Math.floor(maxHeight / lines.length));
|
||||||
while (px > minPx && measureAtHeight(ctx, text, px) > maxWidth) {
|
while (px > minPx && lines.some(line => measureAtHeight(ctx, line, px) > maxWidth)) {
|
||||||
px -= 1;
|
px -= 1;
|
||||||
}
|
}
|
||||||
return px;
|
return px;
|
||||||
|
|
@ -111,6 +145,7 @@ function fitTextSize(ctx, text, maxWidth, maxHeight) {
|
||||||
generalizing it) so the plain QR-only path - the common case - is untouched by this. */
|
generalizing it) so the plain QR-only path - the common case - is untouched by this. */
|
||||||
function drawLabelWithText(canvas, tape, qrContent, textContent) {
|
function drawLabelWithText(canvas, tape, qrContent, textContent) {
|
||||||
const qr = qrContent ? QRCode.create(qrContent) : null;
|
const qr = qrContent ? QRCode.create(qrContent) : null;
|
||||||
|
const lines = Array.isArray(textContent) ? textContent : [textContent];
|
||||||
const availableHeight = tape.printAreaPx;
|
const availableHeight = tape.printAreaPx;
|
||||||
const maxLength = tape.printLengthPx
|
const maxLength = tape.printLengthPx
|
||||||
? tape.printLengthPx - tape.leadPx - TRAILING_PADDING_PX
|
? tape.printLengthPx - tape.leadPx - TRAILING_PADDING_PX
|
||||||
|
|
@ -137,8 +172,8 @@ function drawLabelWithText(canvas, tape, qrContent, textContent) {
|
||||||
+ "try a wider tape or the QR-only layout.");
|
+ "try a wider tape or the QR-only layout.");
|
||||||
}
|
}
|
||||||
const measureCtx = canvas.getContext("2d");
|
const measureCtx = canvas.getContext("2d");
|
||||||
const textPx = fitTextSize(measureCtx, textContent, textBudget, availableHeight);
|
const textPx = fitTextSize(measureCtx, lines, textBudget, availableHeight);
|
||||||
const textWidth = measureAtHeight(measureCtx, textContent, textPx);
|
const textWidth = Math.max(...lines.map(line => measureAtHeight(measureCtx, line, textPx)));
|
||||||
if (textBudget !== Infinity && textWidth > textBudget) {
|
if (textBudget !== Infinity && textWidth > textBudget) {
|
||||||
throw new Error("This text doesn't fit on this tape even at the smallest readable size — "
|
throw new Error("This text doesn't fit on this tape even at the smallest readable size — "
|
||||||
+ "try a shorter value, a wider tape, or a bigger label.");
|
+ "try a shorter value, a wider tape, or a bigger label.");
|
||||||
|
|
@ -175,7 +210,14 @@ function drawLabelWithText(canvas, tape, qrContent, textContent) {
|
||||||
ctx.font = `${textPx}px sans-serif`;
|
ctx.font = `${textPx}px sans-serif`;
|
||||||
ctx.textBaseline = "middle";
|
ctx.textBaseline = "middle";
|
||||||
ctx.textAlign = "left";
|
ctx.textAlign = "left";
|
||||||
ctx.fillText(textContent, cursor, canvas.height / 2);
|
// Lines stack as a block vertically centered in the label, rather than each line centered on
|
||||||
|
// its own - keeps a two-line block reading as one unit instead of drifting apart.
|
||||||
|
const lineHeight = Math.ceil(textPx * 1.15);
|
||||||
|
let y = (canvas.height - lineHeight * lines.length) / 2 + lineHeight / 2;
|
||||||
|
for (const line of lines) {
|
||||||
|
ctx.fillText(line, cursor, y);
|
||||||
|
y += lineHeight;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dispatches to the right tape-fed layout - drawQrLabel is untouched so the plain QR-only
|
/* Dispatches to the right tape-fed layout - drawQrLabel is untouched so the plain QR-only
|
||||||
|
|
@ -224,6 +266,7 @@ const FALLBACK_TEXT_MARGIN_PX = 16; /* left/right margin around a text-only/qr-t
|
||||||
to fit into here, so - unlike drawLabelWithText - the canvas just grows to fit its content. */
|
to fit into here, so - unlike drawLabelWithText - the canvas just grows to fit its content. */
|
||||||
function drawFallbackLabelWithText(canvas, qrContent, textContent) {
|
function drawFallbackLabelWithText(canvas, qrContent, textContent) {
|
||||||
const qr = qrContent ? QRCode.create(qrContent) : null;
|
const qr = qrContent ? QRCode.create(qrContent) : null;
|
||||||
|
const lines = Array.isArray(textContent) ? textContent : [textContent];
|
||||||
|
|
||||||
let scale = 0, qrSize = 0;
|
let scale = 0, qrSize = 0;
|
||||||
if (qr) {
|
if (qr) {
|
||||||
|
|
@ -234,8 +277,8 @@ function drawFallbackLabelWithText(canvas, qrContent, textContent) {
|
||||||
|
|
||||||
const gap = qr ? Math.round(FALLBACK_LABEL_HEIGHT_PX * 0.15) : 0;
|
const gap = qr ? Math.round(FALLBACK_LABEL_HEIGHT_PX * 0.15) : 0;
|
||||||
const measureCtx = canvas.getContext("2d");
|
const measureCtx = canvas.getContext("2d");
|
||||||
const textPx = fitTextSize(measureCtx, textContent, Infinity, FALLBACK_LABEL_HEIGHT_PX);
|
const textPx = fitTextSize(measureCtx, lines, Infinity, FALLBACK_LABEL_HEIGHT_PX);
|
||||||
const textWidth = measureAtHeight(measureCtx, textContent, textPx);
|
const textWidth = Math.max(...lines.map(line => measureAtHeight(measureCtx, line, textPx)));
|
||||||
|
|
||||||
const height = Math.max(qrSize, FALLBACK_LABEL_HEIGHT_PX);
|
const height = Math.max(qrSize, FALLBACK_LABEL_HEIGHT_PX);
|
||||||
const width = qrSize + gap + textWidth + FALLBACK_TEXT_MARGIN_PX * 2;
|
const width = qrSize + gap + textWidth + FALLBACK_TEXT_MARGIN_PX * 2;
|
||||||
|
|
@ -267,7 +310,12 @@ function drawFallbackLabelWithText(canvas, qrContent, textContent) {
|
||||||
ctx.font = `${textPx}px sans-serif`;
|
ctx.font = `${textPx}px sans-serif`;
|
||||||
ctx.textBaseline = "middle";
|
ctx.textBaseline = "middle";
|
||||||
ctx.textAlign = "left";
|
ctx.textAlign = "left";
|
||||||
ctx.fillText(textContent, cursor, height / 2);
|
const lineHeight = Math.ceil(textPx * 1.15);
|
||||||
|
let y = (height - lineHeight * lines.length) / 2 + lineHeight / 2;
|
||||||
|
for (const line of lines) {
|
||||||
|
ctx.fillText(line, cursor, y);
|
||||||
|
y += lineHeight;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dispatches to the right fallback layout - drawQrSquare is untouched so the plain QR-only
|
/* Dispatches to the right fallback layout - drawQrSquare is untouched so the plain QR-only
|
||||||
|
|
|
||||||
|
|
@ -150,7 +150,7 @@ import LabelLayoutPreview from "@/components/LabelLayoutPreview.vue";
|
||||||
|
|
||||||
import {MultiPrinterBlob, canvasToBitmap, bitmapToCanvas} from "../../vendor/weblabel.js";
|
import {MultiPrinterBlob, canvasToBitmap, bitmapToCanvas} from "../../vendor/weblabel.js";
|
||||||
import {buildLabelContent, buildLabelFields} from "@/label-content.js";
|
import {buildLabelContent, buildLabelFields} from "@/label-content.js";
|
||||||
import {LABEL_TEMPLATES, tapeFromStatus, drawLabel, drawFallbackLabel} from "@/label-drawing.js";
|
import {LABEL_TEMPLATES, tapeFromStatus, drawLabel, drawFallbackLabel, templateContent} from "@/label-drawing.js";
|
||||||
|
|
||||||
// Served verbatim from public/vendor/ rather than bundled: libweblabel.js's
|
// Served verbatim from public/vendor/ rather than bundled: libweblabel.js's
|
||||||
// own emscripten glue resolves its .wasm sibling relative to *its own*
|
// own emscripten glue resolves its .wasm sibling relative to *its own*
|
||||||
|
|
@ -210,11 +210,7 @@ export default {
|
||||||
return LABEL_TEMPLATES.find(t => t.id === this.selectedTemplate) || LABEL_TEMPLATES[0];
|
return LABEL_TEMPLATES.find(t => t.id === this.selectedTemplate) || LABEL_TEMPLATES[0];
|
||||||
},
|
},
|
||||||
selectedContent() {
|
selectedContent() {
|
||||||
const t = this.currentTemplate;
|
return templateContent(this.currentTemplate, this.fields);
|
||||||
return {
|
|
||||||
qr: t.qr ? this.fields[t.qr] : null,
|
|
||||||
text: t.text ? this.fields[t.text] : null,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
deviceRows() {
|
deviceRows() {
|
||||||
if (!this.blob) {
|
if (!this.blob) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue