This commit is contained in:
j3d1 2026-08-19 23:09:21 +02:00
parent 4787acd8eb
commit 67c7415c3b
6 changed files with 691 additions and 407 deletions

View file

@ -7,7 +7,7 @@
<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': !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 - fill in the fields this layout needs above.'"
role="button" @click="isAvailable(t) && $emit('input', t.id)">
<canvas :ref="el => setTemplateCanvasRef(t.id, el)"
class="img-thumbnail template-thumb-canvas"
@ -52,12 +52,13 @@
</style>
<script>
import {LABEL_TEMPLATES, drawFallbackLabel, templateIsAvailable, templateContent} from "@/label-drawing.js";
import {drawFallbackLabel} from "@/label.js";
import {LABEL_TEMPLATES, templateIsAvailable, templateContent} from "@/label-layouts.js";
export default {
name: "LabelLayoutPreview",
props: {
// Named content fields the templates draw from (see label-content.js's buildLabelFields)
// Named content fields the templates draw from (see label.js's buildLabelFields)
// - kept in sync by the parent, not owned here. A field missing from this object (rather
// than present-but-empty) means a template that needs it is unavailable right now.
fields: {
@ -109,7 +110,7 @@ export default {
continue;
}
const content = templateContent(t, this.fields);
if (!this.isAvailable(t) || (!content.qr && !content.text)) {
if (!this.isAvailable(t) || !content) {
canvas.width = 1;
canvas.height = 1;
continue;

View file

@ -1,45 +0,0 @@
// Turns a {kind, components} prefill into the literal string a print label should show/encode.
// Keeping this keyed by `kind` rather than having each caller build its own string means the
// format for a given kind of label content only has to be gotten right in one place.
export const LABEL_CONTENT_BUILDERS = {
// The self-contained Item URL (see docs/design-in-progress/items-labels.md) - what a
// printed label actually encodes, since scanning it has to resolve the right
// frontend/backend/item with no other context, not just this browser's history.
"item-url": ({user, id}) => `${window.location.origin}/i/${user}/${id}`,
};
export function buildLabelContent(prefill) {
if (!prefill) {
return "";
}
const build = LABEL_CONTENT_BUILDERS[prefill.kind];
return build ? build(prefill.components) : "";
}
// Named fields the field-specific label templates (see label-drawing.js's LABEL_TEMPLATES) draw
// from - keyed by `kind` for the same reason LABEL_CONTENT_BUILDERS is. A field missing from the
// result (rather than present-but-empty) is what LabelLayoutPreview.vue treats as "not available",
// so builders should only include a field once its inputs actually check out.
const LABEL_FIELD_BUILDERS = {
"item-url": ({user, id}) => {
if (!user || !id) {
return {};
}
return {
itemUrl: `${window.location.origin}/i/${user}/${id}`,
// The compact "owner handle + id" form from docs/design-in-progress/items-labels.md -
// meaningful only where context already makes clear it's a Toolshed item, unlike itemUrl.
itemHandle: `${user}:${id}`,
userHandle: user,
itemId: String(id),
};
},
};
export function buildLabelFields(prefill) {
if (!prefill) {
return {};
}
const build = LABEL_FIELD_BUILDERS[prefill.kind];
return build ? build(prefill.components) : {};
}

View file

@ -1,329 +0,0 @@
import QRCode from "qrcode";
const TRAILING_PADDING_PX = 3; /* blank columns after the cut, same idea as the leading margin */
/* A quiet zone narrower than the spec's usual 4 modules: the printer's own
feed margin already keeps the code clear of the tape edge and the cut. */
const QUIET_ZONE_MODULES = 2;
/* Sizing a label needs numbers only the driver can supply. */
export function tapeFromStatus(status) {
const printAreaPx = status?.tape?.printAreaPx;
const dpi = status?.printer?.dpi;
if (!(printAreaPx > 0) || !(dpi > 0)) {
return null;
}
return {
printAreaPx,
dpi,
mediaWidthMm: status.tape.mediaWidthMm,
printLengthPx: status.tape.printLengthPx > 0 ? status.tape.printLengthPx : 0,
/* Brother's documented margin for the mounted tape, in raster columns. */
leadPx: status.tape.marginsMm
? Math.round(status.tape.marginsMm * dpi / 25.4)
: TRAILING_PADDING_PX,
};
}
/*
Draw the QR code as large as the tape allows, centered in a square, with an
integer number of pixels per module so it stays crisp at printer
resolution rather than blurring at a fractional scale.
*/
function drawQrLabel(canvas, qr, tape) {
const modules = qr.modules.size + QUIET_ZONE_MODULES * 2;
const maxLength = tape.printLengthPx
? tape.printLengthPx - tape.leadPx - TRAILING_PADDING_PX
: Infinity;
const scale = Math.floor(Math.min(tape.printAreaPx, maxLength) / modules);
if (!(scale >= 1)) {
throw new Error("This text needs a bigger QR code than the tape allows — "
+ "try a shorter value or a wider tape.");
}
const square = modules * scale;
const width = tape.printLengthPx || (square + tape.leadPx + TRAILING_PADDING_PX);
canvas.width = width;
canvas.height = tape.printAreaPx;
const ctx = canvas.getContext("2d", {willReadFrequently: true});
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, width, canvas.height);
const left = tape.leadPx + Math.floor((width - tape.leadPx - TRAILING_PADDING_PX - square) / 2);
const top = Math.floor((canvas.height - square) / 2);
ctx.fillStyle = "#000";
for (let row = 0; row < qr.modules.size; row++) {
for (let col = 0; col < qr.modules.size; col++) {
if (qr.modules.get(row, col)) {
ctx.fillRect(
left + (col + QUIET_ZONE_MODULES) * scale,
top + (row + QUIET_ZONE_MODULES) * scale,
scale, scale,
);
}
}
}
}
// 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. `text` can also be
// 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 = [
{id: "qr", name: "QR code only", description: "Just the code - smallest label, prints fastest.",
qr: "value", text: null},
{id: "qr-text", name: "QR code + text", description: "The code with the encoded text printed next to it.",
qr: "value", text: "value"},
{id: "text", name: "Text only", description: "No code, just the text itself, as large as it fits.",
qr: null, text: "value"},
{id: "item-handle", name: "Item handle", qr: null, text: "itemHandle",
description: "The compact owner@domain:id handle - meaningful in-app, not scannable on its own."},
{id: "item-url", name: "Item URL", qr: null, text: "itemUrl",
description: "The full item URL as text, with no code - for copying rather than scanning."},
{id: "owner-handle", name: "Owner handle", qr: null, text: "userHandle",
description: "Just the owning user's handle, as text only."},
{id: "item-id", name: "Item ID", qr: null, text: "itemId",
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",
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",
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",
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) {
ctx.font = `${px}px sans-serif`;
return ctx.measureText(text).width;
}
/* Picks the largest integer font size (down to a floor) that fits every one of `lines` within
maxWidth, stacked within maxHeight - this is a label, not a paragraph, so each line shrinks to
fit rather than wrapping. */
function fitTextSize(ctx, lines, maxWidth, maxHeight) {
const minPx = 8;
let px = Math.max(minPx, Math.floor(maxHeight / lines.length));
while (px > minPx && lines.some(line => measureAtHeight(ctx, line, px) > maxWidth)) {
px -= 1;
}
return px;
}
/* The qr-text/text-only layouts, tape-fed. Kept separate from drawQrLabel above (rather than
generalizing it) so the plain QR-only path - the common case - is untouched by this. */
function drawLabelWithText(canvas, tape, qrContent, textContent) {
const qr = qrContent ? QRCode.create(qrContent) : null;
const lines = Array.isArray(textContent) ? textContent : [textContent];
const availableHeight = tape.printAreaPx;
const maxLength = tape.printLengthPx
? tape.printLengthPx - tape.leadPx - TRAILING_PADDING_PX
: Infinity;
let scale = 0, qrSize = 0;
if (qr) {
const modules = qr.modules.size + QUIET_ZONE_MODULES * 2;
// The QR only gets half the length budget on a fixed-length tape, so a long text value
// can't starve it down to unreadable - the rest goes to the text next to it.
const qrBudget = maxLength === Infinity ? Infinity : maxLength / 2;
scale = Math.floor(Math.min(availableHeight, qrBudget) / modules);
if (!(scale >= 1)) {
throw new Error("This text needs a bigger QR code than the tape allows — "
+ "try a shorter value, a wider tape, or the text-only layout.");
}
qrSize = modules * scale;
}
const gap = qr ? Math.round(availableHeight * 0.15) : 0;
const textBudget = maxLength === Infinity ? Infinity : maxLength - qrSize - gap;
if (!(textBudget > 0)) {
throw new Error("No room left for the text next to the QR code on this tape — "
+ "try a wider tape or the QR-only layout.");
}
const measureCtx = canvas.getContext("2d");
const textPx = fitTextSize(measureCtx, lines, textBudget, availableHeight);
const textWidth = Math.max(...lines.map(line => measureAtHeight(measureCtx, line, textPx)));
if (textBudget !== Infinity && textWidth > textBudget) {
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.");
}
const contentWidth = qrSize + gap + textWidth;
const width = tape.printLengthPx || Math.ceil(contentWidth + tape.leadPx + TRAILING_PADDING_PX);
canvas.width = width;
canvas.height = availableHeight;
const ctx = canvas.getContext("2d", {willReadFrequently: true});
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, width, canvas.height);
ctx.fillStyle = "#000";
let cursor = tape.leadPx + Math.floor((width - tape.leadPx - TRAILING_PADDING_PX - contentWidth) / 2);
if (qr) {
const top = Math.floor((canvas.height - qrSize) / 2);
for (let row = 0; row < qr.modules.size; row++) {
for (let col = 0; col < qr.modules.size; col++) {
if (qr.modules.get(row, col)) {
ctx.fillRect(
cursor + (col + QUIET_ZONE_MODULES) * scale,
top + (row + QUIET_ZONE_MODULES) * scale,
scale, scale,
);
}
}
}
cursor += qrSize + gap;
}
ctx.font = `${textPx}px sans-serif`;
ctx.textBaseline = "middle";
ctx.textAlign = "left";
// 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
layout keeps its exact original pixel output. `content` is {qr, text}, each either the string
to encode/print or null/undefined to skip that half of the layout (see LABEL_TEMPLATES). */
export function drawLabel(canvas, tape, content) {
if (content.qr && !content.text) {
drawQrLabel(canvas, QRCode.create(content.qr), tape);
} else {
drawLabelWithText(canvas, tape, content.qr, content.text);
}
}
const FALLBACK_SCALE_PX = 8; /* pixels per QR module in the no-webusb preview/PNG */
const FALLBACK_QUIET_ZONE_MODULES = 4; /* the spec's usual quiet zone - there's no printer feed margin to lean on here */
/* Same idea as drawQrLabel, but without a real device to ask for tape dimensions: just a
plain square QR code, sized for a PNG someone downloads and prints some other way. */
function drawQrSquare(canvas, qr) {
const modules = qr.modules.size + FALLBACK_QUIET_ZONE_MODULES * 2;
const size = modules * FALLBACK_SCALE_PX;
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d", {willReadFrequently: true});
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, size, size);
ctx.fillStyle = "#000";
for (let row = 0; row < qr.modules.size; row++) {
for (let col = 0; col < qr.modules.size; col++) {
if (qr.modules.get(row, col)) {
ctx.fillRect(
(col + FALLBACK_QUIET_ZONE_MODULES) * FALLBACK_SCALE_PX,
(row + FALLBACK_QUIET_ZONE_MODULES) * FALLBACK_SCALE_PX,
FALLBACK_SCALE_PX, FALLBACK_SCALE_PX,
);
}
}
}
}
const FALLBACK_LABEL_HEIGHT_PX = 200; /* target content height for the qr-text/text-only fallback layouts */
const FALLBACK_TEXT_MARGIN_PX = 16; /* left/right margin around a text-only/qr-text fallback label */
/* The qr-text/text-only layouts for the no-webusb fallback preview/PNG. There's no real tape
to fit into here, so - unlike drawLabelWithText - the canvas just grows to fit its content. */
function drawFallbackLabelWithText(canvas, qrContent, textContent) {
const qr = qrContent ? QRCode.create(qrContent) : null;
const lines = Array.isArray(textContent) ? textContent : [textContent];
let scale = 0, qrSize = 0;
if (qr) {
const modules = qr.modules.size + FALLBACK_QUIET_ZONE_MODULES * 2;
scale = Math.max(1, Math.floor(FALLBACK_LABEL_HEIGHT_PX / modules));
qrSize = modules * scale;
}
const gap = qr ? Math.round(FALLBACK_LABEL_HEIGHT_PX * 0.15) : 0;
const measureCtx = canvas.getContext("2d");
const textPx = fitTextSize(measureCtx, lines, Infinity, FALLBACK_LABEL_HEIGHT_PX);
const textWidth = Math.max(...lines.map(line => measureAtHeight(measureCtx, line, textPx)));
const height = Math.max(qrSize, FALLBACK_LABEL_HEIGHT_PX);
const width = qrSize + gap + textWidth + FALLBACK_TEXT_MARGIN_PX * 2;
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d", {willReadFrequently: true});
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = "#000";
let cursor = FALLBACK_TEXT_MARGIN_PX;
if (qr) {
const top = Math.floor((height - qrSize) / 2);
for (let row = 0; row < qr.modules.size; row++) {
for (let col = 0; col < qr.modules.size; col++) {
if (qr.modules.get(row, col)) {
ctx.fillRect(
cursor + (col + FALLBACK_QUIET_ZONE_MODULES) * scale,
top + (row + FALLBACK_QUIET_ZONE_MODULES) * scale,
scale, scale,
);
}
}
}
cursor += qrSize + gap;
}
ctx.font = `${textPx}px sans-serif`;
ctx.textBaseline = "middle";
ctx.textAlign = "left";
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
layout keeps its exact original pixel output. `content` is {qr, text}, see drawLabel above. */
export function drawFallbackLabel(canvas, content) {
if (content.qr && !content.text) {
drawQrSquare(canvas, QRCode.create(content.qr));
} else {
drawFallbackLabelWithText(canvas, content.qr, content.text);
}
}

View file

@ -0,0 +1,196 @@
// Each template's `layout` is a tree as described in label.js, with "qrcode"/"text" leaves'
// `content` a function from the resolved field values (see label.js's buildLabelFields) to what
// they render - `null`/`undefined` from that function means the field isn't available yet (see
// templateIsAvailable below). A template is only selectable once every leaf's `content` resolves
// to a value.
const GAP = {type: "empty", "min-width": "1mm", "min-height": "1mm"};
export const LABEL_TEMPLATES = [
{
id: "qr", name: "QR code only", description: "Just the code - smallest label, prints fastest.",
required_vars: ["value"],
layout: [{type: "qrcode", content: c => c.value}]
},
{
id: "qr-text", name: "QR code + text", description: "The code with the encoded text printed next to it.",
required_vars: ["value"],
layout: [{type: "qrcode", content: c => c.value}, GAP, {type: "text", content: c => c.value?.split("\n")}]
},
{
id: "qr-text-below", name: "QR code + text below",
description: "The code with the encoded text printed below it.",
required_vars: ["value"],
layout: [[{type: "qrcode", content: c => c.value}, GAP, {type: "text", content: c => c.value?.split("\n")}]]
},
{
id: "id-qr-text-vertical", name: "ID + QR code + text below",
description: "The code with the encoded text printed below it.",
required_vars: ["itemId", "value", "userHandle"],
layout: [[{type: "text", content: c => "Item: "+c.itemId}, GAP, {
type: "qrcode",
content: c => c.value
}, GAP, {type: "text", content: c => c.userHandle}]]
},
{
id: "text", name: "Text only", description: "No code, just the text itself, as large as it fits.",
required_vars: ["value"],
layout: [{type: "text", content: c => c.value?.split("\n")}]
},
{
id: "item-handle", name: "Item handle",
description: "The compact owner@domain:id handle - meaningful in-app, not scannable on its own.",
required_vars: ["itemHandle"],
layout: [{type: "text", content: c => c.itemHandle}]
},
{
id: "item-url", name: "Item URL",
description: "The full item URL as text, with no code - for copying rather than scanning.",
required_vars: ["itemUrl"],
layout: [{type: "text", content: c => c.itemUrl}]
},
{
id: "owner-handle", name: "Owner handle", description: "Just the owning user's handle, as text only.",
required_vars: ["userHandle"],
layout: [{type: "text", content: c => c.userHandle}]
},
{
id: "item-id", name: "Item ID", description: "Just the bare item id, as text only.",
required_vars: ["itemId"],
layout: [{type: "text", content: c => c.itemId}]
},
{
id: "owner-id-text", name: "Owner + item ID",
description: "The owner's handle and the item id, as two lines of text - no code.",
required_vars: ["userHandle", "itemId"],
layout: [{type: "text", content: c => [c.userHandle, c.itemId]}]
},
{
id: "item-url-qr-handle", name: "Item URL + handle",
description: "Scannable item URL, with the item's compact handle printed alongside.",
required_vars: ["itemUrl", "itemHandle"],
layout: [{type: "qrcode", content: c => c.itemUrl}, GAP, {type: "text", content: c => c.itemHandle}]
},
{
id: "item-url-qr-owner", name: "Item URL + owner",
description: "Scannable item URL, with the owner's handle printed alongside.",
required_vars: ["itemUrl", "userHandle"],
layout: [{type: "qrcode", content: c => c.itemUrl}, GAP, {type: "text", content: c => c.userHandle}]
},
{
id: "item-url-qr-id", name: "Item URL + item ID",
description: "Scannable item URL, with the bare item id printed alongside.",
required_vars: ["itemUrl", "itemId"],
layout: [{type: "qrcode", content: c => c.itemUrl}, GAP, {type: "text", content: c => c.itemId}]
},
{
id: "item-url-qr-owner-id", name: "Item URL + owner + ID",
description: "Scannable item URL, with the owner's handle and the item id on two lines alongside.",
required_vars: ["itemUrl", "userHandle", "itemId"],
layout: [{type: "qrcode", content: c => c.itemUrl}, GAP, {type: "text", content: c => [c.userHandle, c.itemId]}]
},
{
id: "item-url-qr-owner-id2", name: "Item URL + owner + ID",
description: "Scannable item URL, with the owner's handle and the item id on two lines alongside.",
required_vars: ["itemUrl", "userHandle", "itemId"],
layout: [{type: "qrcode", content: c => c.itemUrl}, GAP, [{
type: "text",
content: c => c.userHandle
}, GAP, {type: "text", content: c => c.itemId}]]
},
];
// Every field name any template's required_vars names, in first-seen order.
export const KNOWN_VARS = [...new Set(LABEL_TEMPLATES.flatMap(t => t.required_vars))];
// A derived var is a format string calculated from other vars rather than typed directly - it
// doesn't get its own input, just a read-only, live-recalculated display next to the ones that
// do (see Print.vue and withDerivedVars below). `inputs` names every var (base or, in principle,
// derived) `calc` reads - declared up front rather than inferred from calc's body so BASE_VARS
// below can include a var like "webdomain" that only feeds a calculation and that no template
// ever references directly.
export const DERIVED_VARS = {
// The self-contained Item URL (see docs/design-in-progress/items-labels.md) - what a printed
// label actually encodes, since scanning it has to resolve the right frontend/backend/item
// with no other context, not just this browser's history. `webdomain` defaults to this
// browser's own origin (see Print.vue) but is editable, since any frontend can resolve any
// handle - the label doesn't have to point back at whichever frontend happened to print it.
itemUrl: {
inputs: ["webdomain", "userHandle", "itemId"],
calc: (f) => `${f.webdomain}/i/${f.userHandle}/${f.itemId}`,
},
// The compact "owner handle + id" form from docs/design-in-progress/items-labels.md -
// meaningful only where context already makes clear it's a Toolshed item, unlike itemUrl.
itemHandle: {
inputs: ["userHandle", "itemId"],
calc: (f) => `${f.userHandle}:${f.itemId}`,
},
};
// What Print.vue's content form offers a plain text input for: every KNOWN_VAR a template
// references directly, minus the derived ones, plus every var a DERIVED_VARS calculation itself
// needs (like "webdomain", which no template ever names). A template still lights up only once
// every one of its own required_vars, base or derived, has a value (see templateIsAvailable
// below).
export const BASE_VARS = [...new Set([
...KNOWN_VARS.filter(v => !(v in DERIVED_VARS)),
...Object.values(DERIVED_VARS).flatMap(d => d.inputs).filter(v => !(v in DERIVED_VARS)),
])];
// Runs every DERIVED_VARS calculation against `fields` (already holding the base vars - see
// Print.vue), returning a copy with each one's result added wherever all of its own inputs are
// present, so a caller never has to know DERIVED_VARS' internal {inputs, calc} shape.
export function withDerivedVars(fields) {
const result = {...fields};
for (const [name, {inputs, calc}] of Object.entries(DERIVED_VARS)) {
if (inputs.every(v => result[v])) {
result[name] = calc(result);
}
}
return result;
}
function walkLeaves(node, fn) {
if (Array.isArray(node)) {
node.forEach(child => walkLeaves(child, fn));
} else {
fn(node);
}
}
function mapTree(node, fn) {
return Array.isArray(node) ? node.map(child => mapTree(child, fn)) : fn(node);
}
// A content leaf's resolved value counts as present only if every part of it is - a single
// string for "qrcode"/plain "text", every line for a multi-line "text" (see LABEL_TEMPLATES'
// "owner-id-text" and "item-url-qr-owner-id").
function isResolved(value) {
return Array.isArray(value) ? value.every(isResolved) : value !== undefined && value !== null;
}
// LabelLayoutPreview.vue's thumbnail grid and Print.vue's big preview both resolve a template
// through these two functions rather than each re-implementing the leaf-walking/field-resolving
// logic itself.
export function templateIsAvailable(t, fields) {
let available = true;
walkLeaves(t.layout, leaf => {
if (leaf.type !== "empty" && !isResolved(leaf.content(fields))) {
available = false;
}
});
return available;
}
/* Resolves a template's `content` functions against actual field values, turning its layout
tree into one ready for label.js's drawLabel/drawFallbackLabel - or null if there's nothing to
render yet (every leaf's value is still empty, e.g. before the user has typed anything). */
export function templateContent(t, fields) {
const tree = mapTree(t.layout, leaf => leaf.type === "empty" ? leaf : {...leaf, value: leaf.content(fields)});
let hasContent = false;
walkLeaves(tree, leaf => {
if (leaf.type !== "empty" && leaf.value && (!Array.isArray(leaf.value) || leaf.value.some(Boolean))) {
hasContent = true;
}
});
return hasContent ? tree : null;
}

395
frontend/src/label.js Normal file
View file

@ -0,0 +1,395 @@
import QRCode from "qrcode";
const TRAILING_PADDING_PX = 3; /* blank columns after the cut, same idea as the leading margin */
/* Sizing a label needs numbers only the driver can supply. */
export function tapeFromStatus(status) {
const printAreaPx = status?.tape?.printAreaPx;
const dpi = status?.printer?.dpi;
if (!(printAreaPx > 0) || !(dpi > 0)) {
return null;
}
return {
printAreaPx,
dpi,
mediaWidthMm: status.tape.mediaWidthMm,
printLengthPx: status.tape.printLengthPx > 0 ? status.tape.printLengthPx : 0,
/* Brother's documented margin for the mounted tape, in raster columns. */
leadPx: status.tape.marginsMm
? Math.round(status.tape.marginsMm * dpi / 25.4)
: TRAILING_PADDING_PX,
};
}
/*
A layout is a tree built from two shapes, alternating orientation by nesting depth:
- An array is a "split" node: its children sit side by side (a *row*) at even depth
(the root, depth 0, is always a row), or stacked (a *column*) at odd depth. To turn a
row into a column, wrap it in an extra one-element array - that array is one depth
deeper, so its lone child (the original row) is now read at odd depth.
- An object is a leaf: {type: "qrcode", content} / {type: "text", content} draw a QR code
or text block, where `content` is a function from the resolved field values to the
string (or, for "text", an array of strings - one per line) to render. {type: "empty",
"min-width": "2mm"} / {type: "empty", "min-height": "2mm"} is a spacer with no ink of
its own - the *only* way padding/gaps enter a layout, since nothing here draws a
border, margin or gap on its own. An "empty" leaf's dimension always names the axis its
enclosing split flows along: "min-width" inside a row, "min-height" inside a column.
See label-layouts.js's LABEL_TEMPLATES for concrete trees.
*/
const QR_ASPECT = 1; /* a QR code is always square */
const TEXT_REFERENCE_PX = 100; /* font size text leaves measure their natural aspect ratio at */
const MIN_READABLE_TEXT_PX = 8; /* below this, a layout is rejected rather than rendered unreadably small */
function isSplit(node) {
return Array.isArray(node);
}
function parseMm(value, key) {
const match = typeof value === "string" && /^([\d.]+)mm$/.exec(value);
if (!match) {
throw new Error(`An "empty" layout node needs a "${key}" like "2mm", got ${JSON.stringify(value)}.`);
}
return parseFloat(match[1]);
}
/* Every node's width/height relate to each other affinely - width = A*height + B for a node
read in row context, height = A*width + B in column context - because a leaf is either
scale-free (a text block, whose aspect ratio holds at any size: A = aspect or 1/aspect,
B = 0) or a fixed physical size (an "empty" spacer, or a QR code once its crisp pixel size is
known - see snapQrToCrispSize below: A = 0, B = the size in px). Splits combine their
children's relations by addition (a row's total width is the sum of each child's width for
the shared height, and symmetrically for a column), which stays affine, so the same two
numbers describe a whole subtree no matter how deeply it nests.
`ownAxis` is true if the split directly containing `node` is a row, false if a column - for
a leaf, that's what "empty" measures itself against; for a split, its own axis (and thus how
it combines its children) is always the opposite, per the alternating-depth rule. `wantWidth`
is true to ask for {A, B} such that width = A*height + B, false for height = A*width + B;
requesting the direction a split doesn't naturally combine in just inverts its own relation. */
function relation(node, ownAxis, wantWidth, pxPerMm) {
if (!isSplit(node)) {
if (node.type === "qrcode" && node.crispSize !== undefined) {
return {a: 0, b: node.crispSize};
}
if (node.type === "qrcode" || node.type === "text") {
const aspect = node.aspect;
return wantWidth ? {a: aspect, b: 0} : {a: 1 / aspect, b: 0};
}
const key = ownAxis ? "min-width" : "min-height";
return {a: 0, b: parseMm(node[key], key) * pxPerMm};
}
const axis = !ownAxis; // this split's own row(true)/column(false) axis
const combinesAsWidth = axis; // a row sums widths for a shared height
const parts = node.map(child => relation(child, axis, combinesAsWidth, pxPerMm));
const a = parts.reduce((sum, p) => sum + p.a, 0);
const b = parts.reduce((sum, p) => sum + p.b, 0);
if (combinesAsWidth === wantWidth) {
return {a, b};
}
return {a: 1 / a, b: -b / a}; // invert: solve the affine relation the other way
}
/* Top-down: given the fixed (width, height) box `node` must exactly fill, assigns that box to
it and, recursively, an appropriately-shaped box to every descendant. `ownAxis` carries the
same meaning as in relation() above. */
function layoutTree(node, ownAxis, width, height, pxPerMm) {
node.box = {width, height};
if (!isSplit(node)) {
return;
}
const axis = !ownAxis;
for (const child of node) {
if (axis) {
const {a, b} = relation(child, axis, true, pxPerMm);
layoutTree(child, axis, a * height + b, height, pxPerMm);
} else {
const {a, b} = relation(child, axis, false, pxPerMm);
layoutTree(child, axis, width, a * width + b, pxPerMm);
}
}
}
/* Second top-down pass: turns each node's already-sized box into an absolute (x, y) position,
placing a row's children left to right and a column's top to bottom. Kept separate from
layoutTree since a node's size doesn't depend on its position, only on its box dimensions. */
function positionTree(node, ownAxis, x, y) {
node.box.x = x;
node.box.y = y;
if (!isSplit(node)) {
return;
}
const axis = !ownAxis;
let cursor = axis ? x : y;
for (const child of node) {
if (axis) {
positionTree(child, axis, cursor, y);
cursor += child.box.width;
} else {
positionTree(child, axis, x, cursor);
cursor += child.box.height;
}
}
}
/* Walks the tree once it's been sized (layoutTree) and given a hard minimum to check against -
this is the text-too-small-to-read validation, kept as one pass over the already-final boxes
rather than scattered through the drawing code, so nothing gets mutated on the canvas before
every leaf is confirmed to fit. The equivalent QR-doesn't-fit check already happened earlier,
in snapQrToCrispSize. */
function assertLeavesFit(node) {
if (isSplit(node)) {
node.forEach(child => assertLeavesFit(child));
return;
}
if (node.type === "text") {
const fontPx = TEXT_REFERENCE_PX * (node.box.height / node.naturalHeight);
if (fontPx < MIN_READABLE_TEXT_PX) {
throw new Error("This text doesn't fit this layout even at the smallest readable size — "
+ "try a shorter value, a wider tape, or a different layout.");
}
}
// "qrcode"/"empty" leaves have nothing left to check by this point.
}
/* A QR code needs an integer number of pixels per module to render crisply rather than blurring
at a fractional scale, so its true size is whatever that rounds down to - almost never the
scale-free square a bare aspect ratio of 1 would suggest. Called once every qrcode leaf has a
provisional (scale-free) box from a first layoutTree pass, this pins each one's real box.width
(in row context) or box.height (in column context - whichever axis its box doesn't already
share with its siblings) as `crispSize`, so relation() above starts treating it as a fixed
size, the same as an "empty" leaf, instead of one that scales with whatever height/width it's
offered. A second relation()/layoutTree() pass (see layoutContent) then resizes everything
else around that real footprint, so nothing downstream reserves - and leaves unfilled - room
for a squarer QR code than what actually gets drawn. */
function snapQrToCrispSize(node) {
if (isSplit(node)) {
node.forEach(snapQrToCrispSize);
return;
}
if (node.type === "qrcode") {
const modules = node.qr.modules.size;
const scale = Math.floor(Math.min(node.box.width, node.box.height) / modules);
if (!(scale >= 1)) {
throw new Error("This text needs a bigger QR code than the tape allows — "
+ "try a shorter value or a wider tape.");
}
node.crispSize = modules * scale;
}
}
function measureTextBlock(ctx, lines, referencePx) {
ctx.font = `${referencePx}px sans-serif`;
const width = Math.max(...lines.map(line => ctx.measureText(line).width));
const height = referencePx * 1.15 * lines.length;
return {width, height};
}
/* Turns a resolved content tree (see templateContent below - leaf objects carry a `value`
rather than a `content` function) into one ready for layout: a QR leaf gets its actual
QRCode.create() object, a text leaf gets its measured natural aspect ratio, and an "empty"
leaf passes through untouched. Multi-line text (`value` is an array) measures as one leaf,
not one per line - splitting it into a column of independently-sized leaves would let each
line grow to its own full width, ending up at a different font size than its neighbors, which
is legible but not what "one text field" should look like. */
function buildRenderTree(ctx, node, referencePx) {
if (isSplit(node)) {
return node.map(child => buildRenderTree(ctx, child, referencePx));
}
if (node.type === "qrcode") {
return {type: "qrcode", aspect: QR_ASPECT, qr: QRCode.create(node.value)};
}
if (node.type === "text") {
const lines = Array.isArray(node.value) ? node.value : [node.value];
const {width, height} = measureTextBlock(ctx, lines, referencePx);
return {type: "text", aspect: width / height, naturalHeight: height, lines};
}
return {type: "empty", "min-width": node["min-width"], "min-height": node["min-height"]};
}
function drawQrLeaf(ctx, node) {
const modules = node.qr.modules.size;
const scale = Math.floor(Math.min(node.box.width, node.box.height) / modules);
const size = modules * scale;
const left = node.box.x + Math.floor((node.box.width - size) / 2);
const top = node.box.y + Math.floor((node.box.height - size) / 2);
for (let row = 0; row < modules; row++) {
for (let col = 0; col < modules; col++) {
if (node.qr.modules.get(row, col)) {
ctx.fillRect(left + col * scale, top + row * scale, scale, scale);
}
}
}
}
function drawTextLeaf(ctx, node, referencePx) {
const fontPx = referencePx * (node.box.height / node.naturalHeight);
const lineHeight = node.box.height / node.lines.length;
ctx.font = `${fontPx}px sans-serif`;
ctx.textBaseline = "middle";
ctx.textAlign = "center";
const centerX = node.box.x + node.box.width / 2;
// Lines stack as a block, each centered under the last - keeps a multi-line field reading as
// one unit rather than drifting apart.
let y = node.box.y + lineHeight / 2;
for (const line of node.lines) {
ctx.fillText(line, centerX, y);
y += lineHeight;
}
}
// Flip this to true (in a debugger or a local edit) to outline every leaf's box - including
// "empty" ones, normally invisible - in a color that can't be mistaken for real label ink. Handy
// for checking a layout's actual padding/alignment; never wanted on a real printed label, so it's
// a manual toggle rather than something wired up to any UI.
let DEBUG_LEAF_BORDERS = false;
function drawDebugBorder(ctx, node) {
ctx.save();
ctx.strokeStyle = "red";
ctx.lineWidth = 1;
// Inset by half a pixel so the 1px stroke lands crisply on-pixel instead of straddling the
// box edge and rendering as a blurry 2px line.
ctx.strokeRect(node.box.x + 0.5, node.box.y + 0.5, node.box.width - 1, node.box.height - 1);
ctx.restore();
}
function drawTree(ctx, node, referencePx) {
if (isSplit(node)) {
node.forEach(child => drawTree(ctx, child, referencePx));
return;
}
if (node.type === "qrcode") {
drawQrLeaf(ctx, node);
} else if (node.type === "text") {
drawTextLeaf(ctx, node, referencePx);
}
// "empty" leaves carry no ink - their box just reserves the space.
if (DEBUG_LEAF_BORDERS) {
drawDebugBorder(ctx, node);
}
}
/* Builds, sizes and validates the tree for a fixed `height` (the tape's cross-web printAreaPx, or
the fallback preview's reference height) - the one dimension every layout scales from, plus
`pxPerMm` to turn "empty" leaves' physical sizes into pixels. `height` and the tree's content
fully determine its overall width; `maxLength`, when finite (a fixed-length/die-cut tape),
rejects content that doesn't fit rather than shrinking it.
Sizing runs twice: a first pass treats every qrcode leaf as the scale-free square its aspect
ratio of 1 suggests, purely to find out how much room each one would actually be offered; from
that, snapQrToCrispSize pins each one's real (smaller, crisp-pixel) size. The second pass then
resolves the whole tree again with that real size fixed in, so every sibling and the overall
width reflect what's actually drawn rather than the idealized square no QR code ever quite
fills. */
function layoutContent(ctx, content, height, maxLength, referencePx, pxPerMm) {
const tree = buildRenderTree(ctx, content, referencePx);
const measured = relation(tree, false, true, pxPerMm);
layoutTree(tree, false, measured.a * height + measured.b, height, pxPerMm);
snapQrToCrispSize(tree);
const {a, b} = relation(tree, false, true, pxPerMm);
const width = a * height + b;
if (maxLength !== Infinity && width > maxLength) {
throw new Error("This doesn't fit on this tape — "
+ "try a shorter value, a different layout, or a bigger label.");
}
layoutTree(tree, false, width, height, pxPerMm);
assertLeavesFit(tree);
return {tree, width};
}
/* The tape-fed layout - draws a fully resolved content tree (see templateContent) at the tape's
real pixel dimensions. See DEBUG_LEAF_BORDERS above to outline every leaf's box. */
export function drawLabel(canvas, tape, content) {
const maxLength = tape.printLengthPx
? tape.printLengthPx - tape.leadPx - TRAILING_PADDING_PX
: Infinity;
const measureCtx = canvas.getContext("2d");
const pxPerMm = tape.dpi / 25.4;
const {tree, width: contentWidth} = layoutContent(
measureCtx, content, tape.printAreaPx, maxLength, TEXT_REFERENCE_PX, pxPerMm);
const width = tape.printLengthPx || Math.ceil(contentWidth + tape.leadPx + TRAILING_PADDING_PX);
canvas.width = width;
canvas.height = tape.printAreaPx;
const ctx = canvas.getContext("2d", {willReadFrequently: true});
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, width, canvas.height);
ctx.fillStyle = "#000";
const originX = tape.leadPx + Math.floor((width - tape.leadPx - TRAILING_PADDING_PX - contentWidth) / 2);
positionTree(tree, false, originX, 0);
drawTree(ctx, tree, TEXT_REFERENCE_PX);
}
const FALLBACK_LABEL_HEIGHT_PX = 200; /* reference height the no-webusb preview/PNG scales from */
const FALLBACK_DPI = 203; /* reference resolution for turning "empty" leaves' mm sizes into px */
/* The no-webusb preview/PNG - same layout tree and renderer as drawLabel, just scaled from a
fixed reference height instead of a real tape's, and with no maxLength (there's no physical
tape to run out of, so the canvas just grows to fit) and no printer feed margin, since there's
no real print head here to keep clear of. */
export function drawFallbackLabel(canvas, content) {
const measureCtx = canvas.getContext("2d");
const pxPerMm = FALLBACK_DPI / 25.4;
const {tree, width: contentWidth} = layoutContent(
measureCtx, content, FALLBACK_LABEL_HEIGHT_PX, Infinity, TEXT_REFERENCE_PX, pxPerMm);
canvas.width = Math.ceil(contentWidth);
canvas.height = FALLBACK_LABEL_HEIGHT_PX;
const ctx = canvas.getContext("2d", {willReadFrequently: true});
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#000";
positionTree(tree, false, 0, 0);
drawTree(ctx, tree, TEXT_REFERENCE_PX);
}
// Turns a {kind, components} prefill (see Print.vue's `prefill` prop) into the literal string a
// print label should show/encode. Keeping this keyed by `kind` rather than having each caller
// build its own string means the format for a given kind of label content only has to be gotten
// right in one place.
export const LABEL_CONTENT_BUILDERS = {
// The self-contained Item URL (see docs/design-in-progress/items-labels.md) - what a
// printed label actually encodes, since scanning it has to resolve the right
// frontend/backend/item with no other context, not just this browser's history.
"item-url": ({user, id}) => `${window.location.origin}/i/${user}/${id}`,
};
export function buildLabelContent(prefill) {
if (!prefill) {
return "";
}
const build = LABEL_CONTENT_BUILDERS[prefill.kind];
return build ? build(prefill.components) : "";
}
// Seeds for the *base* label-layouts.js vars (see BASE_VARS there) - keyed by `kind` for the same
// reason LABEL_CONTENT_BUILDERS is. Format-string vars derived from these (itemUrl, itemHandle)
// aren't built here; they're calculated live from whatever the base vars currently are (see
// label-layouts.js's DERIVED_VARS), prefill or hand-typed alike. A field missing from the result
// (rather than present-but-empty) is what label-layouts.js's templateIsAvailable treats as "not
// available", so builders should only include a field once its inputs actually check out.
const LABEL_FIELD_BUILDERS = {
"item-url": ({user, id}) => {
if (!user || !id) {
return {};
}
return {userHandle: user, itemId: String(id)};
},
};
export function buildLabelFields(prefill) {
if (!prefill) {
return {};
}
const build = LABEL_FIELD_BUILDERS[prefill.kind];
return build ? build(prefill.components) : {};
}

View file

@ -12,6 +12,40 @@
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 === 'value' ? 'col-12' : 'col-md-6'" v-for="v in baseVars" :key="v">
<label class="form-label">{{ varLabel(v) }}</label>
<textarea v-if="v === 'value'" 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 v-if="!usbSupported" class="row">
<div class="col-lg-7">
<div class="card">
@ -19,13 +53,7 @@
<h5 class="card-title mb-0">Label preview</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">QR code content</label>
<input type="text" class="form-control" v-model="value"
placeholder="https://example.com/…" autofocus>
</div>
<div class="label-preview mb-3" v-show="selectedContent.qr || selectedContent.text">
<div class="label-preview mb-3" v-show="selectedContent">
<canvas ref="fallbackCanvas"></canvas>
</div>
@ -67,13 +95,7 @@
Connect a printer to preview and print a label.
</p>
<template v-else>
<div class="mb-3">
<label class="form-label">QR code content</label>
<input type="text" class="form-control" v-model="value"
placeholder="https://example.com/…" autofocus>
</div>
<div class="label-preview mb-3" v-show="selectedContent.qr || selectedContent.text">
<div class="label-preview mb-3" v-show="selectedContent">
<canvas ref="labelCanvas"></canvas>
</div>
@ -149,8 +171,8 @@ import BaseLayout from "@/components/BaseLayout.vue";
import LabelLayoutPreview from "@/components/LabelLayoutPreview.vue";
import {MultiPrinterBlob, canvasToBitmap, bitmapToCanvas} from "../../vendor/weblabel.js";
import {buildLabelContent, buildLabelFields} from "@/label-content.js";
import {LABEL_TEMPLATES, tapeFromStatus, drawLabel, drawFallbackLabel, templateContent} from "@/label-drawing.js";
import {tapeFromStatus, drawLabel, drawFallbackLabel, buildLabelContent, buildLabelFields} from "@/label.js";
import {LABEL_TEMPLATES, BASE_VARS, DERIVED_VARS, withDerivedVars, templateContent} from "@/label-layouts.js";
// Served verbatim from public/vendor/ rather than bundled: libweblabel.js's
// own emscripten glue resolves its .wasm sibling relative to *its own*
@ -189,7 +211,22 @@ export default {
tape: null,
labelBitmap: null,
// One input per *base* template variable (see label-layouts.js's BASE_VARS) - the
// derived ones (itemUrl, itemHandle) are format strings calculated from these, not
// typed directly, so they're only ever shown (see the `fields` computed below), never
// stored here. Prefilled from the ?kind=& query params where
// buildLabelContent/buildLabelFields have a value for them, editable from there so a
// template needing e.g. userHandle isn't stuck depending on a prefill that never
// arrives.
varValues: {
...Object.fromEntries(BASE_VARS.map(v => [v, ""])),
value: buildLabelContent(this.prefill),
// Defaults to wherever this page itself is being served from - editable since any
// frontend can resolve any handle (see label-layouts.js's DERIVED_VARS.itemUrl),
// so a label doesn't have to point back at this particular one.
webdomain: window.location.origin,
...buildLabelFields(this.prefill),
},
copies: 1,
selectedTemplate: LABEL_TEMPLATES[0].id,
@ -200,11 +237,30 @@ export default {
};
},
computed: {
// Named content fields the field-specific templates draw from, plus the free-text
// `value` field the generic qr/qr-text/text templates use. A field this doesn't have
// (rather than one that's merely empty) is what LabelLayoutPreview.vue greys out.
// The base variables the "Label content" form renders an input for, and the derived ones
// it instead calculates and lists read-only beside that form - plain passthroughs, but
// keep the template from importing label-layouts.js just for these.
baseVars() {
return BASE_VARS;
},
derivedVars() {
return Object.keys(DERIVED_VARS);
},
// Named content fields the templates draw from: the form's own base vars, plus every
// DERIVED_VARS format string calculated live from those - so typing a userHandle and
// itemId (whether by hand or via prefill) recalculates itemUrl/itemHandle the same way
// either way. A blank/uncalculated value is dropped rather than passed through as an
// empty string, so it reads as *absent* to templateIsAvailable/templateContent the same
// way a prefill that never supplied it would - that's what LabelLayoutPreview.vue greys a
// template's thumbnail out on.
fields() {
return {value: this.value, ...buildLabelFields(this.prefill)};
const base = {};
for (const v of BASE_VARS) {
if (this.varValues[v]) {
base[v] = this.varValues[v];
}
}
return withDerivedVars(base);
},
currentTemplate() {
return LABEL_TEMPLATES.find(t => t.id === this.selectedTemplate) || LABEL_TEMPLATES[0];
@ -232,13 +288,16 @@ export default {
},
},
watch: {
value() {
varValues: {
handler() {
if (this.usbSupported) {
this.redraw();
} else {
this.redrawFallback();
}
},
deep: true,
},
selectedTemplate() {
if (this.usbSupported) {
this.redraw();
@ -258,6 +317,13 @@ export default {
},
},
methods: {
// Turns a camelCase variable name (see label-layouts.js's KNOWN_VARS) into a form label,
// e.g. "itemHandle" -> "Item Handle" - so adding a new template variable doesn't also
// require hand-writing a label for it here.
varLabel(v) {
return v.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, c => c.toUpperCase());
},
async guard(fn) {
this.error = null;
this.busy = true;
@ -326,7 +392,7 @@ export default {
redraw() {
this.labelBitmap = null;
const content = this.selectedContent;
if (!this.tape || (!content.qr && !content.text)) {
if (!this.tape || !content) {
return;
}
const canvas = this.$refs.labelCanvas;
@ -350,7 +416,7 @@ export default {
redrawFallback() {
this.fallbackReady = false;
const content = this.selectedContent;
if (!content.qr && !content.text) {
if (!content) {
return;
}
const canvas = this.$refs.fallbackCanvas;