stash
This commit is contained in:
parent
aa94c92000
commit
f87b689e27
3 changed files with 314 additions and 399 deletions
|
|
@ -9,6 +9,9 @@
|
|||
<SearchBox/>
|
||||
<div class="navbar-collapse collapse">
|
||||
<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"/>
|
||||
<Messages :messages="messages"/>
|
||||
<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"
|
||||
rel="noopener noreferrer">{{ c.text }}</a>
|
||||
<template v-else-if="c.link">
|
||||
<router-link :to="c.link.to">{{ c.text }}</router-link>
|
||||
<span v-if="c.error" class="text-danger"> → {{ c.error }}</span>
|
||||
<span v-else-if="c.description" class="text-muted"> → {{ c.description }}</span>
|
||||
<router-link :to="c.link.to">{{ c.text }}
|
||||
<span v-if="c.error" class="text-danger"> → {{
|
||||
c.error
|
||||
}}</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 v-else>{{ c.text }}</template>
|
||||
</div>
|
||||
|
|
@ -53,63 +56,7 @@
|
|||
<h5 class="card-title mb-0">Scan from camera</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p v-if="!cameraSupported" class="text-muted">
|
||||
This browser has no camera API available.
|
||||
</p>
|
||||
<template v-else>
|
||||
<div class="row g-2 align-items-end mb-3">
|
||||
<div class="col-auto flex-grow-1">
|
||||
<label class="form-label">Camera</label>
|
||||
<select class="form-control" v-model="selectedCameraId">
|
||||
<option value="">Auto (prefer recent, then rear camera)</option>
|
||||
<option v-for="(c, i) in cameras" :key="c.deviceId"
|
||||
:value="c.deviceId">
|
||||
{{ cleanCameraLabel(c.label) || ('Camera ' + (i + 1)) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="cameraRunning" class="col-auto">
|
||||
<button class="btn btn-outline-secondary" @click="stopCamera">
|
||||
<b-icon-stop-fill class="me-1"></b-icon-stop-fill>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="cameraRes" class="text-muted small">{{ cameraRes }}</p>
|
||||
<div class="video-wrap">
|
||||
<video ref="video" autoplay muted playsinline></video>
|
||||
<canvas ref="overlay"></canvas>
|
||||
</div>
|
||||
</template>
|
||||
<input ref="fileInput" type="file" accept="image/*" class="form-control mb-3"
|
||||
@change="onFileInputChange">
|
||||
<div class="dropzone" :class="{'dropzone-hover': dropHover}"
|
||||
@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>
|
||||
<CameraScanner @scan="onCameraScan" @error="e => error = e"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -121,10 +68,8 @@
|
|||
|
||||
<script>
|
||||
import {mapActions} from "vuex";
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import BaseLayout from "@/components/BaseLayout.vue";
|
||||
import {loadAnyDCode} from "../../vendor/anyd-qr.js";
|
||||
import cameraManager from "@/cameraManager.js";
|
||||
import CameraScanner from "@/components/CameraScanner.vue";
|
||||
import {encodeHandleForUrl, expandedRoute} from "@/router";
|
||||
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
|
||||
// "(v7, ec=M, mask=3)"; shared by the "from image" and "from camera" result lists below.
|
||||
// "(v7, ec=M, mask=3)".
|
||||
function metaSummary(metadata) {
|
||||
const parts = [];
|
||||
if (metadata.version != null) {
|
||||
|
|
@ -192,15 +137,11 @@ function metaSummary(metadata) {
|
|||
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 {
|
||||
name: "Scan",
|
||||
components: {
|
||||
BaseLayout,
|
||||
...BIcons
|
||||
CameraScanner,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
@ -210,35 +151,13 @@ export default {
|
|||
|
||||
// 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.
|
||||
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: [],
|
||||
};
|
||||
},
|
||||
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: {
|
||||
...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
|
||||
// short id, or the owner/item genuinely couldn't be resolved).
|
||||
//
|
||||
// descriptionCache (keyed by the raw scanned text, shared across cameraLog and
|
||||
// fileResults) memoizes the outcome - a still-in-frame code gets re-decoded and
|
||||
// re-logged several times a second (see logDecode), and re-scanning the same printed
|
||||
// label later is common too, so without this every repeat would re-fire the same
|
||||
// fetchItemByHandle/fetchGroup/fetchStorageLocations/fetchIdMap round trip. Caching the
|
||||
// in-flight promise itself (not just its settled value) also dedupes concurrent lookups
|
||||
// for the same still-in-frame code, rather than firing one request per decode.
|
||||
// descriptionCache (keyed by the raw scanned text) memoizes the outcome - a still-in-frame
|
||||
// code gets re-decoded and re-logged several times a second (see logDecode), and
|
||||
// re-scanning the same printed label later is common too, so without this every repeat
|
||||
// would re-fire the same fetchItemByHandle/fetchGroup/fetchStorageLocations/fetchIdMap
|
||||
// round trip. Caching the in-flight promise itself (not just its settled value) also
|
||||
// dedupes concurrent lookups for the same still-in-frame code, rather than firing one
|
||||
// request per decode.
|
||||
resolveDescription(entry) {
|
||||
const {link, text} = entry;
|
||||
if (!link || link.href) {
|
||||
|
|
@ -341,106 +260,9 @@ export default {
|
|||
return item ? `[#${item.id}] ${item.name}` : null;
|
||||
},
|
||||
|
||||
// Draws `file`/a pasted or dropped Blob onto fileCanvas and decodes it; shared by the file
|
||||
// input, drag&drop, and paste handlers below to avoid duplicating this sequence.
|
||||
async decodeBlob(file) {
|
||||
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;
|
||||
// Handler for CameraScanner's "scan" event - logs every code from the batch it decoded.
|
||||
onCameraScan(codes) {
|
||||
codes.forEach(this.logDecode);
|
||||
},
|
||||
|
||||
logDecode(code) {
|
||||
|
|
@ -465,208 +287,16 @@ export default {
|
|||
// CameraScanner's own dedupe window that makes them stale for re-matching.
|
||||
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() {
|
||||
// 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
|
||||
// own (reactive) `description` field, so the cache itself never needs to be reactive.
|
||||
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>
|
||||
|
||||
<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 {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
|
|
@ -688,9 +318,4 @@ export default {
|
|||
font-weight: 600;
|
||||
color: var(--bs-success);
|
||||
}
|
||||
|
||||
.scan-results-empty {
|
||||
color: var(--bs-gray-600);
|
||||
border-style: dashed;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue