This commit is contained in:
j3d1 2026-08-24 15:57:17 +02:00
parent 8d96bc97c4
commit ed04d98bf1
54 changed files with 661 additions and 1214 deletions

View file

@ -4,6 +4,12 @@
<div class="container-fluid p-0">
<h1 class="h3 mb-3">Scan a code</h1>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" role="switch" id="visitFirstMatch"
v-model="visitFirstMatch">
<label class="form-check-label" for="visitFirstMatch">Visit first match</label>
</div>
<div v-if="error" class="alert alert-danger" role="alert">{{ error }}</div>
<div v-if="!insecureContext" class="alert alert-warning">
@ -24,7 +30,17 @@
<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>
<div class="text-break">
<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"> &rarr; {{ c.error }}</span>
<span v-else-if="c.description" class="text-muted"> &rarr; {{ c.description }}</span>
<span v-else-if="c.description === undefined" class="text-muted"> &rarr; resolving&hellip;</span>
</template>
<template v-else>{{ c.text }}</template>
</div>
</li>
</ul>
</div>
@ -81,7 +97,17 @@
<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>
<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"> &rarr; {{ r.error }}</span>
<span v-else-if="r.description" class="text-muted"> &rarr; {{ r.description }}</span>
<span v-else-if="r.description === undefined" class="text-muted"> &rarr; resolving&hellip;</span>
</template>
<template v-else>{{ r.text }}</template>
</div>
</li>
</ul>
</div>
@ -94,14 +120,61 @@
</template>
<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 {encodeHandleForUrl, expandedRoute} from "@/router";
import {decodeShortId, deserializeShortId} from "@/short-id";
// 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.
// A scanned label can encode any of these (see label-layouts.js's DERIVED_VARS): a full
// self-contained URL (itemUrl/shortUrl), a bare short-id.js token with no domain at all (the
// "mqr"/id-only layout), or the compact no-URL "<userHandle>:<itemId>" form (itemHandle). Detects
// which and returns a link target, or null if the text doesn't match any known format. `decoded`/
// `itemHandle` carry enough of the parsed token for describeLink (below) to also resolve a
// human-readable "what this points at" - not needed for the two URL cases, which link to
// somewhere already showing that.
const ITEM_HANDLE_RE = /^(#?[^\s@:/#+~]+@[^\s@:/#+~]+):(\d+)$/;
function classifyScanText(text) {
if (!text) {
return null;
}
try {
const url = new URL(text);
// Same-origin URLs (the common case - a label printed by this same app) get routed
// in-app instead of forcing a full page reload through an <a> tag. `immediate`: a URL
// needs no async lookup to confirm it's real (unlike the token/handle formats below), so
// it's already "resolved" the moment it's classified - see resolveDescription and
// maybeVisitFirstMatch.
return url.origin === window.location.origin
? {to: url.pathname + url.search + url.hash, immediate: true}
: {href: url.href};
} catch {
// Not an absolute URL - fall through to the other known formats below.
}
if (text.startsWith('~')) {
try {
const decoded = deserializeShortId(decodeShortId(text));
// expandedRoute needs idmap already loaded to resolve item/group_item (see
// router.js's NEEDS_IDMAP) - fall back to the token's own URL (ShortId.vue resolves
// it from there, same as a cold-opened short link) when it can't yet.
return {to: expandedRoute(decoded) || '/' + text, decoded};
} catch {
return null; // starts with '~' but isn't a real short id - leave as plain text
}
}
const handleMatch = text.match(ITEM_HANDLE_RE);
if (handleMatch) {
const [, handle, id] = handleMatch;
return {to: `/inventory/${encodeHandleForUrl(handle)}/${id}`, itemHandle: {handle, id}};
}
return null;
}
// 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.
function metaSummary(metadata) {
const parts = [];
if (metadata.version != null) {
@ -119,8 +192,8 @@ function metaSummary(metadata) {
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.
// 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 {
@ -135,6 +208,10 @@ export default {
insecureContext: window.isSecureContext,
insecureOrigin: `${window.location.protocol}//${window.location.hostname}`,
// 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,
dropHover: false,
hasFileImage: false,
fileResults: [],
@ -148,8 +225,8 @@ export default {
};
},
computed: {
// Switches camera the moment the select changes, rather than waiting for an explicit
// "apply" step - matches prototypes/camera-inputs/InputPhoto.vue's selectedCameraId.
// Switches camera immediately on selection change (no explicit "apply" step), matching
// prototypes/camera-inputs/InputPhoto.vue's selectedCameraId.
selectedCameraId: {
get() {
return this.localSelectedCameraId;
@ -163,9 +240,109 @@ export default {
},
},
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.
...mapActions(["fetchItemByHandle", "fetchGroup", "fetchStorageLocations", "fetchIdMap"]),
// Resolves entry.link into entry.description ("[#7] Cordless drill") for the non-URL
// formats classifyScanText recognizes - mutates the already-rendered entry in place once
// the lookup lands, rather than delaying the log/result list from showing the raw scanned
// text and link immediately. Left as `undefined` (template shows "resolving...") while in
// 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.
resolveDescription(entry) {
const {link, text} = entry;
if (!link || link.href) {
return; // full URLs already show where they go - see this feature's ask
}
if (link.immediate) {
this.maybeVisitFirstMatch(link);
return;
}
if (!this.descriptionCache.has(text)) {
// Wrapped as {error} rather than swallowed to null: a lookup can fail for very
// different reasons (not logged in, item not shared with this viewer, a genuine
// network error) and collapsing them all to "no description" made every one of
// them look identical to "nothing to show" - undiagnosable from the UI.
this.descriptionCache.set(text, this.describeLink(link).catch(e => ({error: e.message ?? String(e)})));
}
this.descriptionCache.get(text).then(result => {
if (result && typeof result === "object") {
entry.description = null;
entry.error = result.error;
return;
}
entry.description = result;
// Only a truthy description confirms the target actually exists - a null/empty
// one (unresolvable, or a kind with no title lookup wired up) shouldn't count as
// a "match" to auto-visit.
if (result) {
this.maybeVisitFirstMatch(link);
}
});
},
// Sends the viewer straight to the first scan this session that's confirmed to resolve
// (immediately for a plain URL, or once resolveDescription confirms a real target for a
// token/handle) while the "Visit first match" toggle is on. One-shot: switches the toggle
// back off so it doesn't fire again for every later scan of the same or another code.
maybeVisitFirstMatch(link) {
if (!this.visitFirstMatch || !link?.to) {
return;
}
this.visitFirstMatch = false;
this.$router.push(link.to);
},
async describeLink(link) {
if (link.itemHandle) {
return this.describeItem(link.itemHandle.handle, link.itemHandle.id);
}
const decoded = link.decoded;
if (!decoded) {
return null;
}
if (decoded.kind === "item" || decoded.kind === "group_item") {
const byId = decoded.kind === "item"
? this.$store.getters.identityHandleById
: this.$store.getters.groupHandleById;
const ownerId = decoded.kind === "item" ? decoded.owner_identity_id : decoded.owner_group_id;
let handle = byId[ownerId];
if (handle === undefined) {
await this.fetchIdMap();
handle = (decoded.kind === "item"
? this.$store.getters.identityHandleById
: this.$store.getters.groupHandleById)[ownerId];
}
return handle === undefined ? null : this.describeItem(handle, decoded.item_local_id);
}
if (decoded.kind === "group") {
const group = await this.fetchGroup({id: decoded.group_id});
return group ? group.handle : null;
}
if (decoded.kind === "storage_location") {
if (!this.$store.state.storage_locations.length) {
await this.fetchStorageLocations();
}
const location = this.$store.state.storage_locations.find(l => l.id === decoded.storage_location_id);
return location ? `[#${location.id}] ${location.name}` : null;
}
return null; // workflow, category, file: no per-item title lookup wired up yet
},
async describeItem(handle, id) {
const item = await this.fetchItemByHandle({handle, id});
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 {
@ -180,7 +357,11 @@ export default {
const results = anyd.decodeImage(imageData);
this.drawBoxes(ctx, results);
this.hasFileImage = true;
this.fileResults = results.map(r => ({...r, metaText: metaSummary(r.metadata)}));
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);
}
@ -233,9 +414,8 @@ export default {
}
},
// 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.
// 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;
@ -264,21 +444,30 @@ export default {
},
logDecode(code) {
this.cameraLog.unshift({
const text = code.text ?? "";
const entry = {
type: code.type,
text: code.text ?? "",
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.
link: classifyScanText(text),
description: undefined,
error: null,
};
this.cameraLog.unshift(entry);
// Resolve against cameraLog[0], not the plain `entry` object above: Vue's reactivity
// tracks property sets through the reactive proxy unshift() just installed, and
// mutating the pre-insertion raw object later bypasses that proxy entirely, so the
// description would never appear to update (stuck on "resolving...") even once the
// lookup actually finished.
this.resolveDescription(this.cameraLog[0]);
// Caps the log (old entries trimmed) rather than growing forever, matching
// CameraScanner's own dedupe window that 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.
// 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;
@ -307,12 +496,8 @@ export default {
() => ctx.clearRect(0, 0, overlay.width, overlay.height), OVERLAY_CLEAR_MS);
},
// Attaches `stream` to the video element - shared by startCamera and the
// camera-switch/reconnect paths so the scanner (which just keeps reading frames off the
// same video element) never needs to be recreated. videoWidth/videoHeight aren't known yet
// right after play() - the video's own "resize" event (see onVideoResize) is what tells us
// the new aspect ratio has actually taken effect, which is also when the overlay needs to
// be resized to match.
// 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;
@ -320,10 +505,8 @@ export default {
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.
// 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) {
@ -382,9 +565,8 @@ export default {
}
},
// 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.
// 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"}`;
@ -401,12 +583,14 @@ export default {
},
},
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.
// 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);