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
|
<b-icon-save></b-icon-save> 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>
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue