stash
This commit is contained in:
parent
5c3b7fc252
commit
bbe52e4a78
9 changed files with 817 additions and 287 deletions
222
frontend/src/cameraManager.js
Normal file
222
frontend/src/cameraManager.js
Normal 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();
|
||||
|
|
@ -54,12 +54,13 @@
|
|||
<b-icon-save></b-icon-save> Save
|
||||
</button>
|
||||
</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"
|
||||
aria-label="Select Camera Source">
|
||||
<option disabled value="">Select Camera Source</option>
|
||||
<option v-for="camera in availableCameras" :key="camera.deviceId" :value="camera">
|
||||
{{ camera.label }}
|
||||
<option v-for="camera in availableCameras" :key="camera.deviceId" :value="camera.deviceId">
|
||||
{{ cleanCameraLabel(camera.label) || `Camera ${availableCameras.indexOf(camera) + 1}` }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
|
@ -74,6 +75,8 @@
|
|||
</style>
|
||||
|
||||
<script>
|
||||
import cameraManager from '@/cameraManager.js';
|
||||
|
||||
export default {
|
||||
name: "WebcamFileSource",
|
||||
data: () => ({
|
||||
|
|
@ -81,80 +84,105 @@ export default {
|
|||
error: false,
|
||||
show_modal: false,
|
||||
availableCameras: [],
|
||||
selectedCamera: undefined,
|
||||
localSelectedCameraId: null,
|
||||
capturing: false,
|
||||
streaming: false,
|
||||
stream: undefined,
|
||||
dataImage: undefined
|
||||
}),
|
||||
methods: {
|
||||
async attemptGetUserMedia(constraints) {
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: false,
|
||||
video: constraints
|
||||
}).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;
|
||||
emits: ['input'],
|
||||
computed: {
|
||||
selectedCameraId: {
|
||||
get() {
|
||||
return this.localSelectedCameraId || cameraManager.getSelectedCameraId();
|
||||
},
|
||||
async assignStream() {
|
||||
console.log(this.stream.getTracks()[0]);
|
||||
const track = this.stream.getTracks()[0];
|
||||
if (track.getCapabilities) {
|
||||
const capabilities = this.stream.getTracks()[0].getCapabilities();
|
||||
await this.attemptGetUserMedia({
|
||||
deviceId: capabilities.deviceId,
|
||||
width: {ideal: capabilities.width.max},
|
||||
height: {ideal: capabilities.height.max}
|
||||
});
|
||||
set(value) {
|
||||
if (value) {
|
||||
this.localSelectedCameraId = value;
|
||||
this.switchCamera(value);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
setupVideoStream(stream) {
|
||||
this.capturing = true;
|
||||
this.streaming = false;
|
||||
const {video} = this.$refs;
|
||||
video.srcObject = this.stream;
|
||||
video.addEventListener('canplay', () => {
|
||||
const video = this.$refs.video;
|
||||
if (!video) return;
|
||||
|
||||
video.srcObject = stream;
|
||||
video.play();
|
||||
video.oncanplay = () => {
|
||||
this.streaming = true;
|
||||
localStorage.setItem("WebcamFileSource#previousDevice", this.selectedCamera.deviceId);
|
||||
}, false);
|
||||
await video.play();
|
||||
this.availableCameras = cameraManager.getAvailableCameras();
|
||||
this.localSelectedCameraId = cameraManager.getSelectedCameraId();
|
||||
};
|
||||
},
|
||||
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() {
|
||||
if (this.capturing) {
|
||||
this.stream.getTracks().forEach(s => s.stop());
|
||||
cameraManager.closeStream();
|
||||
this.capturing = false;
|
||||
this.streaming = false;
|
||||
this.stream = undefined;
|
||||
}
|
||||
},
|
||||
async enumerateCameras() {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
this.availableCameras = devices.filter(device => device.kind === "videoinput");
|
||||
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;
|
||||
},
|
||||
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() {
|
||||
this.show_modal = true;
|
||||
const previousDevice = localStorage.getItem("WebcamFileSource#previousDevice");
|
||||
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();
|
||||
}
|
||||
await this.openStream();
|
||||
},
|
||||
async close() {
|
||||
this.closeStream();
|
||||
this.capturing = false;
|
||||
this.show_modal = false;
|
||||
this.dataImage = undefined;
|
||||
this.error = false;
|
||||
this.lastError = undefined;
|
||||
},
|
||||
captureVideoImage() {
|
||||
const {video, canvas} = this.$refs;
|
||||
|
|
@ -165,11 +193,14 @@ export default {
|
|||
context.drawImage(video, 0, 0, videoWidth, videoHeight);
|
||||
this.dataImage = canvas.toDataURL('image/jpeg', 0.5);
|
||||
this.closeStream();
|
||||
this.capturing = false;
|
||||
},
|
||||
retake() {
|
||||
this.dataImage = undefined;
|
||||
this.open();
|
||||
this.openStream();
|
||||
},
|
||||
async refreshCameraList() {
|
||||
await cameraManager.enumerateDevices();
|
||||
this.availableCameras = cameraManager.getAvailableCameras();
|
||||
},
|
||||
async save() {
|
||||
const mimeType = this.dataImage.split(';')[0].split(':')[1];
|
||||
|
|
@ -194,14 +225,15 @@ export default {
|
|||
}
|
||||
},
|
||||
mounted() {
|
||||
navigator.mediaDevices.addEventListener("devicechange", (event) => {
|
||||
console.log("devicechange");
|
||||
this.enumerateCameras();
|
||||
if (this.availableCameras.findIndex(({deviceId}) => deviceId === this.selectedCamera.deviceId) === -1) {
|
||||
window.addEventListener('camera-disconnected', this.handleCameraDisconnected);
|
||||
window.addEventListener('camera-reconnected', this.handleCameraReconnected);
|
||||
navigator.mediaDevices?.addEventListener('devicechange', this.refreshCameraList);
|
||||
},
|
||||
beforeUnmount() {
|
||||
window.removeEventListener('camera-disconnected', this.handleCameraDisconnected);
|
||||
window.removeEventListener('camera-reconnected', this.handleCameraReconnected);
|
||||
navigator.mediaDevices?.removeEventListener('devicechange', this.refreshCameraList);
|
||||
this.closeStream();
|
||||
this.open();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
@ -1,33 +1,118 @@
|
|||
// Each template's `layout` is a tree as described in label.js, with "qrcode"/"text" leaves'
|
||||
// `content` a function from the resolved field values (see label.js's buildLabelFields) to what
|
||||
// they render - `null`/`undefined` from that function means the field isn't available yet (see
|
||||
// templateIsAvailable below). A template is only selectable once every leaf's `content` resolves
|
||||
// to a value.
|
||||
// Each template's `layout` is a tree as described in label.js, with leaves whose `type` is one of
|
||||
// label.js's QR_LEAF_TYPES keys or "text", and whose `content` is a function from the resolved
|
||||
// field values (see label.js's buildLabelFields) to what they render - `null`/`undefined` from
|
||||
// that function means the field isn't available yet (see templateIsAvailable below). A template
|
||||
// is only selectable once every leaf's `content` resolves to a value.
|
||||
const GAP = {type: "empty", "min-width": "1mm", "min-height": "1mm"};
|
||||
|
||||
// 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 = [
|
||||
{
|
||||
id: "qr", name: "QR code only", description: "Just the code - smallest label, prints fastest.",
|
||||
required_vars: ["text"],
|
||||
layout: [{type: "qrcode", content: c => c.text}]
|
||||
id: "mqr-token", name: "MQR Token", description: "The code with the encoded text printed next to it.",
|
||||
required_vars: ["shortId"],
|
||||
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.",
|
||||
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",
|
||||
description: "The code with the encoded text printed below it.",
|
||||
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",
|
||||
description: "The code with the encoded text printed below it.",
|
||||
required_vars: ["itemId", "text", "userHandle"],
|
||||
layout: [[{type: "text", content: c => "Item: "+c.itemId}, GAP, {
|
||||
type: "qrcode",
|
||||
type: "qr",
|
||||
content: c => c.text
|
||||
}, GAP, {type: "text", content: c => c.userHandle}]]
|
||||
},
|
||||
|
|
@ -68,31 +153,38 @@ export const LABEL_TEMPLATES = [
|
|||
id: "item-url-qr-handle", name: "Item URL + handle",
|
||||
description: "Scannable item URL, with the item's compact handle printed alongside.",
|
||||
required_vars: ["itemUrl", "itemHandle"],
|
||||
layout: [{type: "qrcode", content: c => c.itemUrl}, GAP, {type: "text", content: c => c.itemHandle}]
|
||||
layout: [{type: "qr", content: c => c.itemUrl}, GAP, {type: "text", content: c => c.itemHandle}]
|
||||
},
|
||||
{
|
||||
id: "item-url-qr-owner", name: "Item URL + owner",
|
||||
description: "Scannable item URL, with the owner's handle printed alongside.",
|
||||
required_vars: ["itemUrl", "userHandle"],
|
||||
layout: [{type: "qrcode", content: c => c.itemUrl}, GAP, {type: "text", content: c => c.userHandle}]
|
||||
layout: [{type: "qr", content: c => c.itemUrl}, GAP, {type: "text", content: c => c.userHandle}]
|
||||
},
|
||||
{
|
||||
id: "item-url-qr-id", name: "Item URL + item ID",
|
||||
description: "Scannable item URL, with the bare item id printed alongside.",
|
||||
required_vars: ["itemUrl", "itemId"],
|
||||
layout: [{type: "qrcode", content: c => c.itemUrl}, GAP, {type: "text", content: c => c.itemId}]
|
||||
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",
|
||||
description: "Scannable item URL, with the owner's handle and the item id on two lines alongside.",
|
||||
required_vars: ["itemUrl", "userHandle", "itemId"],
|
||||
layout: [{type: "qrcode", content: c => c.itemUrl}, GAP, {type: "text", content: c => [c.userHandle, c.itemId]}]
|
||||
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",
|
||||
description: "Scannable item URL, with the owner's handle and the item id on two lines alongside.",
|
||||
required_vars: ["itemUrl", "userHandle", "itemId"],
|
||||
layout: [{type: "qrcode", content: c => c.itemUrl}, GAP, [{
|
||||
layout: [{type: "qr", content: c => c.itemUrl}, GAP, [{
|
||||
type: "text",
|
||||
content: c => c.userHandle
|
||||
}, 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
|
||||
// string for "qrcode"/plain "text", every line for a multi-line "text" (see LABEL_TEMPLATES'
|
||||
// "owner-id-text" and "item-url-qr-owner-id").
|
||||
// string for a QR-family leaf (any label.js QR_LEAF_TYPES entry) or plain "text", every line for a
|
||||
// multi-line "text" (see LABEL_TEMPLATES' "owner-id-text" and "item-url-qr-owner-id").
|
||||
function isResolved(value) {
|
||||
return Array.isArray(value) ? value.every(isResolved) : value !== undefined && value !== null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,21 +4,57 @@ import {encodeHandleForUrl} from "@/router"
|
|||
// 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
|
||||
// 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 -
|
||||
// callers already have to handle layoutContent throwing, so this reuses that path rather than
|
||||
// adding a second failure mode.
|
||||
// resolves, a QR-family leaf (any QR_LEAF_TYPES entry) throws (see encodeQr) the same way an
|
||||
// oversized value already does - callers already have to handle layoutContent throwing, so this
|
||||
// reuses that path rather than adding a second failure mode.
|
||||
let anyd = null;
|
||||
|
||||
export function preloadQrEncoder() {
|
||||
return loadAnyDCode().then(instance => { anyd = instance; });
|
||||
}
|
||||
|
||||
// The three symbologies anyd-qr.js exposes (see its CodeType) - Print.vue's code-type selector
|
||||
// offers exactly these. rMQR's matrix isn't square (see encodeQr's width/height below), unlike
|
||||
// qr/micro-qr, which always are.
|
||||
export const QR_CODE_TYPES = ["qr", "micro-qr", "rmqr"];
|
||||
// Maps each of label-layouts.js's LABEL_TEMPLATES leaf types that draw a code to the anyd-qr.js
|
||||
// symbology/error-correction level (and, for rMQR, size strategy) it renders as (see anyd's
|
||||
// EncodeOptions - `ecc`/`size` - and its per-symbology EcLevel enums, `wasm.rs`'s
|
||||
// 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) {
|
||||
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
|
||||
// are kept separate rather than a single `size` (the old library's own shape, always square)
|
||||
// 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};
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
row into a column, wrap it in an extra one-element array - that array is one depth
|
||||
deeper, so its lone child (the original row) is now read at odd depth.
|
||||
- An object is a leaf: {type: "qrcode", content} / {type: "text", content} draw a QR code
|
||||
or text block, where `content` is a function from the resolved field values to the
|
||||
string (or, for "text", an array of strings - one per line) to render. {type: "empty",
|
||||
- An object is a leaf: {type, content} where `type` is one of QR_LEAF_TYPES' keys draws a
|
||||
QR/Micro QR/rMQR code at that id's symbology/error-correction level (see QR_LEAF_TYPES
|
||||
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
|
||||
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
|
||||
|
|
@ -140,10 +178,10 @@ function parseMm(value, key) {
|
|||
requesting the direction a split doesn't naturally combine in just inverts its own relation. */
|
||||
function relation(node, ownAxis, wantWidth, pxPerMm) {
|
||||
if (!isSplit(node)) {
|
||||
if (node.type === "qrcode" && node.crispWidth !== undefined) {
|
||||
if (isQrLeaf(node) && node.crispWidth !== undefined) {
|
||||
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;
|
||||
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
|
||||
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
|
||||
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
|
||||
|
|
@ -220,7 +258,7 @@ function snapQrToCrispSize(node) {
|
|||
node.forEach(snapQrToCrispSize);
|
||||
return;
|
||||
}
|
||||
if (node.type === "qrcode") {
|
||||
if (isQrLeaf(node)) {
|
||||
const {width: modulesW, height: modulesH} = node.qr;
|
||||
const scale = Math.floor(Math.min(node.box.width / modulesW, node.box.height / modulesH));
|
||||
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`
|
||||
rather than a `content` function) into one ready for layout: a QR leaf gets its actual encoded
|
||||
modules (see encodeQr) and an aspect ratio taken from their real width/height - 1 (square) for
|
||||
qr/micro-qr, but not for rMQR, whose symbols are rectangular - a text leaf gets its measured
|
||||
natural aspect ratio, and an "empty" leaf passes through untouched. Multi-line text (`value` is
|
||||
an array) measures as one leaf, not one per line - splitting it into a column of independently-
|
||||
sized leaves would let each line grow to its own full width, ending up at a different font size
|
||||
than its neighbors, which is legible but not what "one text field" should look like. `codeType`
|
||||
is Print.vue's global qr/micro-qr/rmqr choice - see QR_CODE_TYPES - applied to every qrcode leaf
|
||||
in the tree alike, the same way `orientation` applies to the whole tree in layoutContent. */
|
||||
function buildRenderTree(ctx, node, referencePx, codeType) {
|
||||
rather than a `content` function) into one ready for layout: a QR-family leaf gets its
|
||||
actual encoded modules (see encodeQr, keyed off the leaf's own type via QR_LEAF_TYPES) and an
|
||||
aspect ratio taken from their real width/height - 1 (square) for qr/micro-qr, but not for rmqr,
|
||||
whose symbols are rectangular - a text leaf gets its measured natural aspect ratio, and an
|
||||
"empty" leaf passes through untouched. Multi-line text (`value` is an array) measures as one
|
||||
leaf, not one per line - splitting it into a column of independently-sized leaves would let
|
||||
each line grow to its own full width, ending up at a different font size than its neighbors,
|
||||
which is legible but not what "one text field" should look like. */
|
||||
function buildRenderTree(ctx, node, referencePx) {
|
||||
if (isSplit(node)) {
|
||||
return node.map(child => buildRenderTree(ctx, child, referencePx, codeType));
|
||||
return node.map(child => buildRenderTree(ctx, child, referencePx));
|
||||
}
|
||||
if (node.type === "qrcode") {
|
||||
const qr = encodeQr(node.value, codeType);
|
||||
return {type: "qrcode", aspect: qr.width / qr.height, qr};
|
||||
if (isQrLeaf(node)) {
|
||||
const {codeType, ...options} = QR_LEAF_TYPES[node.type];
|
||||
const qr = encodeQr(node.value, codeType, options);
|
||||
return {type: node.type, aspect: qr.width / qr.height, qr};
|
||||
}
|
||||
if (node.type === "text") {
|
||||
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));
|
||||
return;
|
||||
}
|
||||
if (node.type === "qrcode") {
|
||||
if (isQrLeaf(node)) {
|
||||
drawQrLeaf(ctx, node);
|
||||
} else if (node.type === "text") {
|
||||
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
|
||||
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
|
||||
offered; from that, snapQrToCrispSize pins each one's real (smaller, crisp-pixel) size. The
|
||||
second pass then resolves the whole tree again with that real size fixed in, so every sibling
|
||||
and the overall size reflect what's actually drawn rather than the idealized box no code ever
|
||||
quite fills. `codeType`, see buildRenderTree. */
|
||||
function layoutContent(ctx, content, fixedSize, maxLength, referencePx, pxPerMm, orientation, codeType) {
|
||||
const tree = buildRenderTree(ctx, content, referencePx, codeType);
|
||||
quite fills. */
|
||||
function layoutContent(ctx, content, fixedSize, maxLength, referencePx, pxPerMm, orientation) {
|
||||
const tree = buildRenderTree(ctx, content, referencePx);
|
||||
const alongTape = orientation !== "across";
|
||||
|
||||
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
|
||||
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,
|
||||
top-to-bottom order. `codeType` (default "qr"), see buildRenderTree/QR_CODE_TYPES. */
|
||||
export function drawLabel(canvas, tape, content, orientation = "along", codeType = "qr") {
|
||||
top-to-bottom order. */
|
||||
export function drawLabel(canvas, tape, content, orientation = "along") {
|
||||
const maxLength = tape.printLengthPx
|
||||
? tape.printLengthPx - tape.leadPx - TRAILING_PADDING_PX
|
||||
: Infinity;
|
||||
const measureCtx = canvas.getContext("2d");
|
||||
const pxPerMm = tape.dpi / 25.4;
|
||||
const {tree, 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);
|
||||
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
|
||||
fixed reference height instead of a real tape's, and with no maxLength (there's no physical
|
||||
tape to run out of, so the canvas just grows to fit) and no printer feed margin, since there's
|
||||
no real print head here to keep clear of. `orientation`/`codeType`, see drawLabel. Returns
|
||||
{textSizesPx}, see drawLabel. */
|
||||
export function drawFallbackLabel(canvas, content, orientation = "along", codeType = "qr") {
|
||||
no real print head here to keep clear of. `orientation`, see drawLabel. Returns {textSizesPx},
|
||||
see drawLabel. */
|
||||
export function drawFallbackLabel(canvas, content, orientation = "along") {
|
||||
const measureCtx = canvas.getContext("2d");
|
||||
const pxPerMm = FALLBACK_DPI / 25.4;
|
||||
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.height = FALLBACK_LABEL_HEIGHT_PX;
|
||||
|
|
@ -522,8 +560,15 @@ export function drawFallbackLabel(canvas, content, orientation = "along", codeTy
|
|||
export const LABEL_CONTENT_BUILDERS = {
|
||||
// The self-contained Item URL (see docs/design-in-progress/items-labels.md) - what a
|
||||
// printed label actually encodes, since scanning it has to resolve the right
|
||||
// frontend/backend/item with no other context, not just this browser's history.
|
||||
"item-url": ({user, id}) => `${window.location.origin}/i/${encodeHandleForUrl(user)}/${id}`,
|
||||
// frontend/backend/item with no other context, not just this browser's history. Nothing here
|
||||
// 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) {
|
||||
|
|
@ -534,27 +579,42 @@ export function buildLabelContent(prefill) {
|
|||
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
|
||||
// reason LABEL_CONTENT_BUILDERS is. Format-string vars derived from these (itemUrl, itemHandle)
|
||||
// aren't built here; they're calculated live from whatever the base vars currently are (see
|
||||
// label-layouts.js's DERIVED_VARS), prefill or hand-typed alike. A field missing from the result
|
||||
// (rather than present-but-empty) is what label-layouts.js's templateIsAvailable treats as "not
|
||||
// available", so builders should only include a field once its inputs actually check out.
|
||||
// reason LABEL_CONTENT_BUILDERS is. Format-string vars derived from these (userHandle, itemUrl,
|
||||
// itemHandle, …) aren't built here; they're calculated live from whatever the base vars currently
|
||||
// are (see label-layouts.js's DERIVED_VARS and Print.vue's `shortUrl`), prefill or hand-typed
|
||||
// alike. A field missing from the result (rather than present-but-empty) is what
|
||||
// label-layouts.js's templateIsAvailable treats as "not available", so builders should only
|
||||
// include a field once its inputs actually check out.
|
||||
const LABEL_FIELD_BUILDERS = {
|
||||
// `user` here is already a full "user@domain" handle (that's the form login usernames take -
|
||||
// see Login.vue/store.js), so it's split into label-layouts.js's separate `user`/`domain`
|
||||
// base vars the same way store.js's own lookupServer does, rather than stuffing the whole
|
||||
// handle into one field the way userHandle (now derived from these two) used to be.
|
||||
"item-url": ({user, id}) => {
|
||||
if (!user || !id) {
|
||||
"item": ({userHandle, id}) => {
|
||||
const split = splitUserHandle(userHandle);
|
||||
if (!split || !id) {
|
||||
return {};
|
||||
}
|
||||
const at = user.indexOf("@");
|
||||
return {
|
||||
user: at === -1 ? user : user.slice(0, at),
|
||||
domain: at === -1 ? "" : user.slice(at + 1),
|
||||
itemId: String(id),
|
||||
};
|
||||
return {...split, itemId: String(id)};
|
||||
},
|
||||
"storage-location": ({userHandle, id}) => {
|
||||
const split = splitUserHandle(userHandle);
|
||||
if (!split || !id) {
|
||||
return {};
|
||||
}
|
||||
return {...split, locationId: String(id)};
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,9 @@
|
|||
<router-link v-if="shortIdLink(item)" :to="shortIdLink(item)">
|
||||
<b-icon-link></b-icon-link>
|
||||
</router-link>
|
||||
<router-link v-if="printLinkFor(item)" :to="printLinkFor(item)">
|
||||
<b-icon-qr-code></b-icon-qr-code>
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
|
@ -77,6 +80,10 @@
|
|||
class="btn btn-secondary btn-sm">
|
||||
<b-icon-link></b-icon-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>
|
||||
|
|
@ -138,6 +145,17 @@ export default {
|
|||
if (owner_identity_id === undefined) return null
|
||||
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() {
|
||||
await this.fetchInventoryItems()
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@
|
|||
Delete
|
||||
</button>
|
||||
<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>
|
||||
Print label
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -102,21 +102,6 @@
|
|||
</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">
|
||||
<b-icon-download class="me-1"></b-icon-download>
|
||||
Download label as PNG
|
||||
|
|
@ -207,19 +192,6 @@
|
|||
</label>
|
||||
</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">
|
||||
<label class="form-label">Copies</label>
|
||||
<input type="number" class="form-control copies-input"
|
||||
|
|
@ -275,7 +247,7 @@
|
|||
|
||||
<div class="row">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -287,12 +259,27 @@
|
|||
<script>
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import {markRaw, nextTick} from "vue";
|
||||
import {mapActions, mapGetters} from "vuex";
|
||||
import BaseLayout from "@/components/BaseLayout.vue";
|
||||
import LabelLayoutPreview from "@/components/LabelLayoutPreview.vue";
|
||||
|
||||
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 {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
|
||||
// own emscripten glue resolves its .wasm sibling relative to *its own*
|
||||
|
|
@ -314,15 +301,6 @@ const RULER_TIERS = [
|
|||
{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 {
|
||||
name: "Print",
|
||||
components: {
|
||||
|
|
@ -386,10 +364,6 @@ export default {
|
|||
// instead, so it reads across the tape rather than along it. See label.js's
|
||||
// drawLabel/drawFallbackLabel for how that turn is actually drawn.
|
||||
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,
|
||||
// TODO: replace with the real commands for our printers.
|
||||
|
|
@ -417,19 +391,20 @@ export default {
|
|||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(["identityIdByHandle"]),
|
||||
// The base variables the "Label content" form renders an input for, and the derived ones
|
||||
// it instead calculates and lists read-only beside that form - plain passthroughs, but
|
||||
// keep the template from importing label-layouts.js just for these.
|
||||
// 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() {
|
||||
return BASE_VARS;
|
||||
},
|
||||
// {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]}));
|
||||
return BASE_VARS.filter(v => v !== SHORT_URL_VAR && v !== SHORT_ID_VAR);
|
||||
},
|
||||
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
|
||||
// DERIVED_VARS format string calculated live from those - so typing a userHandle and
|
||||
|
|
@ -445,7 +420,15 @@ export default {
|
|||
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() {
|
||||
return LABEL_TEMPLATES.find(t => t.id === this.selectedTemplate) || LABEL_TEMPLATES[0];
|
||||
|
|
@ -546,13 +529,6 @@ export default {
|
|||
this.redrawFallback();
|
||||
}
|
||||
},
|
||||
codeType() {
|
||||
if (this.usbSupported) {
|
||||
this.redraw();
|
||||
} else {
|
||||
this.redrawFallback();
|
||||
}
|
||||
},
|
||||
// Covers connecting/disconnecting/switching printers - anything that changes the
|
||||
// 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
|
||||
|
|
@ -565,6 +541,8 @@ export default {
|
|||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions(["fetchIdMap"]),
|
||||
|
||||
// Turns a camelCase variable name (see label-layouts.js's KNOWN_VARS) into a form label,
|
||||
// e.g. "itemHandle" -> "Item Handle" - so adding a new template variable doesn't also
|
||||
// require hand-writing a label for it here.
|
||||
|
|
@ -572,6 +550,36 @@ export default {
|
|||
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
|
||||
// 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
|
||||
|
|
@ -663,7 +671,7 @@ export default {
|
|||
this.resizeObserver.observe(canvas.parentElement);
|
||||
let textSizesPx;
|
||||
try {
|
||||
({textSizesPx} = drawLabel(canvas, this.tape, content, this.orientation, this.codeType));
|
||||
({textSizesPx} = drawLabel(canvas, this.tape, content, this.orientation));
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
return;
|
||||
|
|
@ -689,7 +697,7 @@ export default {
|
|||
}
|
||||
this.resizeObserver.observe(canvas.parentElement);
|
||||
try {
|
||||
drawFallbackLabel(canvas, content, this.orientation, this.codeType);
|
||||
drawFallbackLabel(canvas, content, this.orientation);
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
return;
|
||||
|
|
@ -823,10 +831,16 @@ export default {
|
|||
},
|
||||
async mounted() {
|
||||
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);
|
||||
// Kicked off here rather than awaited immediately, so it loads concurrently with
|
||||
// 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.
|
||||
const qrReady = preloadQrEncoder();
|
||||
if (!("usb" in navigator)) {
|
||||
|
|
|
|||
|
|
@ -14,10 +14,57 @@
|
|||
<div class="row">
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card h-100">
|
||||
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Scan from image</h5>
|
||||
<h5 class="card-title mb-0">Results</h5>
|
||||
</div>
|
||||
<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"
|
||||
@change="onFileInputChange">
|
||||
<div class="dropzone" :class="{'dropzone-hover': dropHover}"
|
||||
|
|
@ -40,58 +87,6 @@
|
|||
</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>
|
||||
</main>
|
||||
|
|
@ -102,6 +97,7 @@
|
|||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import BaseLayout from "@/components/BaseLayout.vue";
|
||||
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
|
||||
// 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),
|
||||
cameras: [],
|
||||
selectedCameraId: "",
|
||||
localSelectedCameraId: "",
|
||||
cameraRunning: false,
|
||||
cameraRes: "",
|
||||
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: {
|
||||
// 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
|
||||
|
|
@ -229,8 +240,17 @@ export default {
|
|||
if (!navigator.mediaDevices?.enumerateDevices) {
|
||||
return;
|
||||
}
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
this.cameras = devices.filter(d => d.kind === "videoinput");
|
||||
await cameraManager.enumerateDevices();
|
||||
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() {
|
||||
|
|
@ -260,6 +280,7 @@ export default {
|
|||
// OVERLAY_CLEAR_MS - a one-off decode shouldn't leave a stale box on screen once the code
|
||||
// has moved out of frame.
|
||||
drawOverlay(codes) {
|
||||
console.log("drawOverlay", codes);
|
||||
const overlay = this.$refs.overlay;
|
||||
const video = this.$refs.video;
|
||||
if (!overlay || !video || !video.videoWidth) {
|
||||
|
|
@ -286,26 +307,43 @@ export default {
|
|||
() => 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() {
|
||||
this.error = null;
|
||||
try {
|
||||
const constraints = {
|
||||
video: this.selectedCameraId
|
||||
? {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}`;
|
||||
const stream = await cameraManager.openStream(this.localSelectedCameraId || null);
|
||||
this.setupVideoStream(stream);
|
||||
await this.populateCameraList();
|
||||
this.localSelectedCameraId = cameraManager.getSelectedCameraId() || "";
|
||||
|
||||
const anyd = await this.anydPromise;
|
||||
this.scanner = anyd.createCameraScanner(video, {
|
||||
fps: 12,
|
||||
this.scanner = anyd.createCameraScanner(this.$refs.video, {
|
||||
fps: 4,
|
||||
downscale: 2,
|
||||
onDecode: (codes) => {
|
||||
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() {
|
||||
this.scanner?.stop();
|
||||
this.scanner = null;
|
||||
this.stream?.getTracks().forEach(t => t.stop());
|
||||
this.stream = null;
|
||||
cameraManager.closeStream();
|
||||
if (this.$refs.video) {
|
||||
this.$refs.video.srcObject = null;
|
||||
}
|
||||
|
|
@ -334,25 +381,56 @@ export default {
|
|||
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() {
|
||||
// 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
|
||||
// memoization (see anyd-qr.js) means a second call anywhere else in the app is free too.
|
||||
this.anydPromise = loadAnyDCode();
|
||||
this.stream = null;
|
||||
this.scanner = null;
|
||||
this.clearOverlayTimer = null;
|
||||
},
|
||||
mounted() {
|
||||
window.addEventListener("paste", this.onPaste);
|
||||
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();
|
||||
}
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.stopCamera();
|
||||
window.removeEventListener("paste", this.onPaste);
|
||||
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);
|
||||
},
|
||||
}
|
||||
|
|
@ -412,7 +490,6 @@ export default {
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .4rem;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,9 @@
|
|||
<router-link v-if="shortIdLink(location)" :to="shortIdLink(location)">
|
||||
<b-icon-link></b-icon-link>
|
||||
</router-link>
|
||||
<router-link v-if="printLinkFor(location)" :to="printLinkFor(location)">
|
||||
<b-icon-qr-code></b-icon-qr-code>
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
|
@ -79,6 +82,10 @@
|
|||
class="btn btn-secondary btn-sm">
|
||||
<b-icon-link></b-icon-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>
|
||||
|
|
@ -126,6 +133,14 @@ export default {
|
|||
if (owner_identity_id === undefined) return null
|
||||
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() {
|
||||
await this.fetchStorageLocations()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue