stash
This commit is contained in:
parent
aa94c92000
commit
f87b689e27
3 changed files with 314 additions and 399 deletions
|
|
@ -9,6 +9,9 @@
|
||||||
<SearchBox/>
|
<SearchBox/>
|
||||||
<div class="navbar-collapse collapse">
|
<div class="navbar-collapse collapse">
|
||||||
<ul class="navbar-nav navbar-align">
|
<ul class="navbar-nav navbar-align">
|
||||||
|
<router-link to="/scan?navigate=immediate" class="nav-icon">
|
||||||
|
<b-icon-qr-code-scan class="bi-valign-middle"></b-icon-qr-code-scan>
|
||||||
|
</router-link>
|
||||||
<Notifications :notifications="notifications"/>
|
<Notifications :notifications="notifications"/>
|
||||||
<Messages :messages="messages"/>
|
<Messages :messages="messages"/>
|
||||||
<UserDropdown/>
|
<UserDropdown/>
|
||||||
|
|
|
||||||
287
frontend/src/components/CameraScanner.vue
Normal file
287
frontend/src/components/CameraScanner.vue
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
<template>
|
||||||
|
<div class="camera-scanner">
|
||||||
|
<p v-if="!cameraSupported" class="text-muted">
|
||||||
|
This browser has no camera API available.
|
||||||
|
</p>
|
||||||
|
<template v-else>
|
||||||
|
<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>
|
||||||
|
<div class="row g-2 align-items-end mb-3">
|
||||||
|
<div class="col-auto flex-grow-1">
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import * as BIcons from "bootstrap-icons-vue";
|
||||||
|
import {loadAnyDCode} from "../../vendor/anyd-qr.js";
|
||||||
|
import cameraManager from "@/cameraManager.js";
|
||||||
|
|
||||||
|
// How long a detected code's overlay box stays drawn before fading, so it doesn't linger once
|
||||||
|
// the code has moved out of frame.
|
||||||
|
const OVERLAY_CLEAR_MS = 2000;
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "CameraScanner",
|
||||||
|
components: {
|
||||||
|
...BIcons
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
// Whether to open the camera as soon as it's available (still gated on browser support
|
||||||
|
// and a secure context - the camera API is unavailable otherwise). false lets a caller
|
||||||
|
// that wants to gate opening the camera behind its own condition (e.g. a "scan" button)
|
||||||
|
// skip the auto-open.
|
||||||
|
autostart: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emits: [
|
||||||
|
// Fired once per onDecode batch with anyd-qr.js's raw decoded codes (type/text/metadata/box).
|
||||||
|
"scan",
|
||||||
|
// Fired with a message string when a camera error occurs, or null once it clears (e.g. on
|
||||||
|
// reconnect) - lets the caller show/clear it alongside errors from its own other sources.
|
||||||
|
"error",
|
||||||
|
],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
cameraSupported: Boolean(navigator.mediaDevices?.getUserMedia),
|
||||||
|
cameras: [],
|
||||||
|
localSelectedCameraId: "",
|
||||||
|
cameraRunning: false,
|
||||||
|
cameraRes: "",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
// Switches camera immediately on selection change (no explicit "apply" step), matching
|
||||||
|
// 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: {
|
||||||
|
// Device labels only populate once permission is granted, so this is called again right
|
||||||
|
// after getUserMedia resolves in startCamera, not just once up front.
|
||||||
|
async populateCameraList() {
|
||||||
|
if (!navigator.mediaDevices?.enumerateDevices) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await cameraManager.enumerateDevices();
|
||||||
|
this.cameras = cameraManager.getAvailableCameras();
|
||||||
|
},
|
||||||
|
|
||||||
|
cleanCameraLabel(label) {
|
||||||
|
if (!label) return "";
|
||||||
|
const parts = label.split(":").map((p) => p.trim());
|
||||||
|
if (parts.length === 2 && parts[0] === parts[1]) {
|
||||||
|
return parts[0];
|
||||||
|
}
|
||||||
|
return label;
|
||||||
|
},
|
||||||
|
|
||||||
|
syncOverlaySize() {
|
||||||
|
const video = this.$refs.video;
|
||||||
|
const overlay = this.$refs.overlay;
|
||||||
|
if (!video || !overlay) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
overlay.width = video.clientWidth;
|
||||||
|
overlay.height = video.clientHeight;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Draws each detected code's box (video's native pixel space, scaled to the overlay's
|
||||||
|
// displayed size) and fades it out after OVERLAY_CLEAR_MS.
|
||||||
|
drawOverlay(codes) {
|
||||||
|
const overlay = this.$refs.overlay;
|
||||||
|
const video = this.$refs.video;
|
||||||
|
if (!overlay || !video || !video.videoWidth) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ctx = overlay.getContext("2d");
|
||||||
|
ctx.clearRect(0, 0, overlay.width, overlay.height);
|
||||||
|
const scaleX = overlay.width / video.videoWidth;
|
||||||
|
const scaleY = overlay.height / video.videoHeight;
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.strokeStyle = "#3ce77c";
|
||||||
|
ctx.fillStyle = "#3ce77c";
|
||||||
|
ctx.font = "14px sans-serif";
|
||||||
|
for (const code of codes) {
|
||||||
|
if (!code.box) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const {x0, y0, x1, y1} = code.box;
|
||||||
|
ctx.strokeRect(x0 * scaleX, y0 * scaleY, (x1 - x0) * scaleX, (y1 - y0) * scaleY);
|
||||||
|
ctx.fillText(`${code.type}: ${code.text ?? ""}`, x0 * scaleX, Math.max(14, y0 * scaleY - 4));
|
||||||
|
}
|
||||||
|
clearTimeout(this.clearOverlayTimer);
|
||||||
|
this.clearOverlayTimer = setTimeout(
|
||||||
|
() => ctx.clearRect(0, 0, overlay.width, overlay.height), OVERLAY_CLEAR_MS);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Attaches `stream` to the video element so the scanner never needs recreating. See
|
||||||
|
// docs/implementation.md#video-stream-attach-and-resize-sync.
|
||||||
|
setupVideoStream(stream) {
|
||||||
|
const video = this.$refs.video;
|
||||||
|
if (!video) return;
|
||||||
|
video.srcObject = stream;
|
||||||
|
video.play();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Resizes the overlay to match the video's new size once its intrinsic dimensions change.
|
||||||
|
// See docs/implementation.md#video-stream-attach-and-resize-sync.
|
||||||
|
onVideoResize() {
|
||||||
|
const video = this.$refs.video;
|
||||||
|
if (!video || !video.videoWidth) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.syncOverlaySize();
|
||||||
|
this.cameraRes = `Native resolution: ${video.videoWidth} x ${video.videoHeight}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
async startCamera() {
|
||||||
|
this.$emit("error", null);
|
||||||
|
try {
|
||||||
|
const stream = await cameraManager.openStream(this.localSelectedCameraId || null);
|
||||||
|
this.setupVideoStream(stream);
|
||||||
|
await this.populateCameraList();
|
||||||
|
this.localSelectedCameraId = cameraManager.getSelectedCameraId() || "";
|
||||||
|
|
||||||
|
const anyd = await this.anydPromise;
|
||||||
|
this.scanner = anyd.createCameraScanner(this.$refs.video, {
|
||||||
|
fps: 4,
|
||||||
|
downscale: 2,
|
||||||
|
onDecode: (codes) => {
|
||||||
|
this.$emit("scan", codes);
|
||||||
|
this.drawOverlay(codes);
|
||||||
|
},
|
||||||
|
onError: (err) => console.error("[camera scan]", err),
|
||||||
|
});
|
||||||
|
this.scanner.start();
|
||||||
|
this.cameraRunning = true;
|
||||||
|
} catch (e) {
|
||||||
|
this.$emit("error", `Camera error: ${e.message ?? e}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async switchCamera(cameraId) {
|
||||||
|
if (!this.cameraRunning) return;
|
||||||
|
try {
|
||||||
|
const stream = await cameraManager.switchCamera(cameraId);
|
||||||
|
if (stream) this.setupVideoStream(stream);
|
||||||
|
} catch (e) {
|
||||||
|
this.$emit("error", `Camera error: ${e.message ?? e}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
stopCamera() {
|
||||||
|
this.scanner?.stop();
|
||||||
|
this.scanner = null;
|
||||||
|
cameraManager.closeStream();
|
||||||
|
if (this.$refs.video) {
|
||||||
|
this.$refs.video.srcObject = null;
|
||||||
|
}
|
||||||
|
this.cameraRunning = false;
|
||||||
|
const overlay = this.$refs.overlay;
|
||||||
|
if (overlay) {
|
||||||
|
overlay.getContext("2d").clearRect(0, 0, overlay.width, overlay.height);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// cameraManager already tries a fallback device and only fires this event once none
|
||||||
|
// remain, so the running scan session has to stop here.
|
||||||
|
handleCameraDisconnected(event) {
|
||||||
|
if (!this.cameraRunning) return;
|
||||||
|
this.$emit("error", `Camera error: ${event.detail?.error || "Camera disconnected"}`);
|
||||||
|
this.stopCamera();
|
||||||
|
},
|
||||||
|
|
||||||
|
handleCameraReconnected() {
|
||||||
|
if (!this.cameraRunning) return;
|
||||||
|
const stream = cameraManager.getActiveStream();
|
||||||
|
if (!stream) return;
|
||||||
|
this.$emit("error", null);
|
||||||
|
this.setupVideoStream(stream);
|
||||||
|
this.localSelectedCameraId = cameraManager.getSelectedCameraId() || "";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
// Kept as a promise (not awaited) so this and Scan.vue's own file-decode path share one
|
||||||
|
// in-flight load; loadAnyDCode also memoizes, so later calls elsewhere in the app are
|
||||||
|
// free too.
|
||||||
|
this.anydPromise = loadAnyDCode();
|
||||||
|
this.scanner = null;
|
||||||
|
this.clearOverlayTimer = null;
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
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 && window.isSecureContext && this.autostart) {
|
||||||
|
this.startCamera();
|
||||||
|
} else {
|
||||||
|
this.populateCameraList();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
this.stopCamera();
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.video-wrap {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: .75rem;
|
||||||
|
background: #000;
|
||||||
|
border-radius: .35rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-wrap video {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-wrap canvas {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -34,10 +34,13 @@
|
||||||
<a v-if="c.link?.href" :href="c.link.href" target="_blank"
|
<a v-if="c.link?.href" :href="c.link.href" target="_blank"
|
||||||
rel="noopener noreferrer">{{ c.text }}</a>
|
rel="noopener noreferrer">{{ c.text }}</a>
|
||||||
<template v-else-if="c.link">
|
<template v-else-if="c.link">
|
||||||
<router-link :to="c.link.to">{{ c.text }}</router-link>
|
<router-link :to="c.link.to">{{ c.text }}
|
||||||
<span v-if="c.error" class="text-danger"> → {{ c.error }}</span>
|
<span v-if="c.error" class="text-danger"> → {{
|
||||||
<span v-else-if="c.description" class="text-muted"> → {{ c.description }}</span>
|
c.error
|
||||||
<span v-else-if="c.description === undefined" class="text-muted"> → resolving…</span>
|
}}</span>
|
||||||
|
<span v-else-if="c.description"> → {{ c.description }}</span>
|
||||||
|
<span v-else-if="c.description === undefined" class="text-muted"> → resolving…</span>
|
||||||
|
</router-link>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>{{ c.text }}</template>
|
<template v-else>{{ c.text }}</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -53,63 +56,7 @@
|
||||||
<h5 class="card-title mb-0">Scan from camera</h5>
|
<h5 class="card-title mb-0">Scan from camera</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p v-if="!cameraSupported" class="text-muted">
|
<CameraScanner @scan="onCameraScan" @error="e => error = e"/>
|
||||||
This browser has no camera API available.
|
|
||||||
</p>
|
|
||||||
<template v-else>
|
|
||||||
<div class="row g-2 align-items-end mb-3">
|
|
||||||
<div class="col-auto flex-grow-1">
|
|
||||||
<label class="form-label">Camera</label>
|
|
||||||
<select class="form-control" v-model="selectedCameraId">
|
|
||||||
<option value="">Auto (prefer recent, then rear camera)</option>
|
|
||||||
<option v-for="(c, i) in cameras" :key="c.deviceId"
|
|
||||||
:value="c.deviceId">
|
|
||||||
{{ cleanCameraLabel(c.label) || ('Camera ' + (i + 1)) }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div v-if="cameraRunning" class="col-auto">
|
|
||||||
<button class="btn btn-outline-secondary" @click="stopCamera">
|
|
||||||
<b-icon-stop-fill class="me-1"></b-icon-stop-fill>
|
|
||||||
Stop
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p v-if="cameraRes" class="text-muted small">{{ cameraRes }}</p>
|
|
||||||
<div class="video-wrap">
|
|
||||||
<video ref="video" autoplay muted playsinline></video>
|
|
||||||
<canvas ref="overlay"></canvas>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<input ref="fileInput" type="file" accept="image/*" class="form-control mb-3"
|
|
||||||
@change="onFileInputChange">
|
|
||||||
<div class="dropzone" :class="{'dropzone-hover': dropHover}"
|
|
||||||
@dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
|
|
||||||
Drag & drop an image here, paste one, or use the file picker above.
|
|
||||||
</div>
|
|
||||||
<div class="scan-canvas-wrap" v-show="hasFileImage">
|
|
||||||
<canvas ref="fileCanvas" class="scan-canvas"></canvas>
|
|
||||||
</div>
|
|
||||||
<ul class="scan-results">
|
|
||||||
<li v-if="hasFileImage && !fileResults.length" class="scan-results-empty">
|
|
||||||
No codes found.
|
|
||||||
</li>
|
|
||||||
<li v-for="(r, i) in fileResults" :key="i">
|
|
||||||
<span class="scan-result-type">{{ r.type }}</span>
|
|
||||||
<span v-if="r.metaText" class="text-muted"> {{ r.metaText }}</span>
|
|
||||||
<div class="text-break">
|
|
||||||
<a v-if="r.link?.href" :href="r.link.href" target="_blank"
|
|
||||||
rel="noopener noreferrer">{{ r.text }}</a>
|
|
||||||
<template v-else-if="r.link">
|
|
||||||
<router-link :to="r.link.to">{{ r.text }}</router-link>
|
|
||||||
<span v-if="r.error" class="text-danger"> → {{ r.error }}</span>
|
|
||||||
<span v-else-if="r.description" class="text-muted"> → {{ r.description }}</span>
|
|
||||||
<span v-else-if="r.description === undefined" class="text-muted"> → resolving…</span>
|
|
||||||
</template>
|
|
||||||
<template v-else>{{ r.text }}</template>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -121,10 +68,8 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import {mapActions} from "vuex";
|
import {mapActions} from "vuex";
|
||||||
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 CameraScanner from "@/components/CameraScanner.vue";
|
||||||
import cameraManager from "@/cameraManager.js";
|
|
||||||
import {encodeHandleForUrl, expandedRoute} from "@/router";
|
import {encodeHandleForUrl, expandedRoute} from "@/router";
|
||||||
import {decodeShortId, deserializeShortId} from "@/short-id";
|
import {decodeShortId, deserializeShortId} from "@/short-id";
|
||||||
|
|
||||||
|
|
@ -174,7 +119,7 @@ function classifyScanText(text) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Formats a decode result's metadata (see anyd-qr.js's SymbolMetadata) as a short string like
|
// Formats a decode result's metadata (see anyd-qr.js's SymbolMetadata) as a short string like
|
||||||
// "(v7, ec=M, mask=3)"; shared by the "from image" and "from camera" result lists below.
|
// "(v7, ec=M, mask=3)".
|
||||||
function metaSummary(metadata) {
|
function metaSummary(metadata) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (metadata.version != null) {
|
if (metadata.version != null) {
|
||||||
|
|
@ -192,15 +137,11 @@ function metaSummary(metadata) {
|
||||||
return parts.length ? `(${parts.join(", ")})` : "";
|
return parts.length ? `(${parts.join(", ")})` : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// How long a detected code's overlay box stays drawn before fading, so it doesn't linger once
|
|
||||||
// the code has moved out of frame.
|
|
||||||
const OVERLAY_CLEAR_MS = 2000;
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "Scan",
|
name: "Scan",
|
||||||
components: {
|
components: {
|
||||||
BaseLayout,
|
BaseLayout,
|
||||||
...BIcons
|
CameraScanner,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
|
@ -210,35 +151,13 @@ export default {
|
||||||
|
|
||||||
// One-shot: cleared by maybeVisitFirstMatch as soon as it navigates, so it doesn't
|
// One-shot: cleared by maybeVisitFirstMatch as soon as it navigates, so it doesn't
|
||||||
// keep firing router.push for every later scan/decode while left switched on.
|
// keep firing router.push for every later scan/decode while left switched on.
|
||||||
visitFirstMatch: false,
|
// Defaults on when the page itself was opened with ?navigate=immediate (e.g. a link
|
||||||
|
// shared for a "scan and go" workflow), so the toggle doesn't need a manual flip first.
|
||||||
|
visitFirstMatch: this.$route.query.navigate === "immediate",
|
||||||
|
|
||||||
dropHover: false,
|
|
||||||
hasFileImage: false,
|
|
||||||
fileResults: [],
|
|
||||||
|
|
||||||
cameraSupported: Boolean(navigator.mediaDevices?.getUserMedia),
|
|
||||||
cameras: [],
|
|
||||||
localSelectedCameraId: "",
|
|
||||||
cameraRunning: false,
|
|
||||||
cameraRes: "",
|
|
||||||
cameraLog: [],
|
cameraLog: [],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
|
||||||
// Switches camera immediately on selection change (no explicit "apply" step), matching
|
|
||||||
// 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: {
|
||||||
...mapActions(["fetchItemByHandle", "fetchGroup", "fetchStorageLocations", "fetchIdMap"]),
|
...mapActions(["fetchItemByHandle", "fetchGroup", "fetchStorageLocations", "fetchIdMap"]),
|
||||||
|
|
||||||
|
|
@ -249,13 +168,13 @@ export default {
|
||||||
// flight, and settles to a string or `null` (nothing else known to show, e.g. a workflow
|
// flight, and settles to a string or `null` (nothing else known to show, e.g. a workflow
|
||||||
// short id, or the owner/item genuinely couldn't be resolved).
|
// short id, or the owner/item genuinely couldn't be resolved).
|
||||||
//
|
//
|
||||||
// descriptionCache (keyed by the raw scanned text, shared across cameraLog and
|
// descriptionCache (keyed by the raw scanned text) memoizes the outcome - a still-in-frame
|
||||||
// fileResults) memoizes the outcome - a still-in-frame code gets re-decoded and
|
// code gets re-decoded and re-logged several times a second (see logDecode), and
|
||||||
// re-logged several times a second (see logDecode), and re-scanning the same printed
|
// re-scanning the same printed label later is common too, so without this every repeat
|
||||||
// label later is common too, so without this every repeat would re-fire the same
|
// would re-fire the same fetchItemByHandle/fetchGroup/fetchStorageLocations/fetchIdMap
|
||||||
// fetchItemByHandle/fetchGroup/fetchStorageLocations/fetchIdMap round trip. Caching the
|
// round trip. Caching the in-flight promise itself (not just its settled value) also
|
||||||
// in-flight promise itself (not just its settled value) also dedupes concurrent lookups
|
// dedupes concurrent lookups for the same still-in-frame code, rather than firing one
|
||||||
// for the same still-in-frame code, rather than firing one request per decode.
|
// request per decode.
|
||||||
resolveDescription(entry) {
|
resolveDescription(entry) {
|
||||||
const {link, text} = entry;
|
const {link, text} = entry;
|
||||||
if (!link || link.href) {
|
if (!link || link.href) {
|
||||||
|
|
@ -341,106 +260,9 @@ export default {
|
||||||
return item ? `[#${item.id}] ${item.name}` : null;
|
return item ? `[#${item.id}] ${item.name}` : null;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Draws `file`/a pasted or dropped Blob onto fileCanvas and decodes it; shared by the file
|
// Handler for CameraScanner's "scan" event - logs every code from the batch it decoded.
|
||||||
// input, drag&drop, and paste handlers below to avoid duplicating this sequence.
|
onCameraScan(codes) {
|
||||||
async decodeBlob(file) {
|
codes.forEach(this.logDecode);
|
||||||
this.error = null;
|
|
||||||
try {
|
|
||||||
const bitmap = await createImageBitmap(file);
|
|
||||||
const canvas = this.$refs.fileCanvas;
|
|
||||||
canvas.width = bitmap.width;
|
|
||||||
canvas.height = bitmap.height;
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
ctx.drawImage(bitmap, 0, 0);
|
|
||||||
const imageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height);
|
|
||||||
const anyd = await this.anydPromise;
|
|
||||||
const results = anyd.decodeImage(imageData);
|
|
||||||
this.drawBoxes(ctx, results);
|
|
||||||
this.hasFileImage = true;
|
|
||||||
this.fileResults = results.map(r => ({
|
|
||||||
...r, metaText: metaSummary(r.metadata), link: classifyScanText(r.text),
|
|
||||||
description: undefined, error: null
|
|
||||||
}));
|
|
||||||
this.fileResults.forEach(this.resolveDescription);
|
|
||||||
} catch (e) {
|
|
||||||
this.error = e.message ?? String(e);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
drawBoxes(ctx, results) {
|
|
||||||
ctx.lineWidth = Math.max(2, ctx.canvas.width / 300);
|
|
||||||
ctx.strokeStyle = "#3ce77c";
|
|
||||||
ctx.fillStyle = "#3ce77c";
|
|
||||||
ctx.font = `${Math.max(14, ctx.canvas.width / 60)}px sans-serif`;
|
|
||||||
for (const r of results) {
|
|
||||||
if (!r.box) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const {x0, y0, x1, y1} = r.box;
|
|
||||||
ctx.strokeRect(x0, y0, x1 - x0, y1 - y0);
|
|
||||||
ctx.fillText(r.type, x0, Math.max(14, y0 - 4));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
onFileInputChange() {
|
|
||||||
const file = this.$refs.fileInput.files[0];
|
|
||||||
if (file) {
|
|
||||||
this.decodeBlob(file);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
onDragOver(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
this.dropHover = true;
|
|
||||||
},
|
|
||||||
|
|
||||||
onDragLeave() {
|
|
||||||
this.dropHover = false;
|
|
||||||
},
|
|
||||||
|
|
||||||
onDrop(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
this.dropHover = false;
|
|
||||||
const file = e.dataTransfer.files[0];
|
|
||||||
if (file) {
|
|
||||||
this.decodeBlob(file);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
onPaste(e) {
|
|
||||||
const item = [...(e.clipboardData?.items ?? [])].find(i => i.type.startsWith("image/"));
|
|
||||||
if (item) {
|
|
||||||
this.decodeBlob(item.getAsFile());
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Device labels only populate once permission is granted, so this is called again right
|
|
||||||
// after getUserMedia resolves in startCamera, not just once up front.
|
|
||||||
async populateCameraList() {
|
|
||||||
if (!navigator.mediaDevices?.enumerateDevices) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await cameraManager.enumerateDevices();
|
|
||||||
this.cameras = cameraManager.getAvailableCameras();
|
|
||||||
},
|
|
||||||
|
|
||||||
cleanCameraLabel(label) {
|
|
||||||
if (!label) return "";
|
|
||||||
const parts = label.split(":").map((p) => p.trim());
|
|
||||||
if (parts.length === 2 && parts[0] === parts[1]) {
|
|
||||||
return parts[0];
|
|
||||||
}
|
|
||||||
return label;
|
|
||||||
},
|
|
||||||
|
|
||||||
syncOverlaySize() {
|
|
||||||
const video = this.$refs.video;
|
|
||||||
const overlay = this.$refs.overlay;
|
|
||||||
if (!video || !overlay) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
overlay.width = video.clientWidth;
|
|
||||||
overlay.height = video.clientHeight;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
logDecode(code) {
|
logDecode(code) {
|
||||||
|
|
@ -465,208 +287,16 @@ export default {
|
||||||
// CameraScanner's own dedupe window that makes them stale for re-matching.
|
// CameraScanner's own dedupe window that makes them stale for re-matching.
|
||||||
this.cameraLog.length = Math.min(this.cameraLog.length, 20);
|
this.cameraLog.length = Math.min(this.cameraLog.length, 20);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Draws each detected code's box (video's native pixel space, scaled to the overlay's
|
|
||||||
// displayed size) and fades it out after OVERLAY_CLEAR_MS.
|
|
||||||
drawOverlay(codes) {
|
|
||||||
console.log("drawOverlay", codes);
|
|
||||||
const overlay = this.$refs.overlay;
|
|
||||||
const video = this.$refs.video;
|
|
||||||
if (!overlay || !video || !video.videoWidth) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const ctx = overlay.getContext("2d");
|
|
||||||
ctx.clearRect(0, 0, overlay.width, overlay.height);
|
|
||||||
const scaleX = overlay.width / video.videoWidth;
|
|
||||||
const scaleY = overlay.height / video.videoHeight;
|
|
||||||
ctx.lineWidth = 3;
|
|
||||||
ctx.strokeStyle = "#3ce77c";
|
|
||||||
ctx.fillStyle = "#3ce77c";
|
|
||||||
ctx.font = "14px sans-serif";
|
|
||||||
for (const code of codes) {
|
|
||||||
if (!code.box) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const {x0, y0, x1, y1} = code.box;
|
|
||||||
ctx.strokeRect(x0 * scaleX, y0 * scaleY, (x1 - x0) * scaleX, (y1 - y0) * scaleY);
|
|
||||||
ctx.fillText(`${code.type}: ${code.text ?? ""}`, x0 * scaleX, Math.max(14, y0 * scaleY - 4));
|
|
||||||
}
|
|
||||||
clearTimeout(this.clearOverlayTimer);
|
|
||||||
this.clearOverlayTimer = setTimeout(
|
|
||||||
() => ctx.clearRect(0, 0, overlay.width, overlay.height), OVERLAY_CLEAR_MS);
|
|
||||||
},
|
|
||||||
|
|
||||||
// Attaches `stream` to the video element so the scanner never needs recreating. See
|
|
||||||
// docs/implementation.md#video-stream-attach-and-resize-sync.
|
|
||||||
setupVideoStream(stream) {
|
|
||||||
const video = this.$refs.video;
|
|
||||||
if (!video) return;
|
|
||||||
video.srcObject = stream;
|
|
||||||
video.play();
|
|
||||||
},
|
|
||||||
|
|
||||||
// Resizes the overlay to match the video's new size once its intrinsic dimensions change.
|
|
||||||
// See docs/implementation.md#video-stream-attach-and-resize-sync.
|
|
||||||
onVideoResize() {
|
|
||||||
const video = this.$refs.video;
|
|
||||||
if (!video || !video.videoWidth) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.syncOverlaySize();
|
|
||||||
this.cameraRes = `Native resolution: ${video.videoWidth} x ${video.videoHeight}`;
|
|
||||||
},
|
|
||||||
|
|
||||||
async startCamera() {
|
|
||||||
this.error = null;
|
|
||||||
try {
|
|
||||||
const stream = await cameraManager.openStream(this.localSelectedCameraId || null);
|
|
||||||
this.setupVideoStream(stream);
|
|
||||||
await this.populateCameraList();
|
|
||||||
this.localSelectedCameraId = cameraManager.getSelectedCameraId() || "";
|
|
||||||
|
|
||||||
const anyd = await this.anydPromise;
|
|
||||||
this.scanner = anyd.createCameraScanner(this.$refs.video, {
|
|
||||||
fps: 4,
|
|
||||||
downscale: 2,
|
|
||||||
onDecode: (codes) => {
|
|
||||||
codes.forEach(this.logDecode);
|
|
||||||
this.drawOverlay(codes);
|
|
||||||
},
|
|
||||||
onError: (err) => console.error("[camera scan]", err),
|
|
||||||
});
|
|
||||||
this.scanner.start();
|
|
||||||
this.cameraRunning = true;
|
|
||||||
} catch (e) {
|
|
||||||
this.error = `Camera error: ${e.message ?? e}`;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async switchCamera(cameraId) {
|
|
||||||
if (!this.cameraRunning) return;
|
|
||||||
try {
|
|
||||||
const stream = await cameraManager.switchCamera(cameraId);
|
|
||||||
if (stream) this.setupVideoStream(stream);
|
|
||||||
} catch (e) {
|
|
||||||
this.error = `Camera error: ${e.message ?? e}`;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
stopCamera() {
|
|
||||||
this.scanner?.stop();
|
|
||||||
this.scanner = null;
|
|
||||||
cameraManager.closeStream();
|
|
||||||
if (this.$refs.video) {
|
|
||||||
this.$refs.video.srcObject = null;
|
|
||||||
}
|
|
||||||
this.cameraRunning = false;
|
|
||||||
const overlay = this.$refs.overlay;
|
|
||||||
if (overlay) {
|
|
||||||
overlay.getContext("2d").clearRect(0, 0, overlay.width, overlay.height);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// cameraManager already tries a fallback device and only fires this event once none
|
|
||||||
// remain, so the running scan session has to stop here.
|
|
||||||
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 (not awaited) so decodeBlob and startCamera share one in-flight load;
|
|
||||||
// loadAnyDCode also memoizes, so later calls elsewhere in the app are free too.
|
|
||||||
this.anydPromise = loadAnyDCode();
|
|
||||||
this.scanner = null;
|
|
||||||
this.clearOverlayTimer = null;
|
|
||||||
// Not component data: resolveDescription's cached values are read back into each entry's
|
// Not component data: resolveDescription's cached values are read back into each entry's
|
||||||
// own (reactive) `description` field, so the cache itself never needs to be reactive.
|
// own (reactive) `description` field, so the cache itself never needs to be reactive.
|
||||||
this.descriptionCache = new Map();
|
this.descriptionCache = new Map();
|
||||||
},
|
},
|
||||||
mounted() {
|
|
||||||
window.addEventListener("paste", this.onPaste);
|
|
||||||
window.addEventListener("resize", this.syncOverlaySize);
|
|
||||||
window.addEventListener("camera-disconnected", this.handleCameraDisconnected);
|
|
||||||
window.addEventListener("camera-reconnected", this.handleCameraReconnected);
|
|
||||||
navigator.mediaDevices?.addEventListener("devicechange", this.populateCameraList);
|
|
||||||
this.$refs.video?.addEventListener("loadedmetadata", this.onVideoResize);
|
|
||||||
this.$refs.video?.addEventListener("resize", this.onVideoResize);
|
|
||||||
if (this.cameraSupported && this.insecureContext) {
|
|
||||||
this.startCamera();
|
|
||||||
} else {
|
|
||||||
this.populateCameraList();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
beforeUnmount() {
|
|
||||||
this.stopCamera();
|
|
||||||
window.removeEventListener("paste", this.onPaste);
|
|
||||||
window.removeEventListener("resize", this.syncOverlaySize);
|
|
||||||
window.removeEventListener("camera-disconnected", this.handleCameraDisconnected);
|
|
||||||
window.removeEventListener("camera-reconnected", this.handleCameraReconnected);
|
|
||||||
navigator.mediaDevices?.removeEventListener("devicechange", this.populateCameraList);
|
|
||||||
this.$refs.video?.removeEventListener("loadedmetadata", this.onVideoResize);
|
|
||||||
this.$refs.video?.removeEventListener("resize", this.onVideoResize);
|
|
||||||
clearTimeout(this.clearOverlayTimer);
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.dropzone {
|
|
||||||
margin-bottom: .75rem;
|
|
||||||
border: 2px dashed var(--bs-gray-400);
|
|
||||||
border-radius: .35rem;
|
|
||||||
padding: 1.25rem;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--bs-gray-600);
|
|
||||||
font-size: .9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropzone-hover {
|
|
||||||
border-color: var(--bs-primary);
|
|
||||||
color: var(--bs-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scan-canvas-wrap {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: .75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scan-canvas {
|
|
||||||
max-width: 100%;
|
|
||||||
border-radius: .35rem;
|
|
||||||
border: 1px solid var(--bs-gray-300);
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-wrap {
|
|
||||||
position: relative;
|
|
||||||
margin-bottom: .75rem;
|
|
||||||
background: #000;
|
|
||||||
border-radius: .35rem;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-wrap video {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-wrap canvas {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scan-results {
|
.scan-results {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
@ -688,9 +318,4 @@ export default {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--bs-success);
|
color: var(--bs-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scan-results-empty {
|
|
||||||
color: var(--bs-gray-600);
|
|
||||||
border-style: dashed;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue