This commit is contained in:
j3d1 2026-08-21 21:37:12 +02:00
parent 7d9f67a77a
commit 3299c97392
13 changed files with 1407 additions and 327 deletions

435
frontend/src/views/Scan.vue Normal file
View file

@ -0,0 +1,435 @@
<template>
<BaseLayout>
<main class="content">
<div class="container-fluid p-0">
<h1 class="h3 mb-3">Scan a code</h1>
<div v-if="error" class="alert alert-danger" role="alert">{{ error }}</div>
<div v-if="!insecureContext" class="alert alert-warning">
This page isn't in a secure context ({{ insecureOrigin }}), so the camera API is
unavailable. From another device, use https:// (or http://localhost) instead.
</div>
<div class="row">
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header">
<h5 class="card-title mb-0">Scan from image</h5>
</div>
<div class="card-body">
<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 &amp; 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">{{ r.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"
:disabled="cameraRunning">
<option value="">Auto (prefer rear camera)</option>
<option v-for="(c, i) in cameras" :key="c.deviceId"
:value="c.deviceId">
{{ c.label || ('Camera ' + (i + 1)) }}
</option>
</select>
</div>
<div class="col-auto">
<button v-if="!cameraRunning" class="btn btn-primary"
:disabled="!insecureContext" @click="startCamera">
<b-icon-camera class="me-1"></b-icon-camera>
Start camera
</button>
<button v-else class="btn btn-outline-secondary" @click="stopCamera">
<b-icon-stop-fill class="me-1"></b-icon-stop-fill>
Stop
</button>
</div>
</div>
<p v-if="cameraRes" class="text-muted small">{{ cameraRes }}</p>
<div class="video-wrap">
<video ref="video" autoplay muted playsinline></video>
<canvas ref="overlay"></canvas>
</div>
</template>
<ul class="scan-results">
<li v-for="(c, i) in cameraLog" :key="i">
<span class="scan-result-type">{{ c.type }}</span>
<span v-if="c.metaText" class="text-muted"> {{ c.metaText }}</span>
<span class="text-muted"> @ {{ c.time }}</span>
<div class="text-break">{{ c.text }}</div>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</main>
</BaseLayout>
</template>
<script>
import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue";
import {loadAnyDCode} from "../../vendor/anyd-qr.js";
// A decode result's metadata (see anyd-qr.js's SymbolMetadata) as one short, human-readable
// string, e.g. "(v7, ec=M, mask=3)" - shared by both the "from image" and "from camera" result
// lists below rather than each formatting it its own way.
function metaSummary(metadata) {
const parts = [];
if (metadata.version != null) {
parts.push(`v${metadata.version}`);
}
if (metadata.size) {
parts.push(metadata.size);
}
if (metadata.ecc) {
parts.push(`ec=${metadata.ecc}`);
}
if (metadata.mask != null) {
parts.push(`mask=${metadata.mask}`);
}
return parts.length ? `(${parts.join(", ")})` : "";
}
// How long a camera-detected code's bounding box stays drawn on the overlay before fading, so a
// one-off decode doesn't leave a stale box on screen once the code's moved out of frame.
const OVERLAY_CLEAR_MS = 2000;
export default {
name: "Scan",
components: {
BaseLayout,
...BIcons
},
data() {
return {
error: null,
insecureContext: window.isSecureContext,
insecureOrigin: `${window.location.protocol}//${window.location.hostname}`,
dropHover: false,
hasFileImage: false,
fileResults: [],
cameraSupported: Boolean(navigator.mediaDevices?.getUserMedia),
cameras: [],
selectedCameraId: "",
cameraRunning: false,
cameraRes: "",
cameraLog: [],
};
},
methods: {
// Draws `file`/a pasted or dropped Blob onto fileCanvas and decodes whatever's in it -
// shared by the file input, drag&drop and paste handlers below rather than each
// duplicating the createImageBitmap/getImageData/decodeImage 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)}));
} 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 are only populated once camera permission has actually been granted, so
// this is called again right after getUserMedia resolves in startCamera, not just once
// up front.
async populateCameraList() {
if (!navigator.mediaDevices?.enumerateDevices) {
return;
}
const devices = await navigator.mediaDevices.enumerateDevices();
this.cameras = devices.filter(d => d.kind === "videoinput");
},
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) {
this.cameraLog.unshift({
type: code.type,
text: code.text ?? "",
metaText: metaSummary(code.metadata),
time: new Date().toLocaleTimeString(),
});
// Caps the log rather than letting it grow forever - old entries are trimmed off the
// end, same as CameraScanner's own dedupe window makes them stale for re-matching.
this.cameraLog.length = Math.min(this.cameraLog.length, 20);
},
// Draws every detected code's box (in the video's own native pixel space, scaled to
// however large the overlay is actually displayed) and fades them out after
// OVERLAY_CLEAR_MS - a one-off decode shouldn't leave a stale box on screen once the code
// has moved out of frame.
drawOverlay(codes) {
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);
},
async startCamera() {
this.error = null;
try {
const constraints = {
video: this.selectedCameraId
? {deviceId: {exact: this.selectedCameraId}}
: {facingMode: {ideal: "environment"}},
audio: false,
};
this.stream = await navigator.mediaDevices.getUserMedia(constraints);
const video = this.$refs.video;
video.srcObject = this.stream;
await video.play();
this.syncOverlaySize();
this.cameraRes = `Native resolution: ${video.videoWidth} x ${video.videoHeight}`;
await this.populateCameraList();
const anyd = await this.anydPromise;
this.scanner = anyd.createCameraScanner(video, {
fps: 12,
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}`;
}
},
stopCamera() {
this.scanner?.stop();
this.scanner = null;
this.stream?.getTracks().forEach(t => t.stop());
this.stream = null;
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);
}
},
},
created() {
// Kept as a promise (rather than awaited here) so every caller - decodeBlob, startCamera -
// shares the same in-flight load instead of each triggering its own; loadAnyDCode's own
// memoization (see anyd-qr.js) means a second call anywhere else in the app is free too.
this.anydPromise = loadAnyDCode();
this.stream = null;
this.scanner = null;
this.clearOverlayTimer = null;
},
mounted() {
window.addEventListener("paste", this.onPaste);
window.addEventListener("resize", this.syncOverlaySize);
this.populateCameraList();
},
beforeUnmount() {
this.stopCamera();
window.removeEventListener("paste", this.onPaste);
window.removeEventListener("resize", this.syncOverlaySize);
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;
margin: 0;
display: flex;
flex-direction: column;
gap: .4rem;
max-height: 260px;
overflow-y: auto;
}
.scan-results li {
border: 1px solid var(--bs-gray-300);
border-radius: .35rem;
padding: .4rem .6rem;
font-size: .85rem;
}
.scan-result-type {
font-weight: 600;
color: var(--bs-success);
}
.scan-results-empty {
color: var(--bs-gray-600);
border-style: dashed;
}
</style>