diff --git a/frontend/src/router.js b/frontend/src/router.js
index 0cf663c..14ccfb5 100644
--- a/frontend/src/router.js
+++ b/frontend/src/router.js
@@ -78,7 +78,10 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
path: '/print',
component: Print,
meta: {requiresAuth: true},
- props: route => ({prefill: route.query.text})
+ props: route => {
+ const {kind, ...components} = route.query;
+ return {prefill: kind ? {kind, components} : null};
+ }
}, {
path: '/search/:query',
component: Search,
diff --git a/frontend/src/views/InventoryDetail.vue b/frontend/src/views/InventoryDetail.vue
index 780afdb..6de684b 100644
--- a/frontend/src/views/InventoryDetail.vue
+++ b/frontend/src/views/InventoryDetail.vue
@@ -47,7 +47,7 @@
Delete
@@ -86,12 +86,6 @@ export default {
},
location() {
return this.storage_locations.find(loc => loc.id === this.item.storage_location) || null
- },
- itemUrl() {
- // 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.
- return `${window.location.origin}/i/${this.user}/${this.id}`
}
},
methods: {
diff --git a/frontend/src/views/Print.vue b/frontend/src/views/Print.vue
index b414b85..a72ad58 100644
--- a/frontend/src/views/Print.vue
+++ b/frontend/src/views/Print.vue
@@ -130,6 +130,31 @@
+
+
+
+
+
+
+
+
+
+
+ {{ t.name }}
+
+
{{ t.description }}
+
+
+
+
+
+
@@ -142,6 +167,7 @@ import QRCode from "qrcode";
import BaseLayout from "@/components/BaseLayout.vue";
import {MultiPrinterBlob, canvasToBitmap, bitmapToCanvas} from "../../vendor/weblabel.js";
+import {buildLabelContent} from "@/label-content.js";
// Served verbatim from public/vendor/ rather than bundled: libweblabel.js's
// own emscripten glue resolves its .wasm sibling relative to *its own*
@@ -215,6 +241,110 @@ function drawQrLabel(canvas, qr, tape) {
}
}
+const LABEL_TEMPLATES = [
+ {id: "qr", name: "QR code only", description: "Just the code - smallest label, prints fastest."},
+ {id: "qr-text", name: "QR code + text", description: "The code with the encoded text printed next to it."},
+ {id: "text", name: "Text only", description: "No code, just the text itself, as large as it fits."},
+];
+
+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 `text` on one line within
+ maxWidth - this is a label, not a paragraph, so we shrink to fit rather than wrap. */
+function fitTextSize(ctx, text, maxWidth, maxHeight) {
+ const minPx = 8;
+ let px = Math.max(minPx, Math.floor(maxHeight));
+ while (px > minPx && measureAtHeight(ctx, text, 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, value, tape, templateId) {
+ const showQr = templateId === "qr-text";
+ const qr = showQr ? QRCode.create(value) : null;
+ 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, value, textBudget, availableHeight);
+ const textWidth = measureAtHeight(measureCtx, value, 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";
+ ctx.fillText(value, cursor, canvas.height / 2);
+}
+
+/* Dispatches to the right tape-fed layout - drawQrLabel is untouched so the default QR-only
+ template keeps its exact original pixel output. */
+function drawLabel(canvas, value, tape, templateId) {
+ if (templateId === "qr") {
+ drawQrLabel(canvas, QRCode.create(value), tape);
+ } else {
+ drawLabelWithText(canvas, value, tape, templateId);
+ }
+}
+
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 */
@@ -243,6 +373,70 @@ function drawQrSquare(canvas, qr) {
}
}
+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, value, templateId) {
+ const showQr = templateId === "qr-text";
+ const qr = showQr ? QRCode.create(value) : null;
+
+ 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, value, Infinity, FALLBACK_LABEL_HEIGHT_PX);
+ const textWidth = measureAtHeight(measureCtx, value, 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";
+ ctx.fillText(value, cursor, height / 2);
+}
+
+/* Dispatches to the right fallback layout - drawQrSquare is untouched so the default QR-only
+ template keeps its exact original pixel output. */
+function drawFallbackLabel(canvas, value, templateId) {
+ if (templateId === "qr") {
+ drawQrSquare(canvas, QRCode.create(value));
+ } else {
+ drawFallbackLabelWithText(canvas, value, templateId);
+ }
+}
+
export default {
name: "Print",
components: {
@@ -250,11 +444,12 @@ export default {
...BIcons
},
props: {
- // Prefilled from ?text=… when arriving from e.g. an item's "Print label" button
- // (see InventoryDetail.vue) - the router turns that query param into this prop
- // (router.js's /print route), rather than the component reading $route directly.
+ // {kind, components} prefilled from the ?kind=…&… query params when arriving from e.g.
+ // an item's "Print label" button (see InventoryDetail.vue) - the router turns those
+ // query params into this prop (router.js's /print route), rather than the component
+ // reading $route directly. buildLabelContent turns it into the literal string below.
prefill: {
- type: String,
+ type: Object,
default: null
}
},
@@ -270,8 +465,9 @@ export default {
tape: null,
labelBitmap: null,
- value: this.prefill || "",
+ value: buildLabelContent(this.prefill),
copies: 1,
+ selectedTemplate: LABEL_TEMPLATES[0].id,
fallbackReady: false,
// TODO: replace with the real commands for our printers.
@@ -280,6 +476,9 @@ export default {
};
},
computed: {
+ labelTemplates() {
+ return LABEL_TEMPLATES;
+ },
deviceRows() {
if (!this.blob) {
return [];
@@ -301,6 +500,14 @@ export default {
},
watch: {
value() {
+ this.redrawTemplatePreviews();
+ if (this.usbSupported) {
+ this.redraw();
+ } else {
+ this.redrawFallback();
+ }
+ },
+ selectedTemplate() {
if (this.usbSupported) {
this.redraw();
} else {
@@ -395,8 +602,7 @@ export default {
}
this.resizeObserver.observe(canvas.parentElement);
try {
- const qr = QRCode.create(this.value);
- drawQrLabel(canvas, qr, this.tape);
+ drawLabel(canvas, this.value, this.tape, this.selectedTemplate);
} catch (e) {
this.error = e.message;
return;
@@ -419,8 +625,7 @@ export default {
}
this.resizeObserver.observe(canvas.parentElement);
try {
- const qr = QRCode.create(this.value);
- drawQrSquare(canvas, qr);
+ drawFallbackLabel(canvas, this.value, this.selectedTemplate);
} catch (e) {
this.error = e.message;
return;
@@ -430,6 +635,38 @@ export default {
this.fitZoom(canvas);
},
+ setTemplateCanvasRef(id, el) {
+ if (el) {
+ this.templateCanvases[id] = el;
+ } else {
+ delete this.templateCanvases[id];
+ }
+ },
+
+ /* Live per-template thumbnails in the "Label layout" card. Always uses the
+ content-fit fallback renderer (rather than the tape-fed one), regardless of
+ whether a real printer is connected - these are illustrative previews sized by
+ CSS object-fit, not the accurate to-be-printed canvas the main preview is. */
+ redrawTemplatePreviews() {
+ for (const t of LABEL_TEMPLATES) {
+ const canvas = this.templateCanvases[t.id];
+ if (!canvas) {
+ continue;
+ }
+ if (!this.value) {
+ canvas.width = 1;
+ canvas.height = 1;
+ continue;
+ }
+ try {
+ drawFallbackLabel(canvas, this.value, t.id);
+ } catch (e) {
+ // A thumbnail that can't render at this content length just stays blank
+ // rather than surfacing an error for every keystroke.
+ }
+ }
+ },
+
downloadPng() {
const canvas = this.$refs.fallbackCanvas;
if (!canvas) {
@@ -485,9 +722,18 @@ export default {
this.blob = null;
this.resizeObserver = null;
this.resizeTimer = null;
+ // Keyed by template id, populated by setTemplateCanvasRef() - a plain :ref="t.id" string
+ // inside v-for would still get Vue's refInFor array-collecting behavior even though
+ // each iteration uses a different name, turning this.$refs[t.id] into a one-element
+ // array rather than the canvas itself. A function ref sidesteps that entirely.
+ this.templateCanvases = {};
},
async mounted() {
this.resizeObserver = new ResizeObserver(this.handleContainerResize);
+ // Unlike fallbackCanvas/labelCanvas, the thumbnail canvases in the "Label layout" card
+ // aren't behind a v-if on usbSupported, so their refs already exist here - no nextTick
+ // needed before this one.
+ this.redrawTemplatePreviews();
if (!("usb" in navigator)) {
this.usbSupported = false;
await nextTick();
@@ -543,4 +789,19 @@ export default {
white-space: pre-wrap;
word-break: break-all;
}
+
+.template-option {
+ width: 20rem;
+}
+
+.template-thumb-canvas {
+ display: block;
+ width: 100%;
+ height: 10rem;
+ /* The canvas itself is drawn at whatever size fits its content (see
+ redrawTemplatePreviews/drawFallbackLabel) - object-fit scales that down to the
+ thumbnail box the same way it would for an
, no manual zoom math needed. */
+ object-fit: contain;
+ background: #fff;
+}