stash
This commit is contained in:
parent
7d9f67a77a
commit
3299c97392
13 changed files with 1407 additions and 327 deletions
|
|
@ -46,6 +46,35 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!--div class="card mb-3 border-warning">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title mb-0">Experimental: new font candidates</h5>
|
||||
<small class="text-muted">for judging legibility - not part of the real label pipeline</small>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-3">
|
||||
Nine pixel/bitmap-style fonts found in frontend/public, rendered live from the "Text"
|
||||
field above at a range of sizes so they can be judged the same way Tom Thumb/Silkscreen
|
||||
were. <strong>CodersCrux</strong> and <strong>712Serif</strong> fail to load as web
|
||||
fonts at all - Chromium's OTS sanitizer rejects their cmap table - so their cells below
|
||||
are actually just the browser's fallback serif, not a rendering bug in this page.
|
||||
<strong>Chava</strong> is uppercase-only, same dealbreaker as PICO-8.
|
||||
<strong>6pxExpert</strong> only has pixel glyphs for digits; its letters are the same
|
||||
fallback serif.
|
||||
</p>
|
||||
<div v-for="cfg in candidateFonts" :key="cfg.family" class="mb-3">
|
||||
<div class="fw-bold small mb-1">{{ cfg.family }}</div>
|
||||
<div class="d-flex flex-wrap gap-3">
|
||||
<div v-for="size in candidateSizes" :key="cfg.family + '-' + size" class="text-center">
|
||||
<div class="small text-muted mb-1">{{ size }}px</div>
|
||||
<canvas :ref="el => setCandidateCanvasRef(cfg.family + '-' + size, el)"
|
||||
class="pixel-test-canvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div-->
|
||||
|
||||
<div v-if="!usbSupported" class="row">
|
||||
<div class="col-lg-7">
|
||||
<div class="card">
|
||||
|
|
@ -57,6 +86,37 @@
|
|||
<canvas ref="fallbackCanvas"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Orientation</label>
|
||||
<div class="btn-group d-block" role="group">
|
||||
<input type="radio" class="btn-check" id="orientation-along-fallback"
|
||||
autocomplete="off" value="along" v-model="orientation">
|
||||
<label class="btn btn-outline-secondary" for="orientation-along-fallback">
|
||||
Along tape
|
||||
</label>
|
||||
<input type="radio" class="btn-check" id="orientation-across-fallback"
|
||||
autocomplete="off" value="across" v-model="orientation">
|
||||
<label class="btn btn-outline-secondary" for="orientation-across-fallback">
|
||||
Across tape
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Code type</label>
|
||||
<div class="btn-group d-block" role="group">
|
||||
<template v-for="opt in codeTypeOptions" :key="opt.id">
|
||||
<input type="radio" class="btn-check"
|
||||
:id="'code-type-' + opt.id + '-fallback'"
|
||||
autocomplete="off" :value="opt.id" v-model="codeType">
|
||||
<label class="btn btn-outline-secondary"
|
||||
:for="'code-type-' + opt.id + '-fallback'">
|
||||
{{ opt.label }}
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" :disabled="!fallbackReady" @click="downloadPng">
|
||||
<b-icon-download class="me-1"></b-icon-download>
|
||||
Download label as PNG
|
||||
|
|
@ -132,6 +192,34 @@
|
|||
</div>
|
||||
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-auto">
|
||||
<label class="form-label">Orientation</label>
|
||||
<div class="btn-group" role="group">
|
||||
<input type="radio" class="btn-check" id="orientation-along"
|
||||
autocomplete="off" value="along" v-model="orientation">
|
||||
<label class="btn btn-outline-secondary" for="orientation-along">
|
||||
Along tape
|
||||
</label>
|
||||
<input type="radio" class="btn-check" id="orientation-across"
|
||||
autocomplete="off" value="across" v-model="orientation">
|
||||
<label class="btn btn-outline-secondary" for="orientation-across">
|
||||
Across tape
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label">Code type</label>
|
||||
<div class="btn-group" role="group">
|
||||
<template v-for="opt in codeTypeOptions" :key="opt.id">
|
||||
<input type="radio" class="btn-check"
|
||||
:id="'code-type-' + opt.id" autocomplete="off"
|
||||
:value="opt.id" v-model="codeType">
|
||||
<label class="btn btn-outline-secondary" :for="'code-type-' + opt.id">
|
||||
{{ opt.label }}
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label">Copies</label>
|
||||
<input type="number" class="form-control copies-input"
|
||||
|
|
@ -187,7 +275,7 @@
|
|||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<label-layout-preview :fields="fields" :value="selectedTemplate"
|
||||
<label-layout-preview :fields="fields" :value="selectedTemplate" :code-type="codeType"
|
||||
@input="selectedTemplate = $event"></label-layout-preview>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -203,7 +291,7 @@ import BaseLayout from "@/components/BaseLayout.vue";
|
|||
import LabelLayoutPreview from "@/components/LabelLayoutPreview.vue";
|
||||
|
||||
import {MultiPrinterBlob, canvasToBitmap, bitmapToCanvas} from "../../vendor/weblabel.js";
|
||||
import {tapeFromStatus, drawLabel, drawFallbackLabel, buildLabelContent, buildLabelFields} from "@/label.js";
|
||||
import {tapeFromStatus, drawLabel, drawFallbackLabel, buildLabelContent, buildLabelFields, preloadQrEncoder, QR_CODE_TYPES} from "@/label.js";
|
||||
import {LABEL_TEMPLATES, BASE_VARS, DERIVED_VARS, withDerivedVars, templateContent} from "@/label-layouts.js";
|
||||
|
||||
// Served verbatim from public/vendor/ rather than bundled: libweblabel.js's
|
||||
|
|
@ -226,6 +314,15 @@ const RULER_TIERS = [
|
|||
{aboveMm: 500, tickMm: 5, majorEveryMm: 25},
|
||||
];
|
||||
|
||||
// Display labels for label.js's QR_CODE_TYPES - the code-type button group below reads this
|
||||
// (via codeTypeOptions) rather than hard-coding its own list, so a new symbology added to
|
||||
// anyd-qr.js/QR_CODE_TYPES only needs a label here, not a whole new radio group.
|
||||
const CODE_TYPE_LABELS = {
|
||||
"qr": "QR",
|
||||
"micro-qr": "Micro QR",
|
||||
"rmqr": "rMQR",
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "Print",
|
||||
components: {
|
||||
|
|
@ -283,11 +380,40 @@ export default {
|
|||
},
|
||||
copies: 1,
|
||||
selectedTemplate: LABEL_TEMPLATES[0].id,
|
||||
// "along" draws a layout reading along the tape's feed direction (the usual case -
|
||||
// constrained by the tape's cross-web width, growing as long as the content needs);
|
||||
// "across" turns it 90deg, constrained by that same width but along the *other* axis
|
||||
// instead, so it reads across the tape rather than along it. See label.js's
|
||||
// drawLabel/drawFallbackLabel for how that turn is actually drawn.
|
||||
orientation: "along",
|
||||
// Which of label.js's QR_CODE_TYPES every "qrcode" leaf in the current template
|
||||
// renders as - one global choice for the whole label, same as `orientation` above,
|
||||
// since a template only ever has one scannable code in practice.
|
||||
codeType: "qr",
|
||||
|
||||
fallbackReady: false,
|
||||
// TODO: replace with the real commands for our printers.
|
||||
brotherCliExample: "brother_ql --model QL-000 --printer usb://0000:0000 print --label 00 label.png",
|
||||
niimbotCliExample: "niimprint --model b00 --conn usb print --density 3 --image label.png",
|
||||
|
||||
// Nine pixel/bitmap-style font candidates found in frontend/public (see
|
||||
// ../assets/fonts/pixel-candidates/LICENSE.md) - none of these are used by label.js's
|
||||
// real PIXEL_FONT_TIERS; this card exists purely so they can be judged the same way
|
||||
// Tom Thumb/Silkscreen were, against real (live-typed) content instead of a fixed
|
||||
// example.
|
||||
candidateFonts: [
|
||||
{family: "Pixelon"},
|
||||
{family: "Pixelbasel"},
|
||||
{family: "Chava"},
|
||||
{family: "CodersCrux"},
|
||||
{family: "712Serif"},
|
||||
{family: "6pxNormal"},
|
||||
{family: "6pxExpert"},
|
||||
{family: "Jersey10"},
|
||||
{family: "Jersey15"},
|
||||
],
|
||||
candidateSizes: [5, 6, 7, 8, 10, 12, 16, 20],
|
||||
candidateZoom: 4,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
|
@ -297,6 +423,11 @@ export default {
|
|||
baseVars() {
|
||||
return BASE_VARS;
|
||||
},
|
||||
// {id, label} pairs for the code-type button group (see CODE_TYPE_LABELS above), in
|
||||
// label.js's QR_CODE_TYPES order.
|
||||
codeTypeOptions() {
|
||||
return QR_CODE_TYPES.map(id => ({id, label: CODE_TYPE_LABELS[id]}));
|
||||
},
|
||||
derivedVars() {
|
||||
return Object.keys(DERIVED_VARS);
|
||||
},
|
||||
|
|
@ -397,6 +528,7 @@ export default {
|
|||
} else {
|
||||
this.redrawFallback();
|
||||
}
|
||||
this.drawCandidateFontTests();
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
|
|
@ -407,6 +539,20 @@ export default {
|
|||
this.redrawFallback();
|
||||
}
|
||||
},
|
||||
orientation() {
|
||||
if (this.usbSupported) {
|
||||
this.redraw();
|
||||
} else {
|
||||
this.redrawFallback();
|
||||
}
|
||||
},
|
||||
codeType() {
|
||||
if (this.usbSupported) {
|
||||
this.redraw();
|
||||
} else {
|
||||
this.redrawFallback();
|
||||
}
|
||||
},
|
||||
// Covers connecting/disconnecting/switching printers - anything that changes the
|
||||
// tape dimensions redraw() sizes the canvas from. flush: 'post' because the canvas
|
||||
// itself only exists once `tape` is truthy (see the v-if/v-else in the template), so
|
||||
|
|
@ -517,7 +663,7 @@ export default {
|
|||
this.resizeObserver.observe(canvas.parentElement);
|
||||
let textSizesPx;
|
||||
try {
|
||||
({textSizesPx} = drawLabel(canvas, this.tape, content));
|
||||
({textSizesPx} = drawLabel(canvas, this.tape, content, this.orientation, this.codeType));
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
return;
|
||||
|
|
@ -543,7 +689,7 @@ export default {
|
|||
}
|
||||
this.resizeObserver.observe(canvas.parentElement);
|
||||
try {
|
||||
drawFallbackLabel(canvas, content);
|
||||
drawFallbackLabel(canvas, content, this.orientation, this.codeType);
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
return;
|
||||
|
|
@ -575,7 +721,17 @@ export default {
|
|||
if (!(available > 0)) {
|
||||
return 1;
|
||||
}
|
||||
const zoom = Math.min(MAX_ZOOM, available / canvas.width, MAX_PREVIEW_HEIGHT_PX / canvas.height);
|
||||
const rawZoom = Math.min(MAX_ZOOM, available / canvas.width, MAX_PREVIEW_HEIGHT_PX / canvas.height);
|
||||
// When magnifying, round DOWN to a whole number: image-rendering:pixelated below
|
||||
// only actually looks crisp when every source pixel maps to the *same* number of
|
||||
// screen pixels - at a fractional zoom (the overwhelmingly common case, since rawZoom
|
||||
// is just whatever ratio the tape/card happen to produce) some source pixels get
|
||||
// rounded up to one extra screen pixel and others don't, unevenly warping fine,
|
||||
// already-pixel-perfect detail like a crisp QR module or a tiny bitmap font glyph.
|
||||
// Flooring (never rounding/ceiling) keeps the same "never bigger than available
|
||||
// space" guarantee rawZoom already had. Shrinking (zoom < 1) has no equivalent "whole
|
||||
// factor" to snap to - downsampling always blends source pixels - so it's left as-is.
|
||||
const zoom = rawZoom >= 1 ? Math.max(1, Math.floor(rawZoom)) : rawZoom;
|
||||
canvas.style.width = `${canvas.width * zoom}px`;
|
||||
canvas.style.height = `${canvas.height * zoom}px`;
|
||||
canvas.style.imageRendering = zoom >= 1 ? "pixelated" : "auto";
|
||||
|
|
@ -607,17 +763,75 @@ export default {
|
|||
onUsbChange() {
|
||||
this.guard(() => this.refreshDevices());
|
||||
},
|
||||
|
||||
// A plain :ref="key" inside v-for would still get Vue's refInFor array-collecting
|
||||
// behavior (see LabelLayoutPreview.vue's setTemplateCanvasRef for the same pattern), so
|
||||
// this keys the canvases by the caller's own composite key string explicitly instead.
|
||||
setCandidateCanvasRef(key, el) {
|
||||
if (el) {
|
||||
this.candidateCanvases[key] = el;
|
||||
} else {
|
||||
delete this.candidateCanvases[key];
|
||||
}
|
||||
},
|
||||
|
||||
// Draws the live "Text" field's value into a single canvas at the exact size/family
|
||||
// given - no layout math, no snapping, just ctx.font as requested, so what's judged here
|
||||
// is the font itself rather than anything label.js's real pipeline does to it. Canvas size
|
||||
// is measured from the text itself, and ink-centered vertically (see label.js's
|
||||
// drawTextLeaf for the same idea) so a font with unreliable declared metrics still lands
|
||||
// fully inside the canvas instead of clipped off the top/bottom.
|
||||
drawCandidateCell(canvas, family, fontPx) {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
const text = this.varValues.text;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.font = `${fontPx}px "${family}"`;
|
||||
const {width, actualBoundingBoxAscent: up, actualBoundingBoxDescent: down} = ctx.measureText(text);
|
||||
canvas.width = Math.ceil(width) + 4;
|
||||
canvas.height = Math.ceil(up + down) + 8;
|
||||
// Re-set font/alignment - changing canvas.width/height above resets the 2D context.
|
||||
ctx.font = `${fontPx}px "${family}"`;
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "alphabetic";
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.fillStyle = "#000";
|
||||
ctx.fillText(text, 2, (canvas.height + up - down) / 2);
|
||||
canvas.style.width = `${canvas.width * this.candidateZoom}px`;
|
||||
canvas.style.height = `${canvas.height * this.candidateZoom}px`;
|
||||
},
|
||||
|
||||
// Draws every (candidateFonts x candidateSizes) cell - see the card in the template.
|
||||
async drawCandidateFontTests() {
|
||||
await Promise.all(this.candidateFonts
|
||||
.map(({family}) => document.fonts.load(`20px "${family}"`).catch(() => {})));
|
||||
for (const {family} of this.candidateFonts) {
|
||||
for (const size of this.candidateSizes) {
|
||||
const canvas = this.candidateCanvases[`${family}-${size}`];
|
||||
this.drawCandidateCell(canvas, family, size);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.blob = null;
|
||||
this.resizeObserver = null;
|
||||
this.resizeTimer = null;
|
||||
this.candidateCanvases = {};
|
||||
},
|
||||
async mounted() {
|
||||
this.drawCandidateFontTests();
|
||||
this.resizeObserver = new ResizeObserver(this.handleContainerResize);
|
||||
// Kicked off here rather than awaited immediately, so it loads concurrently with
|
||||
// MultiPrinterBlob below instead of serializing two independent wasm fetches - every
|
||||
// redraw()/redrawFallback() call below still waits on it first, since a "qrcode" leaf
|
||||
// throws (see label.js's encodeQr) until it resolves.
|
||||
const qrReady = preloadQrEncoder();
|
||||
if (!("usb" in navigator)) {
|
||||
this.usbSupported = false;
|
||||
await nextTick();
|
||||
await Promise.all([nextTick(), qrReady]);
|
||||
this.redrawFallback();
|
||||
return;
|
||||
}
|
||||
|
|
@ -630,6 +844,7 @@ export default {
|
|||
this.blobReady = true;
|
||||
navigator.usb.addEventListener("connect", this.onUsbChange);
|
||||
navigator.usb.addEventListener("disconnect", this.onUsbChange);
|
||||
await qrReady;
|
||||
await this.guard(() => this.refreshDevices());
|
||||
},
|
||||
beforeUnmount() {
|
||||
|
|
@ -774,6 +989,13 @@ export default {
|
|||
width: 5.5rem;
|
||||
}
|
||||
|
||||
.pixel-test-canvas {
|
||||
display: block;
|
||||
background: #fff;
|
||||
box-shadow: 0 0 0 1px rgba(127, 127, 127, .5);
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: rgba(127, 127, 127, .08);
|
||||
border-radius: .35rem;
|
||||
|
|
|
|||
435
frontend/src/views/Scan.vue
Normal file
435
frontend/src/views/Scan.vue
Normal 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 & 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>
|
||||
143
frontend/src/views/ShortId.vue
Normal file
143
frontend/src/views/ShortId.vue
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
<template>
|
||||
<BaseLayout hide-search>
|
||||
<main class="content">
|
||||
<div class="container-fluid p-0">
|
||||
<div class="card">
|
||||
<div class="card-header">Decoded</div>
|
||||
<div class="card-body">
|
||||
<ul>
|
||||
<li v-for="(value, index) in decoded" :key="index">{{ value }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">Deserialized</div>
|
||||
<div class="card-body">
|
||||
<p>kind: {{ deserialized.kind }}</p>
|
||||
<ul>
|
||||
<li v-for="(value, name) in fields" :key="name">{{ name }}: {{ value }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">Examples</div>
|
||||
<table class="table table-striped mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kind</th>
|
||||
<th>Fields</th>
|
||||
<th>Serialized</th>
|
||||
<th>Bits</th>
|
||||
<th>Token</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="example in examples" :key="example.token">
|
||||
<td>{{ example.kind }}</td>
|
||||
<td>
|
||||
<span v-for="(value, name) in example.fields" :key="name" class="me-2">
|
||||
{{ name }}: {{ value }}
|
||||
</span>
|
||||
</td>
|
||||
<td>[{{ example.ints.join(', ') }}]</td>
|
||||
<td><code>{{ example.bits }}</code></td>
|
||||
<td>
|
||||
<router-link :to="'/' + example.token">{{ example.token }}</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</BaseLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BaseLayout from '@/components/BaseLayout.vue';
|
||||
import {decodeShortId, deserializeShortId, encodeShortId, serializeShortId} from '@/short-id';
|
||||
|
||||
const EXAMPLES = [
|
||||
{kind: 'item', owner_identity_id: 7, item_local_id: 42},
|
||||
{kind: 'storage_location', owner_identity_id: 3, storage_location_id: 1000},
|
||||
{kind: 'category', category_id: 5},
|
||||
{kind: 'workflow', owner_identity_id: 2, workflow_id: 9},
|
||||
{kind: 'group', group_id: 11},
|
||||
{kind: 'file', file_id: 123},
|
||||
];
|
||||
|
||||
// Debug-only display helper, kept out of short-id.js's production library: mirrors its bit-packing
|
||||
// rules (2-bit kind tag with an all-ones escape, 4-bit continuation chunks, see
|
||||
// docs/handles-and-shortids.md) just to show them, recomputed straight from an already-serialized
|
||||
// `ints` list rather than re-parsing a token.
|
||||
const KIND_TAG_BITS = 2;
|
||||
const CHUNK_BITS = 4;
|
||||
const DIRECT_KIND_COUNT = 2 ** KIND_TAG_BITS - 1;
|
||||
|
||||
function chunkedIntBits(value, chunkBits) {
|
||||
value = BigInt(value);
|
||||
const mask = (1n << BigInt(chunkBits)) - 1n;
|
||||
const chunks = [];
|
||||
let remaining = value;
|
||||
do {
|
||||
chunks.unshift(remaining & mask);
|
||||
remaining >>= BigInt(chunkBits);
|
||||
} while (remaining > 0n);
|
||||
return chunks.map((chunk, i) => {
|
||||
const continuation = i === chunks.length - 1 ? '0' : '1';
|
||||
return continuation + chunk.toString(2).padStart(chunkBits, '0');
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function shortIdBitSegments(ints) {
|
||||
const [kindIndex, ...fieldValues] = ints;
|
||||
const segments = [];
|
||||
if (kindIndex < DIRECT_KIND_COUNT) {
|
||||
segments.push(kindIndex.toString(2).padStart(KIND_TAG_BITS, '0'));
|
||||
} else {
|
||||
segments.push('1'.repeat(KIND_TAG_BITS));
|
||||
segments.push(chunkedIntBits(kindIndex - DIRECT_KIND_COUNT, CHUNK_BITS));
|
||||
}
|
||||
fieldValues.forEach(value => segments.push(chunkedIntBits(value, CHUNK_BITS)));
|
||||
|
||||
const totalBits = segments.join('').length;
|
||||
const paddingBits = (6 - (totalBits % 6)) % 6;
|
||||
if (paddingBits > 0) {
|
||||
segments.push('0'.repeat(paddingBits));
|
||||
}
|
||||
return segments.join(' ');
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'ShortId',
|
||||
components: {
|
||||
BaseLayout
|
||||
},
|
||||
props: {
|
||||
short_id: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
decoded() {
|
||||
return decodeShortId(this.short_id);
|
||||
},
|
||||
deserialized() {
|
||||
return deserializeShortId(this.decoded);
|
||||
},
|
||||
fields() {
|
||||
const {kind, ...fields} = this.deserialized;
|
||||
return fields;
|
||||
},
|
||||
examples() {
|
||||
return EXAMPLES.map(named => {
|
||||
const {kind, ...fields} = named;
|
||||
const ints = serializeShortId(named);
|
||||
const token = encodeShortId(ints);
|
||||
return {kind, fields, ints, token, bits: shortIdBitSegments(ints)};
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Loading…
Add table
Add a link
Reference in a new issue