stash
This commit is contained in:
parent
accbaf3603
commit
95ddb484eb
3 changed files with 275 additions and 17 deletions
|
|
@ -78,7 +78,10 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
|
||||||
path: '/print',
|
path: '/print',
|
||||||
component: Print,
|
component: Print,
|
||||||
meta: {requiresAuth: true},
|
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',
|
path: '/search/:query',
|
||||||
component: Search,
|
component: Search,
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-secondary"
|
<button class="btn btn-secondary"
|
||||||
@click="$router.push({path: '/print', query: {text: itemUrl}})">
|
@click="$router.push({path: '/print', query: {kind: 'item-url', user, id}})">
|
||||||
<b-icon-printer></b-icon-printer>
|
<b-icon-printer></b-icon-printer>
|
||||||
Print label
|
Print label
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -86,12 +86,6 @@ export default {
|
||||||
},
|
},
|
||||||
location() {
|
location() {
|
||||||
return this.storage_locations.find(loc => loc.id === this.item.storage_location) || null
|
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: {
|
methods: {
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,31 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">Label layout</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex flex-wrap gap-3">
|
||||||
|
<div v-for="t in labelTemplates" :key="t.id" class="template-option text-center"
|
||||||
|
role="button" @click="selectedTemplate = t.id">
|
||||||
|
<canvas :ref="el => setTemplateCanvasRef(t.id, el)"
|
||||||
|
class="img-thumbnail template-thumb-canvas"
|
||||||
|
:class="{'border-primary': selectedTemplate === t.id}"></canvas>
|
||||||
|
<div class="small mt-1"
|
||||||
|
:class="{'fw-bold text-primary': selectedTemplate === t.id}">
|
||||||
|
{{ t.name }}
|
||||||
|
</div>
|
||||||
|
<div class="small text-muted">{{ t.description }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
@ -142,6 +167,7 @@ import QRCode from "qrcode";
|
||||||
import BaseLayout from "@/components/BaseLayout.vue";
|
import BaseLayout from "@/components/BaseLayout.vue";
|
||||||
|
|
||||||
import {MultiPrinterBlob, canvasToBitmap, bitmapToCanvas} from "../../vendor/weblabel.js";
|
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
|
// 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*
|
||||||
|
|
@ -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_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 */
|
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 {
|
export default {
|
||||||
name: "Print",
|
name: "Print",
|
||||||
components: {
|
components: {
|
||||||
|
|
@ -250,11 +444,12 @@ export default {
|
||||||
...BIcons
|
...BIcons
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
// Prefilled from ?text=… when arriving from e.g. an item's "Print label" button
|
// {kind, components} prefilled from the ?kind=…&… query params when arriving from e.g.
|
||||||
// (see InventoryDetail.vue) - the router turns that query param into this prop
|
// an item's "Print label" button (see InventoryDetail.vue) - the router turns those
|
||||||
// (router.js's /print route), rather than the component reading $route directly.
|
// 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: {
|
prefill: {
|
||||||
type: String,
|
type: Object,
|
||||||
default: null
|
default: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -270,8 +465,9 @@ export default {
|
||||||
tape: null,
|
tape: null,
|
||||||
labelBitmap: null,
|
labelBitmap: null,
|
||||||
|
|
||||||
value: this.prefill || "",
|
value: buildLabelContent(this.prefill),
|
||||||
copies: 1,
|
copies: 1,
|
||||||
|
selectedTemplate: LABEL_TEMPLATES[0].id,
|
||||||
|
|
||||||
fallbackReady: false,
|
fallbackReady: false,
|
||||||
// TODO: replace with the real commands for our printers.
|
// TODO: replace with the real commands for our printers.
|
||||||
|
|
@ -280,6 +476,9 @@ export default {
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
labelTemplates() {
|
||||||
|
return LABEL_TEMPLATES;
|
||||||
|
},
|
||||||
deviceRows() {
|
deviceRows() {
|
||||||
if (!this.blob) {
|
if (!this.blob) {
|
||||||
return [];
|
return [];
|
||||||
|
|
@ -301,6 +500,14 @@ export default {
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
value() {
|
value() {
|
||||||
|
this.redrawTemplatePreviews();
|
||||||
|
if (this.usbSupported) {
|
||||||
|
this.redraw();
|
||||||
|
} else {
|
||||||
|
this.redrawFallback();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectedTemplate() {
|
||||||
if (this.usbSupported) {
|
if (this.usbSupported) {
|
||||||
this.redraw();
|
this.redraw();
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -395,8 +602,7 @@ export default {
|
||||||
}
|
}
|
||||||
this.resizeObserver.observe(canvas.parentElement);
|
this.resizeObserver.observe(canvas.parentElement);
|
||||||
try {
|
try {
|
||||||
const qr = QRCode.create(this.value);
|
drawLabel(canvas, this.value, this.tape, this.selectedTemplate);
|
||||||
drawQrLabel(canvas, qr, this.tape);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.error = e.message;
|
this.error = e.message;
|
||||||
return;
|
return;
|
||||||
|
|
@ -419,8 +625,7 @@ export default {
|
||||||
}
|
}
|
||||||
this.resizeObserver.observe(canvas.parentElement);
|
this.resizeObserver.observe(canvas.parentElement);
|
||||||
try {
|
try {
|
||||||
const qr = QRCode.create(this.value);
|
drawFallbackLabel(canvas, this.value, this.selectedTemplate);
|
||||||
drawQrSquare(canvas, qr);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.error = e.message;
|
this.error = e.message;
|
||||||
return;
|
return;
|
||||||
|
|
@ -430,6 +635,38 @@ export default {
|
||||||
this.fitZoom(canvas);
|
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() {
|
downloadPng() {
|
||||||
const canvas = this.$refs.fallbackCanvas;
|
const canvas = this.$refs.fallbackCanvas;
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
|
|
@ -485,9 +722,18 @@ export default {
|
||||||
this.blob = null;
|
this.blob = null;
|
||||||
this.resizeObserver = null;
|
this.resizeObserver = null;
|
||||||
this.resizeTimer = 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() {
|
async mounted() {
|
||||||
this.resizeObserver = new ResizeObserver(this.handleContainerResize);
|
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)) {
|
if (!("usb" in navigator)) {
|
||||||
this.usbSupported = false;
|
this.usbSupported = false;
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
@ -543,4 +789,19 @@ export default {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-all;
|
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 <img>, no manual zoom math needed. */
|
||||||
|
object-fit: contain;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue