stash
This commit is contained in:
parent
4787acd8eb
commit
67c7415c3b
6 changed files with 691 additions and 407 deletions
|
|
@ -7,7 +7,7 @@
|
||||||
<div class="template-grid d-flex flex-wrap align-items-start">
|
<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"
|
<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 - fill in the fields this layout needs above.'"
|
||||||
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"
|
||||||
|
|
@ -52,12 +52,13 @@
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<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 {
|
export default {
|
||||||
name: "LabelLayoutPreview",
|
name: "LabelLayoutPreview",
|
||||||
props: {
|
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
|
// - 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.
|
// than present-but-empty) means a template that needs it is unavailable right now.
|
||||||
fields: {
|
fields: {
|
||||||
|
|
@ -109,7 +110,7 @@ export default {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const content = templateContent(t, this.fields);
|
const content = templateContent(t, this.fields);
|
||||||
if (!this.isAvailable(t) || (!content.qr && !content.text)) {
|
if (!this.isAvailable(t) || !content) {
|
||||||
canvas.width = 1;
|
canvas.width = 1;
|
||||||
canvas.height = 1;
|
canvas.height = 1;
|
||||||
continue;
|
continue;
|
||||||
|
|
|
||||||
|
|
@ -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) : {};
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
196
frontend/src/label-layouts.js
Normal file
196
frontend/src/label-layouts.js
Normal 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
395
frontend/src/label.js
Normal 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) : {};
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,40 @@
|
||||||
alternatives below.
|
alternatives below.
|
||||||
</div>
|
</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 v-if="!usbSupported" class="row">
|
||||||
<div class="col-lg-7">
|
<div class="col-lg-7">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|
@ -19,13 +53,7 @@
|
||||||
<h5 class="card-title mb-0">Label preview</h5>
|
<h5 class="card-title mb-0">Label preview</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="mb-3">
|
<div class="label-preview mb-3" v-show="selectedContent">
|
||||||
<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">
|
|
||||||
<canvas ref="fallbackCanvas"></canvas>
|
<canvas ref="fallbackCanvas"></canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -67,13 +95,7 @@
|
||||||
Connect a printer to preview and print a label.
|
Connect a printer to preview and print a label.
|
||||||
</p>
|
</p>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="mb-3">
|
<div class="label-preview mb-3" v-show="selectedContent">
|
||||||
<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">
|
|
||||||
<canvas ref="labelCanvas"></canvas>
|
<canvas ref="labelCanvas"></canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -149,8 +171,8 @@ import BaseLayout from "@/components/BaseLayout.vue";
|
||||||
import LabelLayoutPreview from "@/components/LabelLayoutPreview.vue";
|
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 {tapeFromStatus, drawLabel, drawFallbackLabel, buildLabelContent, buildLabelFields} from "@/label.js";
|
||||||
import {LABEL_TEMPLATES, tapeFromStatus, drawLabel, drawFallbackLabel, templateContent} from "@/label-drawing.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
|
// 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*
|
||||||
|
|
@ -189,7 +211,22 @@ export default {
|
||||||
tape: null,
|
tape: null,
|
||||||
labelBitmap: 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),
|
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,
|
copies: 1,
|
||||||
selectedTemplate: LABEL_TEMPLATES[0].id,
|
selectedTemplate: LABEL_TEMPLATES[0].id,
|
||||||
|
|
||||||
|
|
@ -200,11 +237,30 @@ export default {
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
// Named content fields the field-specific templates draw from, plus the free-text
|
// The base variables the "Label content" form renders an input for, and the derived ones
|
||||||
// `value` field the generic qr/qr-text/text templates use. A field this doesn't have
|
// it instead calculates and lists read-only beside that form - plain passthroughs, but
|
||||||
// (rather than one that's merely empty) is what LabelLayoutPreview.vue greys out.
|
// 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() {
|
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() {
|
currentTemplate() {
|
||||||
return LABEL_TEMPLATES.find(t => t.id === this.selectedTemplate) || LABEL_TEMPLATES[0];
|
return LABEL_TEMPLATES.find(t => t.id === this.selectedTemplate) || LABEL_TEMPLATES[0];
|
||||||
|
|
@ -232,13 +288,16 @@ export default {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
value() {
|
varValues: {
|
||||||
|
handler() {
|
||||||
if (this.usbSupported) {
|
if (this.usbSupported) {
|
||||||
this.redraw();
|
this.redraw();
|
||||||
} else {
|
} else {
|
||||||
this.redrawFallback();
|
this.redrawFallback();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
deep: true,
|
||||||
|
},
|
||||||
selectedTemplate() {
|
selectedTemplate() {
|
||||||
if (this.usbSupported) {
|
if (this.usbSupported) {
|
||||||
this.redraw();
|
this.redraw();
|
||||||
|
|
@ -258,6 +317,13 @@ export default {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
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) {
|
async guard(fn) {
|
||||||
this.error = null;
|
this.error = null;
|
||||||
this.busy = true;
|
this.busy = true;
|
||||||
|
|
@ -326,7 +392,7 @@ export default {
|
||||||
redraw() {
|
redraw() {
|
||||||
this.labelBitmap = null;
|
this.labelBitmap = null;
|
||||||
const content = this.selectedContent;
|
const content = this.selectedContent;
|
||||||
if (!this.tape || (!content.qr && !content.text)) {
|
if (!this.tape || !content) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const canvas = this.$refs.labelCanvas;
|
const canvas = this.$refs.labelCanvas;
|
||||||
|
|
@ -350,7 +416,7 @@ export default {
|
||||||
redrawFallback() {
|
redrawFallback() {
|
||||||
this.fallbackReady = false;
|
this.fallbackReady = false;
|
||||||
const content = this.selectedContent;
|
const content = this.selectedContent;
|
||||||
if (!content.qr && !content.text) {
|
if (!content) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const canvas = this.$refs.fallbackCanvas;
|
const canvas = this.$refs.fallbackCanvas;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue