This commit is contained in:
j3d1 2026-08-23 00:56:03 +02:00
parent 5c3b7fc252
commit bbe52e4a78
9 changed files with 817 additions and 287 deletions

View file

@ -0,0 +1,222 @@
// Shared camera stream manager used by any component that needs webcam access
// (WebcamFileSource, Scan). Centralizing this means every consumer gets device
// enumeration, preferred-camera memory, stream reuse and disconnect/reconnect
// handling for free instead of re-implementing it per component.
class CameraManager {
constructor() {
this.availableCameras = [];
this.activeStream = null;
this.selectedCameraId = null;
this.isInitialized = false;
this.streamUsers = 0;
}
async enumerateDevices() {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const videoDevices = devices.filter((d) => d.kind === 'videoinput' && d.deviceId);
if (videoDevices.length === 0) return this.availableCameras;
const uniqueMap = new Map();
videoDevices.forEach((device) => uniqueMap.set(device.deviceId, device));
this.availableCameras = Array.from(uniqueMap.values());
return this.availableCameras;
} catch (err) {
console.error('Error enumerating devices:', err);
return [];
}
}
loadPreferredCamera() {
const savedCameras = this.getRecentCameras();
if (savedCameras.length === 0) return null;
if (this.availableCameras.length === 0) {
this.selectedCameraId = savedCameras[0];
return savedCameras[0];
}
for (const cameraId of savedCameras) {
if (this.availableCameras.some((cam) => cam.deviceId === cameraId)) {
this.selectedCameraId = cameraId;
return cameraId;
}
}
return null;
}
getRecentCameras() {
try {
const saved = localStorage.getItem('recentCameraIds');
return saved ? JSON.parse(saved) : [];
} catch (err) {
console.error('Error loading recent cameras:', err);
return [];
}
}
savePreferredCamera(cameraId) {
if (!cameraId) return;
const recentCameras = this.getRecentCameras();
const updated = [cameraId, ...recentCameras.filter((id) => id !== cameraId)];
try {
localStorage.setItem('recentCameraIds', JSON.stringify(updated));
this.selectedCameraId = cameraId;
} catch (err) {
console.error('Error saving recent cameras:', err);
}
}
async openStream(cameraId = null) {
const targetCameraId = cameraId || this.selectedCameraId;
if (this.activeStream) {
const currentSettings = this.activeStream.getVideoTracks()[0].getSettings();
if (currentSettings.deviceId === targetCameraId) {
this.streamUsers++;
return this.activeStream;
}
this.closeStream(true);
}
if (!this.isInitialized) {
await this.enumerateDevices();
this.isInitialized = true;
}
this.selectedCameraId = this._selectCamera(targetCameraId);
const constraints = {
video: this.selectedCameraId
? { deviceId: { ideal: this.selectedCameraId } }
: { facingMode: 'environment' },
audio: false,
};
try {
this.activeStream = await navigator.mediaDevices.getUserMedia(constraints);
this.streamUsers = 1;
await this.enumerateDevices();
const actualDeviceId = this.activeStream.getVideoTracks()[0]?.getSettings().deviceId;
if (actualDeviceId) this.selectedCameraId = actualDeviceId;
this.activeStream.getVideoTracks().forEach((track) => {
track.onended = () => this.handleTrackEnded(track);
});
return this.activeStream;
} catch (err) {
console.error('Error opening camera stream:', err);
if (err.name === 'OverconstrainedError' && this.selectedCameraId) {
this.selectedCameraId = null;
localStorage.removeItem('recentCameraIds');
return await this.openStream();
}
throw err;
}
}
_selectCamera(targetCameraId) {
if (targetCameraId) return targetCameraId;
const preferredCamera = this.loadPreferredCamera();
if (preferredCamera) return preferredCamera;
return this.availableCameras[0]?.deviceId || null;
}
async switchCamera(cameraId) {
this.savePreferredCamera(cameraId);
const wasActive = this.streamUsers > 0;
this.closeStream(true);
return wasActive ? await this.openStream(cameraId) : null;
}
async handleTrackEnded(track) {
console.warn('Camera track ended (may have been unplugged)');
const disconnectedDeviceId = track.getSettings().deviceId;
this.activeStream = null;
this.streamUsers = 0;
await this.enumerateDevices();
if (this.availableCameras.some((cam) => cam.deviceId === disconnectedDeviceId)) return;
if (this.availableCameras.length === 0) {
console.error('No cameras available after disconnection');
this._dispatchCameraEvent('camera-disconnected', { error: 'No cameras available' });
return;
}
const fallbackCameraId = this._selectFallbackCamera();
const fallbackCamera = this.availableCameras.find((cam) => cam.deviceId === fallbackCameraId);
try {
await this.openStream(fallbackCameraId);
this._dispatchCameraEvent('camera-reconnected', {
deviceId: fallbackCameraId,
label: fallbackCamera?.label || 'Unknown',
});
} catch (err) {
console.error('Failed to open fallback camera:', err);
this._dispatchCameraEvent('camera-disconnected', { error: 'No cameras available' });
}
}
_selectFallbackCamera() {
const recentCameras = this.getRecentCameras();
for (const cameraId of recentCameras) {
if (this.availableCameras.some((cam) => cam.deviceId === cameraId)) {
return cameraId;
}
}
return this.availableCameras[0]?.deviceId;
}
_cleanCameraLabel(label) {
if (!label) return 'Unknown';
const parts = label.split(':').map((p) => p.trim());
if (parts.length === 2 && parts[0] === parts[1]) {
return parts[0];
}
return label;
}
_dispatchCameraEvent(eventName, detail) {
if (detail.label) {
detail.label = this._cleanCameraLabel(detail.label);
}
window.dispatchEvent(new CustomEvent(eventName, { detail }));
}
closeStream(force = false) {
if (!this.activeStream) return;
this.streamUsers = force ? 0 : Math.max(0, this.streamUsers - 1);
if (this.streamUsers === 0) {
this.activeStream.getTracks().forEach((track) => track.stop());
this.activeStream = null;
}
}
getAvailableCameras() {
return this.availableCameras;
}
getActiveStream() {
return this.activeStream;
}
getSelectedCameraId() {
return this.selectedCameraId;
}
}
export default new CameraManager();

View file

@ -54,12 +54,13 @@
<b-icon-save></b-icon-save>&nbsp;Save <b-icon-save></b-icon-save>&nbsp;Save
</button> </button>
</div> </div>
<select v-model="selectedCamera" v-on:change="onUserSelect" <select
v-if="availableCameras.length > 1"
v-model="selectedCameraId"
class="form-select position-relative w-50 mx-auto mb-5 mt-2 shadow" class="form-select position-relative w-50 mx-auto mb-5 mt-2 shadow"
aria-label="Select Camera Source"> aria-label="Select Camera Source">
<option disabled value="">Select Camera Source</option> <option v-for="camera in availableCameras" :key="camera.deviceId" :value="camera.deviceId">
<option v-for="camera in availableCameras" :key="camera.deviceId" :value="camera"> {{ cleanCameraLabel(camera.label) || `Camera ${availableCameras.indexOf(camera) + 1}` }}
{{ camera.label }}
</option> </option>
</select> </select>
</div> </div>
@ -74,6 +75,8 @@
</style> </style>
<script> <script>
import cameraManager from '@/cameraManager.js';
export default { export default {
name: "WebcamFileSource", name: "WebcamFileSource",
data: () => ({ data: () => ({
@ -81,80 +84,105 @@ export default {
error: false, error: false,
show_modal: false, show_modal: false,
availableCameras: [], availableCameras: [],
selectedCamera: undefined, localSelectedCameraId: null,
capturing: false, capturing: false,
streaming: false, streaming: false,
stream: undefined,
dataImage: undefined dataImage: undefined
}), }),
methods: { emits: ['input'],
async attemptGetUserMedia(constraints) { computed: {
this.stream = await navigator.mediaDevices.getUserMedia({ selectedCameraId: {
audio: false, get() {
video: constraints return this.localSelectedCameraId || cameraManager.getSelectedCameraId();
}).catch((error) => {
console.error(error);
if (error.name === "NotAllowedError") this.lastError = "Camera Permission Not Granted";
if (error.name === "NotReadableError") this.lastError = "Camera Hardware Error";
this.lastError = "Unknown Error"
});
if (this.stream) this.allowed = true;
}, },
async assignStream() { set(value) {
console.log(this.stream.getTracks()[0]); if (value) {
const track = this.stream.getTracks()[0]; this.localSelectedCameraId = value;
if (track.getCapabilities) { this.switchCamera(value);
const capabilities = this.stream.getTracks()[0].getCapabilities();
await this.attemptGetUserMedia({
deviceId: capabilities.deviceId,
width: {ideal: capabilities.width.max},
height: {ideal: capabilities.height.max}
});
} }
},
},
},
methods: {
setupVideoStream(stream) {
this.capturing = true; this.capturing = true;
this.streaming = false; this.streaming = false;
const {video} = this.$refs; const video = this.$refs.video;
video.srcObject = this.stream; if (!video) return;
video.addEventListener('canplay', () => {
video.srcObject = stream;
video.play();
video.oncanplay = () => {
this.streaming = true; this.streaming = true;
localStorage.setItem("WebcamFileSource#previousDevice", this.selectedCamera.deviceId); this.availableCameras = cameraManager.getAvailableCameras();
}, false); this.localSelectedCameraId = cameraManager.getSelectedCameraId();
await video.play(); };
},
async openStream(cameraId) {
this.error = false;
this.lastError = undefined;
try {
const stream = await cameraManager.openStream(cameraId);
this.setupVideoStream(stream);
} catch (err) {
console.error('Failed to open camera stream:', err);
if (err.name === "NotAllowedError") this.lastError = "Camera Permission Not Granted";
else if (err.name === "NotReadableError") this.lastError = "Camera Hardware Error";
else this.lastError = "Unknown Error";
this.error = true;
this.capturing = false;
}
},
async switchCamera(cameraId) {
if (!this.capturing) return;
this.streaming = false;
try {
const stream = await cameraManager.switchCamera(cameraId);
if (stream) this.setupVideoStream(stream);
} catch (err) {
console.error('Failed to switch camera:', err);
}
}, },
closeStream() { closeStream() {
if (this.capturing) { if (this.capturing) {
this.stream.getTracks().forEach(s => s.stop()); cameraManager.closeStream();
this.capturing = false;
this.streaming = false; this.streaming = false;
this.stream = undefined;
} }
}, },
async enumerateCameras() { cleanCameraLabel(label) {
const devices = await navigator.mediaDevices.enumerateDevices(); if (!label) return '';
this.availableCameras = devices.filter(device => device.kind === "videoinput"); const parts = label.split(':').map((p) => p.trim());
if (parts.length === 2 && parts[0] === parts[1]) {
return parts[0];
}
return label;
},
handleCameraDisconnected(event) {
console.error('Camera disconnected:', event.detail);
this.streaming = false;
this.error = true;
this.lastError = 'Camera Disconnected';
},
handleCameraReconnected() {
const stream = cameraManager.getActiveStream();
if (stream) {
this.error = false;
this.lastError = undefined;
this.setupVideoStream(stream);
}
}, },
async open() { async open() {
this.show_modal = true; this.show_modal = true;
const previousDevice = localStorage.getItem("WebcamFileSource#previousDevice"); await this.openStream();
if (previousDevice) await this.attemptGetUserMedia({deviceId: previousDevice});
if (!this.stream) await this.attemptGetUserMedia({facingMode: "environment"});
if (!this.stream) await this.attemptGetUserMedia(true);
if (!this.stream) this.error = true;
await this.enumerateCameras();
this.selectedCamera = this.availableCameras.find(({deviceId}) => deviceId === this.stream.getTracks()[0].getSettings().deviceId);
await this.assignStream();
},
async onUserSelect() {
this.closeStream();
if (!this.dataImage) {
await this.attemptGetUserMedia({deviceId: this.selectedCamera.deviceId})
await this.assignStream();
}
}, },
async close() { async close() {
this.closeStream(); this.closeStream();
this.capturing = false;
this.show_modal = false; this.show_modal = false;
this.dataImage = undefined; this.dataImage = undefined;
this.error = false;
this.lastError = undefined;
}, },
captureVideoImage() { captureVideoImage() {
const {video, canvas} = this.$refs; const {video, canvas} = this.$refs;
@ -165,11 +193,14 @@ export default {
context.drawImage(video, 0, 0, videoWidth, videoHeight); context.drawImage(video, 0, 0, videoWidth, videoHeight);
this.dataImage = canvas.toDataURL('image/jpeg', 0.5); this.dataImage = canvas.toDataURL('image/jpeg', 0.5);
this.closeStream(); this.closeStream();
this.capturing = false;
}, },
retake() { retake() {
this.dataImage = undefined; this.dataImage = undefined;
this.open(); this.openStream();
},
async refreshCameraList() {
await cameraManager.enumerateDevices();
this.availableCameras = cameraManager.getAvailableCameras();
}, },
async save() { async save() {
const mimeType = this.dataImage.split(';')[0].split(':')[1]; const mimeType = this.dataImage.split(';')[0].split(':')[1];
@ -194,14 +225,15 @@ export default {
} }
}, },
mounted() { mounted() {
navigator.mediaDevices.addEventListener("devicechange", (event) => { window.addEventListener('camera-disconnected', this.handleCameraDisconnected);
console.log("devicechange"); window.addEventListener('camera-reconnected', this.handleCameraReconnected);
this.enumerateCameras(); navigator.mediaDevices?.addEventListener('devicechange', this.refreshCameraList);
if (this.availableCameras.findIndex(({deviceId}) => deviceId === this.selectedCamera.deviceId) === -1) { },
beforeUnmount() {
window.removeEventListener('camera-disconnected', this.handleCameraDisconnected);
window.removeEventListener('camera-reconnected', this.handleCameraReconnected);
navigator.mediaDevices?.removeEventListener('devicechange', this.refreshCameraList);
this.closeStream(); this.closeStream();
this.open(); },
}
});
}
} }
</script> </script>

View file

@ -1,33 +1,118 @@
// Each template's `layout` is a tree as described in label.js, with "qrcode"/"text" leaves' // Each template's `layout` is a tree as described in label.js, with leaves whose `type` is one of
// `content` a function from the resolved field values (see label.js's buildLabelFields) to what // label.js's QR_LEAF_TYPES keys or "text", and whose `content` is a function from the resolved
// they render - `null`/`undefined` from that function means the field isn't available yet (see // field values (see label.js's buildLabelFields) to what they render - `null`/`undefined` from
// templateIsAvailable below). A template is only selectable once every leaf's `content` resolves // that function means the field isn't available yet (see templateIsAvailable below). A template
// to a value. // is only selectable once every leaf's `content` resolves to a value.
const GAP = {type: "empty", "min-width": "1mm", "min-height": "1mm"}; const GAP = {type: "empty", "min-width": "1mm", "min-height": "1mm"};
// The full "just the code" matrix: every {symbology, error-correction level, [rMQR] size
// strategy} label.js's QR_LEAF_TYPES supports, one template each - a plain `id` (no suffix) is
// always anyd's own defaults, ecc "M" and (rMQR only) size "balanced". Coverage isn't uniform
// (see QR_LEAF_TYPES): full QR gets all four ecc grades L/M/Q/H; Micro QR swaps "L" (QR's actual
// lowest) for the even-lower, M1-only, detection-only "Detection" and has no "H" at all; rMQR
// only ever supports ecc "M" or "H", each of those crossed with all three size strategies
// (balanced/min/max). Generated (rather than hand-writing every near-duplicate entry) so a
// symbology/level/size this matrix is missing is one new row here, not a new block to keep in
// sync with its neighbors. `id` doubles as the layout's leaf `type`, since that's exactly what
// QR_LEAF_TYPES is keyed by.
const QR_ONLY_TEMPLATES = [
{
id: "qr-l", name: "QR code only (low error correction)",
description: "Just the code, at QR's lowest error-correction level - fits more data (or a "
+ "smaller code) for the same text, but less tolerant of damage.",
},
{id: "qr", name: "QR code only", description: "Just the code - smallest label, prints fastest."},
{
id: "qr-q", name: "QR code only (quartile error correction)",
description: "Just the code, at QR's second-highest (quartile) error-correction level - a "
+ "middle ground between code size and damage tolerance.",
},
{
id: "qr-h", name: "QR code only (high error correction)",
description: "Just the code, at QR's highest error-correction level - still scans if scuffed "
+ "or partly obscured, at the cost of a bigger code for the same text.",
},
{
id: "mqr-d", name: "Micro QR code only (detection only)",
description: "Just the code, in the more compact Micro QR format at its lowest, M1-only level - "
+ "the smallest QR-family code there is, but can only tell a scan is corrupted, not "
+ "recover from it.",
},
{
id: "mqr-l", name: "Micro QR code only (low error correction)",
description: "Just the code, in the more compact Micro QR format at its low error-correction level.",
},
{
id: "mqr", name: "Micro QR code only",
description: "Just the code, in the more compact Micro QR format - smallest label, prints fastest.",
},
{
id: "mqr-q", name: "Micro QR code only (quartile error correction)",
description: "Just the code, in the more compact Micro QR format at its highest (quartile) "
+ "error-correction level.",
},
{
id: "rmqr", name: "rMQR code only",
description: "Just the code, in the rectangular rMQR format, sized for the smallest total area "
+ "that fits the text - smallest label, prints fastest.",
},
{
id: "rmqr-min", name: "rMQR code only (shortest, widest)",
description: "Just the code, in the rectangular rMQR format, preferring the flattest/widest "
+ "symbol that fits the text - shortest across the tape, longest along it.",
},
{
id: "rmqr-max", name: "rMQR code only (tallest, narrowest)",
description: "Just the code, in the rectangular rMQR format, preferring the tallest/narrowest "
+ "symbol that fits the text - tallest across the tape, shortest along it.",
},
{
id: "rmqr-h", name: "rMQR code only (high error correction)",
description: "Just the code, in the rectangular rMQR format at its high error-correction level, "
+ "sized for the smallest total area that fits the text - still scans if scuffed or partly "
+ "obscured, at the cost of a bigger code for the same text.",
},
{
id: "rmqr-h-min", name: "rMQR code only (high error correction, shortest/widest)",
description: "Just the code, in the rectangular rMQR format at its high error-correction level, "
+ "preferring the flattest/widest symbol that fits the text.",
},
{
id: "rmqr-h-max", name: "rMQR code only (high error correction, tallest/narrowest)",
description: "Just the code, in the rectangular rMQR format at its high error-correction level, "
+ "preferring the tallest/narrowest symbol that fits the text.",
},
].map(t => ({...t, required_vars: ["text"], layout: [{type: t.id, content: c => c.text}]}));
export const LABEL_TEMPLATES = [ export const LABEL_TEMPLATES = [
{ {
id: "qr", name: "QR code only", description: "Just the code - smallest label, prints fastest.", id: "mqr-token", name: "MQR Token", description: "The code with the encoded text printed next to it.",
required_vars: ["text"], required_vars: ["shortId"],
layout: [{type: "qrcode", content: c => c.text}] layout: [{type: "mqr", content: c => c.shortId}]
}, },
{
id: "qr-url", name: "MQR Token", description: "The code with the encoded text printed next to it.",
required_vars: ["shortUrl"],
layout: [{type: "qr-h", content: c => c.shortUrl}]
},...QR_ONLY_TEMPLATES,
{ {
id: "qr-text", name: "QR code + text", description: "The code with the encoded text printed next to it.", id: "qr-text", name: "QR code + text", description: "The code with the encoded text printed next to it.",
required_vars: ["text"], required_vars: ["text"],
layout: [{type: "qrcode", content: c => c.text}, GAP, {type: "text", content: c => c.text?.split("\n")}] layout: [{type: "qr", content: c => c.text}, GAP, {type: "text", content: c => c.text?.split("\n")}]
}, },
{ {
id: "qr-text-below", name: "QR code + text below", id: "qr-text-below", name: "QR code + text below",
description: "The code with the encoded text printed below it.", description: "The code with the encoded text printed below it.",
required_vars: ["text"], required_vars: ["text"],
layout: [[{type: "qrcode", content: c => c.text}, GAP, {type: "text", content: c => c.text?.split("\n")}]] layout: [[{type: "qr", content: c => c.text}, GAP, {type: "text", content: c => c.text?.split("\n")}]]
}, },
{ {
id: "id-qr-text-vertical", name: "ID + QR code + text below", id: "id-qr-text-vertical", name: "ID + QR code + text below",
description: "The code with the encoded text printed below it.", description: "The code with the encoded text printed below it.",
required_vars: ["itemId", "text", "userHandle"], required_vars: ["itemId", "text", "userHandle"],
layout: [[{type: "text", content: c => "Item: "+c.itemId}, GAP, { layout: [[{type: "text", content: c => "Item: "+c.itemId}, GAP, {
type: "qrcode", type: "qr",
content: c => c.text content: c => c.text
}, GAP, {type: "text", content: c => c.userHandle}]] }, GAP, {type: "text", content: c => c.userHandle}]]
}, },
@ -68,31 +153,38 @@ export const LABEL_TEMPLATES = [
id: "item-url-qr-handle", name: "Item URL + handle", id: "item-url-qr-handle", name: "Item URL + handle",
description: "Scannable item URL, with the item's compact handle printed alongside.", description: "Scannable item URL, with the item's compact handle printed alongside.",
required_vars: ["itemUrl", "itemHandle"], required_vars: ["itemUrl", "itemHandle"],
layout: [{type: "qrcode", content: c => c.itemUrl}, GAP, {type: "text", content: c => c.itemHandle}] layout: [{type: "qr", content: c => c.itemUrl}, GAP, {type: "text", content: c => c.itemHandle}]
}, },
{ {
id: "item-url-qr-owner", name: "Item URL + owner", id: "item-url-qr-owner", name: "Item URL + owner",
description: "Scannable item URL, with the owner's handle printed alongside.", description: "Scannable item URL, with the owner's handle printed alongside.",
required_vars: ["itemUrl", "userHandle"], required_vars: ["itemUrl", "userHandle"],
layout: [{type: "qrcode", content: c => c.itemUrl}, GAP, {type: "text", content: c => c.userHandle}] layout: [{type: "qr", content: c => c.itemUrl}, GAP, {type: "text", content: c => c.userHandle}]
}, },
{ {
id: "item-url-qr-id", name: "Item URL + item ID", id: "item-url-qr-id", name: "Item URL + item ID",
description: "Scannable item URL, with the bare item id printed alongside.", description: "Scannable item URL, with the bare item id printed alongside.",
required_vars: ["itemUrl", "itemId"], required_vars: ["itemUrl", "itemId"],
layout: [{type: "qrcode", content: c => c.itemUrl}, GAP, {type: "text", content: c => c.itemId}] layout: [{type: "qr", content: c => c.itemUrl}, GAP, {type: "text", content: c => c.itemId}]
}, },
{ {
id: "item-url-qr-owner-id", name: "Item URL + owner + ID", 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.", description: "Scannable item URL, with the owner's handle and the item id on two lines alongside.",
required_vars: ["itemUrl", "userHandle", "itemId"], required_vars: ["itemUrl", "userHandle", "itemId"],
layout: [{type: "qrcode", content: c => c.itemUrl}, GAP, {type: "text", content: c => [c.userHandle, c.itemId]}] layout: [{type: "qr", content: c => c.itemUrl}, GAP, {type: "text", content: c => [c.userHandle, c.itemId]}]
},
{
id: "short-url-qr", name: "Short link (QR code)",
description: "Scannable short link for this item or storage location - more compact than "
+ "the full URL. Available for any element with a resolvable short link, not just items.",
required_vars: ["shortUrl"],
layout: [{type: "qr", content: c => c.shortUrl}]
}, },
{ {
id: "item-url-qr-owner-id2", name: "Item URL + owner + ID", 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.", description: "Scannable item URL, with the owner's handle and the item id on two lines alongside.",
required_vars: ["itemUrl", "userHandle", "itemId"], required_vars: ["itemUrl", "userHandle", "itemId"],
layout: [{type: "qrcode", content: c => c.itemUrl}, GAP, [{ layout: [{type: "qr", content: c => c.itemUrl}, GAP, [{
type: "text", type: "text",
content: c => c.userHandle content: c => c.userHandle
}, GAP, {type: "text", content: c => c.itemId}]] }, GAP, {type: "text", content: c => c.itemId}]]
@ -171,8 +263,8 @@ function mapTree(node, fn) {
} }
// A content leaf's resolved value counts as present only if every part of it is - a single // 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' // string for a QR-family leaf (any label.js QR_LEAF_TYPES entry) or plain "text", every line for a
// "owner-id-text" and "item-url-qr-owner-id"). // multi-line "text" (see LABEL_TEMPLATES' "owner-id-text" and "item-url-qr-owner-id").
function isResolved(value) { function isResolved(value) {
return Array.isArray(value) ? value.every(isResolved) : value !== undefined && value !== null; return Array.isArray(value) ? value.every(isResolved) : value !== undefined && value !== null;
} }

View file

@ -4,21 +4,57 @@ import {encodeHandleForUrl} from "@/router"
// anyd-qr.js's own loadAnyDCode() memoizes the wasm instantiation itself, so calling it more // anyd-qr.js's own loadAnyDCode() memoizes the wasm instantiation itself, so calling it more
// than once (each of Print.vue and LabelLayoutPreview.vue does, on mount) is free - `anyd` just // than once (each of Print.vue and LabelLayoutPreview.vue does, on mount) is free - `anyd` just
// mirrors its resolved value so buildRenderTree below can use it synchronously. Until it // mirrors its resolved value so buildRenderTree below can use it synchronously. Until it
// resolves, a "qrcode" leaf throws (see encodeQr) the same way an oversized value already does - // resolves, a QR-family leaf (any QR_LEAF_TYPES entry) throws (see encodeQr) the same way an
// callers already have to handle layoutContent throwing, so this reuses that path rather than // oversized value already does - callers already have to handle layoutContent throwing, so this
// adding a second failure mode. // reuses that path rather than adding a second failure mode.
let anyd = null; let anyd = null;
export function preloadQrEncoder() { export function preloadQrEncoder() {
return loadAnyDCode().then(instance => { anyd = instance; }); return loadAnyDCode().then(instance => { anyd = instance; });
} }
// The three symbologies anyd-qr.js exposes (see its CodeType) - Print.vue's code-type selector // Maps each of label-layouts.js's LABEL_TEMPLATES leaf types that draw a code to the anyd-qr.js
// offers exactly these. rMQR's matrix isn't square (see encodeQr's width/height below), unlike // symbology/error-correction level (and, for rMQR, size strategy) it renders as (see anyd's
// qr/micro-qr, which always are. // EncodeOptions - `ecc`/`size` - and its per-symbology EcLevel enums, `wasm.rs`'s
export const QR_CODE_TYPES = ["qr", "micro-qr", "rmqr"]; // qr_ec/micro_ec/rmqr_ec/rmqr_size) - which combination a given label uses is baked into its
// layout tree (see label-layouts.js's "qr"-prefixed templates), rather than a single choice
// applied to every code leaf alike, so there's no longer a global selector for any of them (see
// Print.vue). A plain symbology id (no suffix) always means anyd's own defaults - ecc "M", rMQR
// size "balanced" - every other value gets a "-<name>" suffix naming it:
// - ecc: the same letter anyd itself uses (qr_ec/micro_ec's L/M/Q/H), except micro-qr's
// "Detection" (`MicroEcLevel::Detection`, an M1-only error-*detection*-but-not-correction mode
// with no plain single-letter grade of its own). Coverage isn't uniform across symbologies
// (see qr_ec/micro_ec/rmqr_ec) - full QR takes all four grades, Micro QR swaps "L" (QR's
// actual lowest) for "Detection" (lower still, but M1-only) and has no "H" at all, and rMQR
// only ever supports "M" or "H".
// - size (rMQR only, see rmqr_size/SizeStrategy): "min"/"max" prefer the shortest (flattest,
// widest) or tallest (narrowest) symbol that fits the text, over the default "balanced"
// (smallest total module area) - which shape to prefer depends on which of the tape's two
// axes (across vs. along the feed) is more constrained.
// rMQR's matrix isn't square (see encodeQr's width/height below), unlike qr/micro-qr, which
// always are.
const QR_LEAF_TYPES = {
"qr-l": {codeType: "qr", ecc: "L"},
qr: {codeType: "qr", ecc: "M"},
"qr-q": {codeType: "qr", ecc: "Q"},
"qr-h": {codeType: "qr", ecc: "H"},
"mqr-d": {codeType: "micro-qr", ecc: "Detection"},
"mqr-l": {codeType: "micro-qr", ecc: "L"},
mqr: {codeType: "micro-qr", ecc: "M"},
"mqr-q": {codeType: "micro-qr", ecc: "Q"},
rmqr: {codeType: "rmqr", ecc: "M"},
"rmqr-min": {codeType: "rmqr", ecc: "M", size: "min"},
"rmqr-max": {codeType: "rmqr", ecc: "M", size: "max"},
"rmqr-h": {codeType: "rmqr", ecc: "H"},
"rmqr-h-min": {codeType: "rmqr", ecc: "H", size: "min"},
"rmqr-h-max": {codeType: "rmqr", ecc: "H", size: "max"},
};
function encodeQr(text, codeType) { function isQrLeaf(node) {
return node.type in QR_LEAF_TYPES;
}
function encodeQr(text, codeType, options) {
if (!anyd) { if (!anyd) {
throw new Error("The QR encoder is still loading — try again in a moment."); throw new Error("The QR encoder is still loading — try again in a moment.");
} }
@ -28,7 +64,7 @@ function encodeQr(text, codeType) {
// width/height (see its ModuleMatrix type), same as the old library's BitMatrix. width/height // width/height (see its ModuleMatrix type), same as the old library's BitMatrix. width/height
// are kept separate rather than a single `size` (the old library's own shape, always square) // are kept separate rather than a single `size` (the old library's own shape, always square)
// since rMQR symbols are rectangular. // since rMQR symbols are rectangular.
const {width, height, modules} = anyd.encode(codeType, new TextEncoder().encode(text), {ecc: "M"}).matrix; const {width, height, modules} = anyd.encode(codeType, new TextEncoder().encode(text), options).matrix;
return {width, height, get: (row, col) => modules[row * width + col] !== 0}; return {width, height, get: (row, col) => modules[row * width + col] !== 0};
} }
@ -60,9 +96,11 @@ export function tapeFromStatus(status) {
(the root, depth 0, is always a row), or stacked (a *column*) at odd depth. To turn a (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 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. 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 - An object is a leaf: {type, content} where `type` is one of QR_LEAF_TYPES' keys draws a
or text block, where `content` is a function from the resolved field values to the QR/Micro QR/rMQR code at that id's symbology/error-correction level (see QR_LEAF_TYPES
string (or, for "text", an array of strings - one per line) to render. {type: "empty", above), {type: "text", content} draws a text block - either way `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 "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 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 border, margin or gap on its own. An "empty" leaf's dimension always names the axis its
@ -140,10 +178,10 @@ function parseMm(value, key) {
requesting the direction a split doesn't naturally combine in just inverts its own relation. */ requesting the direction a split doesn't naturally combine in just inverts its own relation. */
function relation(node, ownAxis, wantWidth, pxPerMm) { function relation(node, ownAxis, wantWidth, pxPerMm) {
if (!isSplit(node)) { if (!isSplit(node)) {
if (node.type === "qrcode" && node.crispWidth !== undefined) { if (isQrLeaf(node) && node.crispWidth !== undefined) {
return {a: 0, b: wantWidth ? node.crispWidth : node.crispHeight}; return {a: 0, b: wantWidth ? node.crispWidth : node.crispHeight};
} }
if (node.type === "qrcode" || node.type === "text") { if (isQrLeaf(node) || node.type === "text") {
const aspect = node.aspect; const aspect = node.aspect;
return wantWidth ? {a: aspect, b: 0} : {a: 1 / aspect, b: 0}; return wantWidth ? {a: aspect, b: 0} : {a: 1 / aspect, b: 0};
} }
@ -206,7 +244,7 @@ function positionTree(node, ownAxis, x, y) {
/* A QR code needs an integer number of pixels per module to render crisply rather than blurring /* 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 at a fractional scale, so its true size is whatever that rounds down to - almost never the
scale-free box its aspect ratio alone would suggest. Called once every qrcode leaf has a scale-free box its aspect ratio alone would suggest. Called once every QR-family leaf has a
provisional (scale-free) box from a first layoutTree pass, this pins each one's real provisional (scale-free) box from a first layoutTree pass, this pins each one's real
box.width/box.height as `crispWidth`/`crispHeight`, so relation() above starts treating it as a box.width/box.height as `crispWidth`/`crispHeight`, 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 fixed size, the same as an "empty" leaf, instead of one that scales with whatever height/width
@ -220,7 +258,7 @@ function snapQrToCrispSize(node) {
node.forEach(snapQrToCrispSize); node.forEach(snapQrToCrispSize);
return; return;
} }
if (node.type === "qrcode") { if (isQrLeaf(node)) {
const {width: modulesW, height: modulesH} = node.qr; const {width: modulesW, height: modulesH} = node.qr;
const scale = Math.floor(Math.min(node.box.width / modulesW, node.box.height / modulesH)); const scale = Math.floor(Math.min(node.box.width / modulesW, node.box.height / modulesH));
if (!(scale >= 1)) { if (!(scale >= 1)) {
@ -246,22 +284,22 @@ function measureTextBlock(ctx, lines, referencePx) {
} }
/* Turns a resolved content tree (see templateContent below - leaf objects carry a `value` /* 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 encoded rather than a `content` function) into one ready for layout: a QR-family leaf gets its
modules (see encodeQr) and an aspect ratio taken from their real width/height - 1 (square) for actual encoded modules (see encodeQr, keyed off the leaf's own type via QR_LEAF_TYPES) and an
qr/micro-qr, but not for rMQR, whose symbols are rectangular - a text leaf gets its measured aspect ratio taken from their real width/height - 1 (square) for qr/micro-qr, but not for rmqr,
natural aspect ratio, and an "empty" leaf passes through untouched. Multi-line text (`value` is whose symbols are rectangular - a text leaf gets its measured natural aspect ratio, and an
an array) measures as one leaf, not one per line - splitting it into a column of independently- "empty" leaf passes through untouched. Multi-line text (`value` is an array) measures as one
sized leaves would let each line grow to its own full width, ending up at a different font size leaf, not one per line - splitting it into a column of independently-sized leaves would let
than its neighbors, which is legible but not what "one text field" should look like. `codeType` each line grow to its own full width, ending up at a different font size than its neighbors,
is Print.vue's global qr/micro-qr/rmqr choice - see QR_CODE_TYPES - applied to every qrcode leaf which is legible but not what "one text field" should look like. */
in the tree alike, the same way `orientation` applies to the whole tree in layoutContent. */ function buildRenderTree(ctx, node, referencePx) {
function buildRenderTree(ctx, node, referencePx, codeType) {
if (isSplit(node)) { if (isSplit(node)) {
return node.map(child => buildRenderTree(ctx, child, referencePx, codeType)); return node.map(child => buildRenderTree(ctx, child, referencePx));
} }
if (node.type === "qrcode") { if (isQrLeaf(node)) {
const qr = encodeQr(node.value, codeType); const {codeType, ...options} = QR_LEAF_TYPES[node.type];
return {type: "qrcode", aspect: qr.width / qr.height, qr}; const qr = encodeQr(node.value, codeType, options);
return {type: node.type, aspect: qr.width / qr.height, qr};
} }
if (node.type === "text") { if (node.type === "text") {
const lines = Array.isArray(node.value) ? node.value : [node.value]; const lines = Array.isArray(node.value) ? node.value : [node.value];
@ -372,7 +410,7 @@ function drawTree(ctx, node, referencePx, textSizesPx) {
node.forEach(child => drawTree(ctx, child, referencePx, textSizesPx)); node.forEach(child => drawTree(ctx, child, referencePx, textSizesPx));
return; return;
} }
if (node.type === "qrcode") { if (isQrLeaf(node)) {
drawQrLeaf(ctx, node); drawQrLeaf(ctx, node);
} else if (node.type === "text") { } else if (node.type === "text") {
textSizesPx.push(drawTextLeaf(ctx, node, referencePx)); textSizesPx.push(drawTextLeaf(ctx, node, referencePx));
@ -398,14 +436,14 @@ function drawTree(ctx, node, referencePx, textSizesPx) {
here needs to know about that rotation, since relation()/layoutTree() below already solve the here needs to know about that rotation, since relation()/layoutTree() below already solve the
tree in either direction symmetrically. tree in either direction symmetrically.
Sizing runs twice: a first pass treats every qrcode leaf as the scale-free box its real Sizing runs twice: a first pass treats every QR-family leaf as the scale-free box its real
width/height ratio suggests, purely to find out how much room each one would actually be width/height ratio 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 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 second pass then resolves the whole tree again with that real size fixed in, so every sibling
and the overall size reflect what's actually drawn rather than the idealized box no code ever and the overall size reflect what's actually drawn rather than the idealized box no code ever
quite fills. `codeType`, see buildRenderTree. */ quite fills. */
function layoutContent(ctx, content, fixedSize, maxLength, referencePx, pxPerMm, orientation, codeType) { function layoutContent(ctx, content, fixedSize, maxLength, referencePx, pxPerMm, orientation) {
const tree = buildRenderTree(ctx, content, referencePx, codeType); const tree = buildRenderTree(ctx, content, referencePx);
const alongTape = orientation !== "across"; const alongTape = orientation !== "across";
const solve = () => { const solve = () => {
@ -438,15 +476,15 @@ function layoutContent(ctx, content, fixedSize, maxLength, referencePx, pxPerMm,
canvas transform so it lands correctly in that same raster, rather than transposing every box canvas transform so it lands correctly in that same raster, rather than transposing every box
the tree itself computed. See DEBUG_LEAF_BORDERS above to outline every leaf's box. Returns the tree itself computed. See DEBUG_LEAF_BORDERS above to outline every leaf's box. Returns
{textSizesPx}: each "text" leaf's effective font size, in the tree's own left-to-right, {textSizesPx}: each "text" leaf's effective font size, in the tree's own left-to-right,
top-to-bottom order. `codeType` (default "qr"), see buildRenderTree/QR_CODE_TYPES. */ top-to-bottom order. */
export function drawLabel(canvas, tape, content, orientation = "along", codeType = "qr") { export function drawLabel(canvas, tape, content, orientation = "along") {
const maxLength = tape.printLengthPx const maxLength = tape.printLengthPx
? tape.printLengthPx - tape.leadPx - TRAILING_PADDING_PX ? tape.printLengthPx - tape.leadPx - TRAILING_PADDING_PX
: Infinity; : Infinity;
const measureCtx = canvas.getContext("2d"); const measureCtx = canvas.getContext("2d");
const pxPerMm = tape.dpi / 25.4; const pxPerMm = tape.dpi / 25.4;
const {tree, length: contentLength} = layoutContent( const {tree, length: contentLength} = layoutContent(
measureCtx, content, tape.printAreaPx, maxLength, TEXT_REFERENCE_PX, pxPerMm, orientation, codeType); measureCtx, content, tape.printAreaPx, maxLength, TEXT_REFERENCE_PX, pxPerMm, orientation);
const printedLength = tape.printLengthPx || Math.ceil(contentLength + tape.leadPx + TRAILING_PADDING_PX); const printedLength = tape.printLengthPx || Math.ceil(contentLength + tape.leadPx + TRAILING_PADDING_PX);
canvas.width = printedLength; canvas.width = printedLength;
@ -484,13 +522,13 @@ const FALLBACK_DPI = 203; /* reference resolution for turning "empty" leaves' m
/* The no-webusb preview/PNG - same layout tree and renderer as drawLabel, just scaled from a /* 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 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 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. `orientation`/`codeType`, see drawLabel. Returns no real print head here to keep clear of. `orientation`, see drawLabel. Returns {textSizesPx},
{textSizesPx}, see drawLabel. */ see drawLabel. */
export function drawFallbackLabel(canvas, content, orientation = "along", codeType = "qr") { export function drawFallbackLabel(canvas, content, orientation = "along") {
const measureCtx = canvas.getContext("2d"); const measureCtx = canvas.getContext("2d");
const pxPerMm = FALLBACK_DPI / 25.4; const pxPerMm = FALLBACK_DPI / 25.4;
const {tree, length: contentLength} = layoutContent( const {tree, length: contentLength} = layoutContent(
measureCtx, content, FALLBACK_LABEL_HEIGHT_PX, Infinity, TEXT_REFERENCE_PX, pxPerMm, orientation, codeType); measureCtx, content, FALLBACK_LABEL_HEIGHT_PX, Infinity, TEXT_REFERENCE_PX, pxPerMm, orientation);
canvas.width = Math.ceil(contentLength); canvas.width = Math.ceil(contentLength);
canvas.height = FALLBACK_LABEL_HEIGHT_PX; canvas.height = FALLBACK_LABEL_HEIGHT_PX;
@ -522,8 +560,15 @@ export function drawFallbackLabel(canvas, content, orientation = "along", codeTy
export const LABEL_CONTENT_BUILDERS = { export const LABEL_CONTENT_BUILDERS = {
// The self-contained Item URL (see docs/design-in-progress/items-labels.md) - what a // 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 // 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. // frontend/backend/item with no other context, not just this browser's history. Nothing here
"item-url": ({user, id}) => `${window.location.origin}/i/${encodeHandleForUrl(user)}/${id}`, // needs anything beyond the prefill's own {userHandle, id} - the short link (see Print.vue's
// `shortUrl` computed) needs a store lookup no synchronous builder can do, so it's never baked
// into `text` this way; it's just another field/template a user can pick once the page is up.
"item": ({userHandle, id}) => `${window.location.origin}/i/${encodeHandleForUrl(userHandle)}/${id}`,
// Storage locations have no long-form URL route of their own (see router.js - only items get
// an /i/:handle/:id) - so there's nothing to bake synchronously here. Its base vars (below)
// still populate normally, so the short link (Print.vue's `shortUrl`) and any future
// location template are still available; `text` just starts blank until one is picked.
}; };
export function buildLabelContent(prefill) { export function buildLabelContent(prefill) {
@ -534,27 +579,42 @@ export function buildLabelContent(prefill) {
return build ? build(prefill.components) : ""; return build ? build(prefill.components) : "";
} }
// A prefill's {userHandle, id} is the same raw identity for either resource kind below - this
// just splits the handle into label-layouts.js's separate `user`/`domain` base vars the same way
// store.js's own lookupServer does, and tags on whichever id field the resource's own templates
// key their required_vars by.
function splitUserHandle(userHandle) {
if (!userHandle) {
return null;
}
const at = userHandle.indexOf("@");
return {
user: at === -1 ? userHandle : userHandle.slice(0, at),
domain: at === -1 ? "" : userHandle.slice(at + 1),
};
}
// Seeds for the *base* label-layouts.js vars (see BASE_VARS there) - keyed by `kind` for the same // 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) // reason LABEL_CONTENT_BUILDERS is. Format-string vars derived from these (userHandle, itemUrl,
// aren't built here; they're calculated live from whatever the base vars currently are (see // itemHandle, …) aren't built here; they're calculated live from whatever the base vars currently
// label-layouts.js's DERIVED_VARS), prefill or hand-typed alike. A field missing from the result // are (see label-layouts.js's DERIVED_VARS and Print.vue's `shortUrl`), prefill or hand-typed
// (rather than present-but-empty) is what label-layouts.js's templateIsAvailable treats as "not // alike. A field missing from the result (rather than present-but-empty) is what
// available", so builders should only include a field once its inputs actually check out. // 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 = { const LABEL_FIELD_BUILDERS = {
// `user` here is already a full "user@domain" handle (that's the form login usernames take - "item": ({userHandle, id}) => {
// see Login.vue/store.js), so it's split into label-layouts.js's separate `user`/`domain` const split = splitUserHandle(userHandle);
// base vars the same way store.js's own lookupServer does, rather than stuffing the whole if (!split || !id) {
// handle into one field the way userHandle (now derived from these two) used to be.
"item-url": ({user, id}) => {
if (!user || !id) {
return {}; return {};
} }
const at = user.indexOf("@"); return {...split, itemId: String(id)};
return { },
user: at === -1 ? user : user.slice(0, at), "storage-location": ({userHandle, id}) => {
domain: at === -1 ? "" : user.slice(at + 1), const split = splitUserHandle(userHandle);
itemId: String(id), if (!split || !id) {
}; return {};
}
return {...split, locationId: String(id)};
}, },
}; };

View file

@ -43,6 +43,9 @@
<router-link v-if="shortIdLink(item)" :to="shortIdLink(item)"> <router-link v-if="shortIdLink(item)" :to="shortIdLink(item)">
<b-icon-link></b-icon-link> <b-icon-link></b-icon-link>
</router-link> </router-link>
<router-link v-if="printLinkFor(item)" :to="printLinkFor(item)">
<b-icon-qr-code></b-icon-qr-code>
</router-link>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@ -77,6 +80,10 @@
class="btn btn-secondary btn-sm"> class="btn btn-secondary btn-sm">
<b-icon-link></b-icon-link> <b-icon-link></b-icon-link>
</router-link> </router-link>
<router-link v-if="printLinkFor(item)" :to="printLinkFor(item)"
class="btn btn-secondary btn-sm">
<b-icon-qr-code></b-icon-qr-code>
</router-link>
</div> </div>
</div> </div>
</div> </div>
@ -138,6 +145,17 @@ export default {
if (owner_identity_id === undefined) return null if (owner_identity_id === undefined) return null
return shortenedRoute({kind: 'item', owner_identity_id, item_local_id: item.id}) return shortenedRoute({kind: 'item', owner_identity_id, item_local_id: item.id})
}, },
// Routes to Print.vue with this item's own raw identity - userHandle + id, the same shape
// InventoryDetail.vue's own Print label button sends - rather than any pre-built link, so
// the print page can derive every item template (item-handle, owner-handle, item-url,
// the short link, ) itself and isn't tied to whichever one this button "suggests".
// Group-owned items have no individual owner handle - short-id.js's group_item kind
// resolves them via owner_group instead (see shortIdLink above) - so there's no
// {userHandle, id} to build here yet; they get no print link until that's supported too.
printLinkFor(item) {
if (!item.owner) return null
return {path: '/print', query: {kind: 'item', userHandle: item.owner, id: item.id}}
},
}, },
async mounted() { async mounted() {
await this.fetchInventoryItems() await this.fetchInventoryItems()

View file

@ -47,7 +47,7 @@
Delete Delete
</button> </button>
<button class="btn btn-secondary" <button class="btn btn-secondary"
@click="$router.push({path: '/print', query: {kind: 'item-url', user, id}})"> @click="$router.push({path: '/print', query: {kind: 'item', userHandle: user, id}})">
<b-icon-printer></b-icon-printer> <b-icon-printer></b-icon-printer>
Print label Print label
</button> </button>

View file

@ -102,21 +102,6 @@
</div> </div>
</div> </div>
<div class="mb-3">
<label class="form-label">Code type</label>
<div class="btn-group d-block" role="group">
<template v-for="opt in codeTypeOptions" :key="opt.id">
<input type="radio" class="btn-check"
:id="'code-type-' + opt.id + '-fallback'"
autocomplete="off" :value="opt.id" v-model="codeType">
<label class="btn btn-outline-secondary"
:for="'code-type-' + opt.id + '-fallback'">
{{ opt.label }}
</label>
</template>
</div>
</div>
<button class="btn btn-primary" :disabled="!fallbackReady" @click="downloadPng"> <button class="btn btn-primary" :disabled="!fallbackReady" @click="downloadPng">
<b-icon-download class="me-1"></b-icon-download> <b-icon-download class="me-1"></b-icon-download>
Download label as PNG Download label as PNG
@ -207,19 +192,6 @@
</label> </label>
</div> </div>
</div> </div>
<div class="col-auto">
<label class="form-label">Code type</label>
<div class="btn-group" role="group">
<template v-for="opt in codeTypeOptions" :key="opt.id">
<input type="radio" class="btn-check"
:id="'code-type-' + opt.id" autocomplete="off"
:value="opt.id" v-model="codeType">
<label class="btn btn-outline-secondary" :for="'code-type-' + opt.id">
{{ opt.label }}
</label>
</template>
</div>
</div>
<div class="col-auto"> <div class="col-auto">
<label class="form-label">Copies</label> <label class="form-label">Copies</label>
<input type="number" class="form-control copies-input" <input type="number" class="form-control copies-input"
@ -275,7 +247,7 @@
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<label-layout-preview :fields="fields" :value="selectedTemplate" :code-type="codeType" <label-layout-preview :fields="fields" :value="selectedTemplate"
@input="selectedTemplate = $event"></label-layout-preview> @input="selectedTemplate = $event"></label-layout-preview>
</div> </div>
</div> </div>
@ -287,12 +259,27 @@
<script> <script>
import * as BIcons from "bootstrap-icons-vue"; import * as BIcons from "bootstrap-icons-vue";
import {markRaw, nextTick} from "vue"; import {markRaw, nextTick} from "vue";
import {mapActions, mapGetters} from "vuex";
import BaseLayout from "@/components/BaseLayout.vue"; 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 {tapeFromStatus, drawLabel, drawFallbackLabel, buildLabelContent, buildLabelFields, preloadQrEncoder, QR_CODE_TYPES} from "@/label.js"; import {tapeFromStatus, drawLabel, drawFallbackLabel, buildLabelContent, buildLabelFields, preloadQrEncoder} from "@/label.js";
import {LABEL_TEMPLATES, BASE_VARS, DERIVED_VARS, withDerivedVars, templateContent} from "@/label-layouts.js"; import {LABEL_TEMPLATES, BASE_VARS, DERIVED_VARS, withDerivedVars, templateContent} from "@/label-layouts.js";
import {shortenedRoute} from "@/router";
// The extra "Calculated" fields (and label-layouts.js "Short link (QR code)" template input) this
// view adds on top of label-layouts.js's own DERIVED_VARS - resolving either needs the current
// identityIdByHandle map (see store.js's fetchIdMap) to turn a handle into the numeric
// owner_identity_id short-id.js's 'item'/'storage_location' kinds encode, so neither can be a pure
// fields->value calc like the others and both live here instead of in label-layouts.js. Excluded
// from baseVars below since, unlike every other entry KNOWN_VARS picks up from a template's
// required_vars, neither is ever typed directly.
const SHORT_URL_VAR = "shortUrl";
// The bare short-id.js token itself (e.g. "~AbCd12"), with no domain or leading "/" - what
// shortUrl's own path is built from (see fields()/the shortId method below), for a label that
// wants just the compact code rather than a full scannable URL.
const SHORT_ID_VAR = "shortId";
// 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*
@ -314,15 +301,6 @@ const RULER_TIERS = [
{aboveMm: 500, tickMm: 5, majorEveryMm: 25}, {aboveMm: 500, tickMm: 5, majorEveryMm: 25},
]; ];
// Display labels for label.js's QR_CODE_TYPES - the code-type button group below reads this
// (via codeTypeOptions) rather than hard-coding its own list, so a new symbology added to
// anyd-qr.js/QR_CODE_TYPES only needs a label here, not a whole new radio group.
const CODE_TYPE_LABELS = {
"qr": "QR",
"micro-qr": "Micro QR",
"rmqr": "rMQR",
};
export default { export default {
name: "Print", name: "Print",
components: { components: {
@ -386,10 +364,6 @@ export default {
// instead, so it reads across the tape rather than along it. See label.js's // instead, so it reads across the tape rather than along it. See label.js's
// drawLabel/drawFallbackLabel for how that turn is actually drawn. // drawLabel/drawFallbackLabel for how that turn is actually drawn.
orientation: "along", orientation: "along",
// Which of label.js's QR_CODE_TYPES every "qrcode" leaf in the current template
// renders as - one global choice for the whole label, same as `orientation` above,
// since a template only ever has one scannable code in practice.
codeType: "qr",
fallbackReady: false, fallbackReady: false,
// TODO: replace with the real commands for our printers. // TODO: replace with the real commands for our printers.
@ -417,19 +391,20 @@ export default {
}; };
}, },
computed: { computed: {
...mapGetters(["identityIdByHandle"]),
// The base variables the "Label content" form renders an input for, and the derived ones // The base variables the "Label content" form renders an input for, and the derived ones
// it instead calculates and lists read-only beside that form - plain passthroughs, but // it instead calculates and lists read-only beside that form - plain passthroughs, but
// keep the template from importing label-layouts.js just for these. // keep the template from importing label-layouts.js just for these. SHORT_URL_VAR is
// excluded here even though the "Short link (QR code)" template's required_vars puts it in
// BASE_VARS (it isn't a label-layouts.js DERIVED_VARS entry) - see fields() below for why
// it's calculated, not typed. SHORT_ID_VAR isn't referenced by any template's
// required_vars (so isn't actually in BASE_VARS today), filtered out too in case one ever
// is.
baseVars() { baseVars() {
return BASE_VARS; return BASE_VARS.filter(v => v !== SHORT_URL_VAR && v !== SHORT_ID_VAR);
},
// {id, label} pairs for the code-type button group (see CODE_TYPE_LABELS above), in
// label.js's QR_CODE_TYPES order.
codeTypeOptions() {
return QR_CODE_TYPES.map(id => ({id, label: CODE_TYPE_LABELS[id]}));
}, },
derivedVars() { derivedVars() {
return Object.keys(DERIVED_VARS); return [...Object.keys(DERIVED_VARS), SHORT_ID_VAR, SHORT_URL_VAR];
}, },
// Named content fields the templates draw from: the form's own base vars, plus every // 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 // DERIVED_VARS format string calculated live from those - so typing a userHandle and
@ -445,7 +420,15 @@ export default {
base[v] = this.varValues[v]; base[v] = this.varValues[v];
} }
} }
return withDerivedVars(base); const derived = withDerivedVars(base);
const shortId = this.shortId(derived);
if (shortId) {
derived[SHORT_ID_VAR] = shortId;
if (derived.webdomain) {
derived[SHORT_URL_VAR] = derived.webdomain + "/" + shortId;
}
}
return derived;
}, },
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];
@ -546,13 +529,6 @@ export default {
this.redrawFallback(); this.redrawFallback();
} }
}, },
codeType() {
if (this.usbSupported) {
this.redraw();
} else {
this.redrawFallback();
}
},
// Covers connecting/disconnecting/switching printers - anything that changes the // Covers connecting/disconnecting/switching printers - anything that changes the
// tape dimensions redraw() sizes the canvas from. flush: 'post' because the canvas // tape dimensions redraw() sizes the canvas from. flush: 'post' because the canvas
// itself only exists once `tape` is truthy (see the v-if/v-else in the template), so // itself only exists once `tape` is truthy (see the v-if/v-else in the template), so
@ -565,6 +541,8 @@ export default {
}, },
}, },
methods: { methods: {
...mapActions(["fetchIdMap"]),
// Turns a camelCase variable name (see label-layouts.js's KNOWN_VARS) into a form label, // 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 // e.g. "itemHandle" -> "Item Handle" - so adding a new template variable doesn't also
// require hand-writing a label for it here. // require hand-writing a label for it here.
@ -572,6 +550,36 @@ export default {
return v.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, c => c.toUpperCase()); return v.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, c => c.toUpperCase());
}, },
// The same shortened link Inventory.vue/StorageLocation.vue's own shortIdLink builds for
// one of their rows, but as the bare token (see short-id.js's encodeShortId) rather than a
// router target or full URL - shortenedRoute's own leading "/" is stripped since this is a
// plain display field/potential label content, not something this view itself navigates
// to; fields() below reattaches a domain and slash to build shortUrl from this same value,
// so the two can never disagree. Which short-id.js kind applies depends on which id field
// the current prefill's LABEL_FIELD_BUILDERS populated (see label.js) - itemId for an
// item, locationId for a storage location - rather than trusting the prefill's own `kind`
// directly, so hand-typing userHandle+itemId with no prefill at all still resolves this
// the same way. Falls back to no value - same as an unresolved DERIVED_VARS entry - until
// identityIdByHandle has loaded (see mounted's fetchIdMap) or if the handle isn't in it.
shortId(f) {
if (!f.userHandle) {
return null;
}
const owner_identity_id = this.identityIdByHandle[f.userHandle];
if (owner_identity_id === undefined) {
return null;
}
if (f.itemId) {
return shortenedRoute({kind: "item", owner_identity_id, item_local_id: f.itemId}).slice(1);
}
if (f.locationId) {
return shortenedRoute({
kind: "storage_location", owner_identity_id, storage_location_id: f.locationId
}).slice(1);
}
return null;
},
// Ticks from 0 up to totalMm, each positioned in on-screen pixels via tapePxPerMm - shared // Ticks from 0 up to totalMm, each positioned in on-screen pixels via tapePxPerMm - shared
// by the horizontal/vertical ruler computeds above. Both the plain tick spacing and the // by the horizontal/vertical ruler computeds above. Both the plain tick spacing and the
// labeled/major one come from the shared rulerTier (see above), not from this totalMm, so // labeled/major one come from the shared rulerTier (see above), not from this totalMm, so
@ -663,7 +671,7 @@ export default {
this.resizeObserver.observe(canvas.parentElement); this.resizeObserver.observe(canvas.parentElement);
let textSizesPx; let textSizesPx;
try { try {
({textSizesPx} = drawLabel(canvas, this.tape, content, this.orientation, this.codeType)); ({textSizesPx} = drawLabel(canvas, this.tape, content, this.orientation));
} catch (e) { } catch (e) {
this.error = e.message; this.error = e.message;
return; return;
@ -689,7 +697,7 @@ export default {
} }
this.resizeObserver.observe(canvas.parentElement); this.resizeObserver.observe(canvas.parentElement);
try { try {
drawFallbackLabel(canvas, content, this.orientation, this.codeType); drawFallbackLabel(canvas, content, this.orientation);
} catch (e) { } catch (e) {
this.error = e.message; this.error = e.message;
return; return;
@ -823,10 +831,16 @@ export default {
}, },
async mounted() { async mounted() {
this.drawCandidateFontTests(); this.drawCandidateFontTests();
// Not awaited: itemShortUrl just reads whatever identityIdByHandle currently holds (see
// the fields computed), so this resolving after first render only means it shows ""
// briefly rather than blocking the rest of mounted's (unrelated) printer/wasm setup.
this.fetchIdMap().catch(e => {
this.error = e.message;
});
this.resizeObserver = new ResizeObserver(this.handleContainerResize); this.resizeObserver = new ResizeObserver(this.handleContainerResize);
// Kicked off here rather than awaited immediately, so it loads concurrently with // Kicked off here rather than awaited immediately, so it loads concurrently with
// MultiPrinterBlob below instead of serializing two independent wasm fetches - every // MultiPrinterBlob below instead of serializing two independent wasm fetches - every
// redraw()/redrawFallback() call below still waits on it first, since a "qrcode" leaf // redraw()/redrawFallback() call below still waits on it first, since a qr/mqr/rmqr leaf
// throws (see label.js's encodeQr) until it resolves. // throws (see label.js's encodeQr) until it resolves.
const qrReady = preloadQrEncoder(); const qrReady = preloadQrEncoder();
if (!("usb" in navigator)) { if (!("usb" in navigator)) {

View file

@ -14,10 +14,57 @@
<div class="row"> <div class="row">
<div class="col-lg-6 mb-4"> <div class="col-lg-6 mb-4">
<div class="card h-100"> <div class="card h-100">
<div class="card-header"> <div class="card-header">
<h5 class="card-title mb-0">Scan from image</h5> <h5 class="card-title mb-0">Results</h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<ul class="scan-results">
<li v-for="(c, i) in cameraLog" :key="i">
<span class="scan-result-type">{{ c.type }}</span>
<span v-if="c.metaText" class="text-muted"> {{ c.metaText }}</span>
<span class="text-muted"> @ {{ c.time }}</span>
<div class="text-break">{{ c.text }}</div>
</li>
</ul>
</div>
</div>
</div>
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header">
<h5 class="card-title mb-0">Scan from camera</h5>
</div>
<div class="card-body">
<p v-if="!cameraSupported" class="text-muted">
This browser has no camera API available.
</p>
<template v-else>
<div class="row g-2 align-items-end mb-3">
<div class="col-auto flex-grow-1">
<label class="form-label">Camera</label>
<select class="form-control" v-model="selectedCameraId">
<option value="">Auto (prefer recent, then rear camera)</option>
<option v-for="(c, i) in cameras" :key="c.deviceId"
:value="c.deviceId">
{{ cleanCameraLabel(c.label) || ('Camera ' + (i + 1)) }}
</option>
</select>
</div>
<div v-if="cameraRunning" class="col-auto">
<button class="btn btn-outline-secondary" @click="stopCamera">
<b-icon-stop-fill class="me-1"></b-icon-stop-fill>
Stop
</button>
</div>
</div>
<p v-if="cameraRes" class="text-muted small">{{ cameraRes }}</p>
<div class="video-wrap">
<video ref="video" autoplay muted playsinline></video>
<canvas ref="overlay"></canvas>
</div>
</template>
<input ref="fileInput" type="file" accept="image/*" class="form-control mb-3" <input ref="fileInput" type="file" accept="image/*" class="form-control mb-3"
@change="onFileInputChange"> @change="onFileInputChange">
<div class="dropzone" :class="{'dropzone-hover': dropHover}" <div class="dropzone" :class="{'dropzone-hover': dropHover}"
@ -40,58 +87,6 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header">
<h5 class="card-title mb-0">Scan from camera</h5>
</div>
<div class="card-body">
<p v-if="!cameraSupported" class="text-muted">
This browser has no camera API available.
</p>
<template v-else>
<div class="row g-2 align-items-end mb-3">
<div class="col-auto flex-grow-1">
<label class="form-label">Camera</label>
<select class="form-control" v-model="selectedCameraId"
:disabled="cameraRunning">
<option value="">Auto (prefer rear camera)</option>
<option v-for="(c, i) in cameras" :key="c.deviceId"
:value="c.deviceId">
{{ c.label || ('Camera ' + (i + 1)) }}
</option>
</select>
</div>
<div class="col-auto">
<button v-if="!cameraRunning" class="btn btn-primary"
:disabled="!insecureContext" @click="startCamera">
<b-icon-camera class="me-1"></b-icon-camera>
Start camera
</button>
<button v-else class="btn btn-outline-secondary" @click="stopCamera">
<b-icon-stop-fill class="me-1"></b-icon-stop-fill>
Stop
</button>
</div>
</div>
<p v-if="cameraRes" class="text-muted small">{{ cameraRes }}</p>
<div class="video-wrap">
<video ref="video" autoplay muted playsinline></video>
<canvas ref="overlay"></canvas>
</div>
</template>
<ul class="scan-results">
<li v-for="(c, i) in cameraLog" :key="i">
<span class="scan-result-type">{{ c.type }}</span>
<span v-if="c.metaText" class="text-muted"> {{ c.metaText }}</span>
<span class="text-muted"> @ {{ c.time }}</span>
<div class="text-break">{{ c.text }}</div>
</li>
</ul>
</div>
</div>
</div>
</div> </div>
</div> </div>
</main> </main>
@ -102,6 +97,7 @@
import * as BIcons from "bootstrap-icons-vue"; import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue"; import BaseLayout from "@/components/BaseLayout.vue";
import {loadAnyDCode} from "../../vendor/anyd-qr.js"; import {loadAnyDCode} from "../../vendor/anyd-qr.js";
import cameraManager from "@/cameraManager.js";
// A decode result's metadata (see anyd-qr.js's SymbolMetadata) as one short, human-readable // A decode result's metadata (see anyd-qr.js's SymbolMetadata) as one short, human-readable
// string, e.g. "(v7, ec=M, mask=3)" - shared by both the "from image" and "from camera" result // string, e.g. "(v7, ec=M, mask=3)" - shared by both the "from image" and "from camera" result
@ -145,12 +141,27 @@ export default {
cameraSupported: Boolean(navigator.mediaDevices?.getUserMedia), cameraSupported: Boolean(navigator.mediaDevices?.getUserMedia),
cameras: [], cameras: [],
selectedCameraId: "", localSelectedCameraId: "",
cameraRunning: false, cameraRunning: false,
cameraRes: "", cameraRes: "",
cameraLog: [], cameraLog: [],
}; };
}, },
computed: {
// Switches camera the moment the select changes, rather than waiting for an explicit
// "apply" step - matches prototypes/camera-inputs/InputPhoto.vue's selectedCameraId.
selectedCameraId: {
get() {
return this.localSelectedCameraId;
},
set(value) {
this.localSelectedCameraId = value;
if (this.cameraRunning) {
this.switchCamera(value || null);
}
},
},
},
methods: { methods: {
// Draws `file`/a pasted or dropped Blob onto fileCanvas and decodes whatever's in it - // Draws `file`/a pasted or dropped Blob onto fileCanvas and decodes whatever's in it -
// shared by the file input, drag&drop and paste handlers below rather than each // shared by the file input, drag&drop and paste handlers below rather than each
@ -229,8 +240,17 @@ export default {
if (!navigator.mediaDevices?.enumerateDevices) { if (!navigator.mediaDevices?.enumerateDevices) {
return; return;
} }
const devices = await navigator.mediaDevices.enumerateDevices(); await cameraManager.enumerateDevices();
this.cameras = devices.filter(d => d.kind === "videoinput"); this.cameras = cameraManager.getAvailableCameras();
},
cleanCameraLabel(label) {
if (!label) return "";
const parts = label.split(":").map((p) => p.trim());
if (parts.length === 2 && parts[0] === parts[1]) {
return parts[0];
}
return label;
}, },
syncOverlaySize() { syncOverlaySize() {
@ -260,6 +280,7 @@ export default {
// OVERLAY_CLEAR_MS - a one-off decode shouldn't leave a stale box on screen once the code // OVERLAY_CLEAR_MS - a one-off decode shouldn't leave a stale box on screen once the code
// has moved out of frame. // has moved out of frame.
drawOverlay(codes) { drawOverlay(codes) {
console.log("drawOverlay", codes);
const overlay = this.$refs.overlay; const overlay = this.$refs.overlay;
const video = this.$refs.video; const video = this.$refs.video;
if (!overlay || !video || !video.videoWidth) { if (!overlay || !video || !video.videoWidth) {
@ -286,26 +307,43 @@ export default {
() => ctx.clearRect(0, 0, overlay.width, overlay.height), OVERLAY_CLEAR_MS); () => ctx.clearRect(0, 0, overlay.width, overlay.height), OVERLAY_CLEAR_MS);
}, },
// Attaches `stream` to the video element - shared by startCamera and the
// camera-switch/reconnect paths so the scanner (which just keeps reading frames off the
// same video element) never needs to be recreated. videoWidth/videoHeight aren't known yet
// right after play() - the video's own "resize" event (see onVideoResize) is what tells us
// the new aspect ratio has actually taken effect, which is also when the overlay needs to
// be resized to match.
setupVideoStream(stream) {
const video = this.$refs.video;
if (!video) return;
video.srcObject = stream;
video.play();
},
// Fires on the video element's own "resize"/"loadedmetadata" events - i.e. whenever its
// intrinsic width/height actually change (initial load, or switching to a camera with a
// different native resolution/aspect ratio) - so the overlay canvas is resized to match the
// video's *new* rendered size rather than the stale one from before the switch.
onVideoResize() {
const video = this.$refs.video;
if (!video || !video.videoWidth) {
return;
}
this.syncOverlaySize();
this.cameraRes = `Native resolution: ${video.videoWidth} x ${video.videoHeight}`;
},
async startCamera() { async startCamera() {
this.error = null; this.error = null;
try { try {
const constraints = { const stream = await cameraManager.openStream(this.localSelectedCameraId || null);
video: this.selectedCameraId this.setupVideoStream(stream);
? {deviceId: {exact: this.selectedCameraId}}
: {facingMode: {ideal: "environment"}},
audio: false,
};
this.stream = await navigator.mediaDevices.getUserMedia(constraints);
const video = this.$refs.video;
video.srcObject = this.stream;
await video.play();
this.syncOverlaySize();
this.cameraRes = `Native resolution: ${video.videoWidth} x ${video.videoHeight}`;
await this.populateCameraList(); await this.populateCameraList();
this.localSelectedCameraId = cameraManager.getSelectedCameraId() || "";
const anyd = await this.anydPromise; const anyd = await this.anydPromise;
this.scanner = anyd.createCameraScanner(video, { this.scanner = anyd.createCameraScanner(this.$refs.video, {
fps: 12, fps: 4,
downscale: 2, downscale: 2,
onDecode: (codes) => { onDecode: (codes) => {
codes.forEach(this.logDecode); codes.forEach(this.logDecode);
@ -320,11 +358,20 @@ export default {
} }
}, },
async switchCamera(cameraId) {
if (!this.cameraRunning) return;
try {
const stream = await cameraManager.switchCamera(cameraId);
if (stream) this.setupVideoStream(stream);
} catch (e) {
this.error = `Camera error: ${e.message ?? e}`;
}
},
stopCamera() { stopCamera() {
this.scanner?.stop(); this.scanner?.stop();
this.scanner = null; this.scanner = null;
this.stream?.getTracks().forEach(t => t.stop()); cameraManager.closeStream();
this.stream = null;
if (this.$refs.video) { if (this.$refs.video) {
this.$refs.video.srcObject = null; this.$refs.video.srcObject = null;
} }
@ -334,25 +381,56 @@ export default {
overlay.getContext("2d").clearRect(0, 0, overlay.width, overlay.height); overlay.getContext("2d").clearRect(0, 0, overlay.width, overlay.height);
} }
}, },
// The camera manager already tries a fallback device on disconnect (see cameraManager.js)
// and only fires 'camera-disconnected' once none is left, so by the time this runs there's
// nothing left to fall back to and the running scan session has to stop.
handleCameraDisconnected(event) {
if (!this.cameraRunning) return;
this.error = `Camera error: ${event.detail?.error || "Camera disconnected"}`;
this.stopCamera();
},
handleCameraReconnected() {
if (!this.cameraRunning) return;
const stream = cameraManager.getActiveStream();
if (!stream) return;
this.error = null;
this.setupVideoStream(stream);
this.localSelectedCameraId = cameraManager.getSelectedCameraId() || "";
},
}, },
created() { created() {
// Kept as a promise (rather than awaited here) so every caller - decodeBlob, startCamera - // Kept as a promise (rather than awaited here) so every caller - decodeBlob, startCamera -
// shares the same in-flight load instead of each triggering its own; loadAnyDCode's own // shares the same in-flight load instead of each triggering its own; loadAnyDCode's own
// memoization (see anyd-qr.js) means a second call anywhere else in the app is free too. // memoization (see anyd-qr.js) means a second call anywhere else in the app is free too.
this.anydPromise = loadAnyDCode(); this.anydPromise = loadAnyDCode();
this.stream = null;
this.scanner = null; this.scanner = null;
this.clearOverlayTimer = null; this.clearOverlayTimer = null;
}, },
mounted() { mounted() {
window.addEventListener("paste", this.onPaste); window.addEventListener("paste", this.onPaste);
window.addEventListener("resize", this.syncOverlaySize); window.addEventListener("resize", this.syncOverlaySize);
window.addEventListener("camera-disconnected", this.handleCameraDisconnected);
window.addEventListener("camera-reconnected", this.handleCameraReconnected);
navigator.mediaDevices?.addEventListener("devicechange", this.populateCameraList);
this.$refs.video?.addEventListener("loadedmetadata", this.onVideoResize);
this.$refs.video?.addEventListener("resize", this.onVideoResize);
if (this.cameraSupported && this.insecureContext) {
this.startCamera();
} else {
this.populateCameraList(); this.populateCameraList();
}
}, },
beforeUnmount() { beforeUnmount() {
this.stopCamera(); this.stopCamera();
window.removeEventListener("paste", this.onPaste); window.removeEventListener("paste", this.onPaste);
window.removeEventListener("resize", this.syncOverlaySize); window.removeEventListener("resize", this.syncOverlaySize);
window.removeEventListener("camera-disconnected", this.handleCameraDisconnected);
window.removeEventListener("camera-reconnected", this.handleCameraReconnected);
navigator.mediaDevices?.removeEventListener("devicechange", this.populateCameraList);
this.$refs.video?.removeEventListener("loadedmetadata", this.onVideoResize);
this.$refs.video?.removeEventListener("resize", this.onVideoResize);
clearTimeout(this.clearOverlayTimer); clearTimeout(this.clearOverlayTimer);
}, },
} }
@ -412,7 +490,6 @@ export default {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: .4rem; gap: .4rem;
max-height: 260px;
overflow-y: auto; overflow-y: auto;
} }

View file

@ -46,6 +46,9 @@
<router-link v-if="shortIdLink(location)" :to="shortIdLink(location)"> <router-link v-if="shortIdLink(location)" :to="shortIdLink(location)">
<b-icon-link></b-icon-link> <b-icon-link></b-icon-link>
</router-link> </router-link>
<router-link v-if="printLinkFor(location)" :to="printLinkFor(location)">
<b-icon-qr-code></b-icon-qr-code>
</router-link>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@ -79,6 +82,10 @@
class="btn btn-secondary btn-sm"> class="btn btn-secondary btn-sm">
<b-icon-link></b-icon-link> <b-icon-link></b-icon-link>
</router-link> </router-link>
<router-link v-if="printLinkFor(location)" :to="printLinkFor(location)"
class="btn btn-secondary btn-sm">
<b-icon-qr-code></b-icon-qr-code>
</router-link>
</div> </div>
</div> </div>
</div> </div>
@ -126,6 +133,14 @@ export default {
if (owner_identity_id === undefined) return null if (owner_identity_id === undefined) return null
return shortenedRoute({kind: 'storage_location', owner_identity_id, storage_location_id: location.id}) return shortenedRoute({kind: 'storage_location', owner_identity_id, storage_location_id: location.id})
}, },
// Routes to Print.vue with this location's own raw identity - userHandle + id - rather
// than any pre-built link, the same shape Inventory.vue's printLinkFor sends for an item
// (see label.js's "storage-location" LABEL_FIELD_BUILDERS entry). Locations are always
// individually owned (see StorageLocationViewSet.get_queryset), so unlike Inventory.vue's
// version this never has to fall back to "no metadata available".
printLinkFor(location) {
return {path: '/print', query: {kind: 'storage-location', userHandle: location.owner, id: location.id}}
},
}, },
async mounted() { async mounted() {
await this.fetchStorageLocations() await this.fetchStorageLocations()