feat: add barcode detection support with zxing-wasm and barcode-detector

This commit is contained in:
2026-08-07 17:09:57 +07:00
parent e9d4e41f7f
commit 7d98bb00fd
6 changed files with 133 additions and 35 deletions
+3 -1
View File
@@ -22,10 +22,12 @@
"@robonen/platform": "^0.0.5",
"@robonen/stdlib": "^0.0.12",
"@robonen/vue": "^0.2.0",
"barcode-detector": "^3.2.1",
"date-fns": "^4.4.0",
"lucide": "^1.29.0",
"vue": "3.6.0-rc.2",
"vue-sync-engine": "link:../vue-sync-engine/lib"
"vue-sync-engine": "link:../vue-sync-engine/lib",
"zxing-wasm": "3.1.1"
},
"devDependencies": {
"@robonen/eslint": "^0.0.1",
+44
View File
@@ -23,6 +23,9 @@ importers:
'@robonen/vue':
specifier: ^0.2.0
version: 0.2.0(vue@3.6.0-rc.2(typescript@6.0.3))
barcode-detector:
specifier: ^3.2.1
version: 3.2.1(@types/emscripten@1.41.5)
date-fns:
specifier: ^4.4.0
version: 4.4.0
@@ -35,6 +38,9 @@ importers:
vue-sync-engine:
specifier: link:../vue-sync-engine/lib
version: link:../vue-sync-engine/lib
zxing-wasm:
specifier: 3.1.1
version: 3.1.1(@types/emscripten@1.41.5)
devDependencies:
'@robonen/eslint':
specifier: ^0.0.1
@@ -1242,6 +1248,9 @@ packages:
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/emscripten@1.41.5':
resolution: {integrity: sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==}
'@types/esrecurse@4.3.1':
resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
@@ -1699,6 +1708,9 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
barcode-detector@3.2.1:
resolution: {integrity: sha512-zLL7AbT9uNJBUzYpKg9v5tUA4yQGReExSi60q0g660Mj0wjUhKmN0GrUF3qELo8ITW9THzyJXdZQkXLMnsieiw==}
baseline-browser-mapping@2.11.12:
resolution: {integrity: sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==}
engines: {node: '>=6.0.0'}
@@ -2949,6 +2961,10 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
tagged-tag@1.0.0:
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
engines: {node: '>=20'}
tailwindcss@4.3.3:
resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
@@ -3007,6 +3023,10 @@ packages:
resolution: {integrity: sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==}
engines: {node: '>=10'}
type-fest@5.8.0:
resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==}
engines: {node: '>=20'}
typed-array-buffer@1.0.3:
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
engines: {node: '>= 0.4'}
@@ -3355,6 +3375,11 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
zxing-wasm@3.1.1:
resolution: {integrity: sha512-g0sPJBIubO6zLcJh1jftLPIN6xziaqLsvLgtpGKwDrEhyXXqla3E3yjFrznlr78UHIOMzbJPi0HDWKs/KgaB7A==}
peerDependencies:
'@types/emscripten': '>=1.39.6'
snapshots:
'@apideck/better-ajv-errors@0.3.7(ajv@8.20.0)':
@@ -4513,6 +4538,8 @@ snapshots:
'@types/deep-eql@4.0.2': {}
'@types/emscripten@1.41.5': {}
'@types/esrecurse@4.3.1': {}
'@types/estree@1.0.9': {}
@@ -4982,6 +5009,12 @@ snapshots:
balanced-match@4.0.4: {}
barcode-detector@3.2.1(@types/emscripten@1.41.5):
dependencies:
zxing-wasm: 3.1.1(@types/emscripten@1.41.5)
transitivePeerDependencies:
- '@types/emscripten'
baseline-browser-mapping@2.11.12: {}
boolbase@1.0.0: {}
@@ -6325,6 +6358,8 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
tagged-tag@1.0.0: {}
tailwindcss@4.3.3: {}
tapable@2.3.3: {}
@@ -6375,6 +6410,10 @@ snapshots:
type-fest@0.16.0: {}
type-fest@5.8.0:
dependencies:
tagged-tag: 1.0.0
typed-array-buffer@1.0.3:
dependencies:
call-bound: 1.0.4
@@ -6786,3 +6825,8 @@ snapshots:
yallist@3.1.1: {}
yocto-queue@0.1.0: {}
zxing-wasm@3.1.1(@types/emscripten@1.41.5):
dependencies:
'@types/emscripten': 1.41.5
type-fest: 5.8.0
+70 -19
View File
@@ -1,20 +1,57 @@
import { onMounted, onUnmounted, shallowRef } from 'vue';
import { useRef } from 'vue-jsx-vapor';
/** Есть ли в этом браузере нативное распознавание штрихкодов (Chromium). */
export function isBarcodeScanSupported(): boolean {
return typeof BarcodeDetector !== 'undefined' && !!navigator.mediaDevices?.getUserMedia;
}
const SCAN_INTERVAL_MS = 300;
import type { BarcodeFormat, DetectedBarcode } from 'barcode-detector/ponyfill';
// Только URL: сам wasm в бандл не попадает, он эмитится отдельным файлом и
// скачивается лишь тогда, когда до него дойдёт дело. Версия zxing-wasm в
// package.json приколочена намертво — обвязка и .wasm обязаны быть из одного
// релиза, иначе модуль не инициализируется.
import wasmUrl from 'zxing-wasm/reader/zxing_reader.wasm?url';
/**
* Камера + BarcodeDetector: распознали EAN/UPC — отдали наверх и погасили
* камеру. Компонент монтируется только после isBarcodeScanSupported().
* Сканер доступен везде, где есть камера. Нативный Shape Detection API —
* только в Chromium; в WebKit (Safari и любой браузер на iOS, включая Chrome)
* BarcodeDetector не реализован, там работает wasm-сборка zxing.
*/
export function isBarcodeScanSupported(): boolean {
return !!navigator.mediaDevices?.getUserMedia;
}
const FORMATS: BarcodeFormat[] = ['ean_13', 'ean_8', 'upc_a', 'upc_e', 'code_128'];
const SCAN_INTERVAL_MS = 300;
interface Detector {
detect: (source: HTMLVideoElement) => Promise<DetectedBarcode[]>;
}
type NativeDetector = new (options?: { formats?: BarcodeFormat[] }) => Detector;
/**
* Нативный детектор бесплатен и энергоэффективнее, поэтому wasm подключается
* только там, где своего распознавания нет.
*/
async function createDetector(): Promise<Detector> {
const Native = (globalThis as { BarcodeDetector?: NativeDetector }).BarcodeDetector;
if (Native) return new Native({ formats: FORMATS });
const { BarcodeDetector, prepareZXingModule } = await import('barcode-detector/ponyfill');
// По умолчанию zxing тянет .wasm с CDN — это ломало бы сканер офлайн и
// упиралось бы в CSP, поэтому подставляем файл из собственной сборки.
await prepareZXingModule({
overrides: {
locateFile: (path: string, prefix: string) => (path.endsWith('.wasm') ? wasmUrl : `${prefix}${path}`),
},
fireImmediately: true,
});
return new BarcodeDetector({ formats: FORMATS });
}
/**
* Камера + детектор: распознали EAN/UPC — отдали наверх и погасили камеру.
*/
export default function BarcodeScanner(props: { onDetected: (code: string) => void; onCancel: () => void }) {
const videoEl = useRef();
const error = shallowRef('');
const loading = shallowRef(true);
let stream: MediaStream | null = null;
let timer: ReturnType<typeof setInterval> | null = null;
@@ -32,15 +69,18 @@ export default function BarcodeScanner(props: { onDetected: (code: string) => vo
if (!video) return;
try {
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' },
video: { facingMode: { ideal: 'environment' } },
audio: false,
});
// Пока ждали разрешение, лист мог закрыться — поток нужно погасить.
if (finished) return;
video.srcObject = stream;
await video.play();
const detector = new BarcodeDetector({
formats: ['ean_13', 'ean_8', 'upc_a', 'upc_e', 'code_128'],
});
const detector = await createDetector();
if (finished) return;
loading.value = false;
timer = setInterval(async () => {
if (finished || video.readyState < 2) return;
try {
@@ -58,26 +98,37 @@ export default function BarcodeScanner(props: { onDetected: (code: string) => vo
}, SCAN_INTERVAL_MS);
}
catch (cause) {
error.value = cause instanceof DOMException && cause.name === 'NotAllowedError'
? 'Нет доступа к камере — разрешите его в настройках браузера.'
: 'Не удалось включить камеру.';
if (cause instanceof DOMException && cause.name === 'NotAllowedError') {
error.value = 'Нет доступа к камере — разрешите его в настройках браузера.';
}
else if (stream) {
// Камера уже работает, значит споткнулись на загрузке распознавания.
error.value = 'Не удалось загрузить распознавание штрихкодов. Введите цифры вручную.';
}
else {
error.value = 'Не удалось включить камеру.';
}
stop();
}
});
onUnmounted(stop);
onUnmounted(() => {
finished = true;
stop();
});
return (
<div class="overflow-hidden rounded-2xl border hairline bg-black/60">
{error.value === ''
? (
<div class="relative">
<video ref={videoEl} muted playsinline class="aspect-[4/3] w-full object-cover" />
<video ref={videoEl} muted playsinline class="aspect-4/3 w-full object-cover" />
{/* Рамка прицела */}
<div class="pointer-events-none absolute inset-0 grid place-items-center">
<div class="h-24 w-56 rounded-xl border-2 border-ember-bright/80 shadow-[0_0_0_999px_rgba(0,0,0,0.35)]" />
</div>
<p class="absolute right-0 bottom-2 left-0 text-center text-[12px] text-ink/90">
Наведите на штрихкод упаковки
{loading.value ? 'Готовим распознавание…' : 'Наведите на штрихкод упаковки'}
</p>
</div>
)
-12
View File
@@ -1,13 +1 @@
/// <reference types="vite/client" />
// BarcodeDetector (Chromium): в lib.dom его ещё нет — минимальная декларация.
interface DetectedBarcode {
rawValue: string;
format: string;
}
declare class BarcodeDetector {
constructor(options?: { formats?: string[] });
detect(source: CanvasImageSource): Promise<DetectedBarcode[]>;
static getSupportedFormats(): Promise<string[]>;
}
+2 -2
View File
@@ -168,7 +168,7 @@ export default function StatsScreen() {
class="absolute right-0 left-0 z-10 border-t border-dashed border-ink-soft/40"
style={{ bottom: `${(target.value / chartMax.value) * 100}%` }}
/>
<div class="flex h-full items-end gap-[3px]">
<div class="flex h-full items-end gap-0.75">
{days.value.map(day => (
<button
type="button"
@@ -177,7 +177,7 @@ export default function StatsScreen() {
onClick={() => (selectedBar.value = day.date)}
>
<div
class={`w-full rounded-t-[4px] transition-colors ${day.kcal > 0 ? '' : 'min-h-[2px]'} ${selectedBar.value === day.date
class={`w-full rounded-t-sm transition-colors ${day.kcal > 0 ? '' : 'min-h-0.5'} ${selectedBar.value === day.date
? 'bg-ember-bright'
: day.kcal > target.value ? 'bg-over/80 group-hover:bg-over' : 'bg-ember/65 group-hover:bg-ember'}`}
style={{ height: `${Math.max((day.kcal / chartMax.value) * 100, day.kcal > 0 ? 2 : 1)}%` }}
+14 -1
View File
@@ -36,8 +36,21 @@ export default defineConfig({
workbox: {
// woff тут дубли woff2 от fontsource — в прекеш идёт только woff2.
globPatterns: ['**/*.{js,css,html,woff2,svg,png,ico}'],
// Штрихкоды из Open Food Facts: сеть свежее, но офлайн отдаём кэш.
runtimeCaching: [
// Распознавание штрихкодов на WebKit — ~1 МБ wasm. В прекеш его класть
// жалко (нужен не всем), поэтому кэшируем после первого запуска
// сканера: дальше он работает и офлайн. Файл с хэшем в имени, так что
// CacheFirst безопасен.
{
urlPattern: /\.wasm$/,
handler: 'CacheFirst',
options: {
cacheName: 'wasm',
expiration: { maxEntries: 4 },
cacheableResponse: { statuses: [0, 200] },
},
},
// Штрихкоды из Open Food Facts: сеть свежее, но офлайн отдаём кэш.
{
urlPattern: /^https:\/\/world\.openfoodfacts\.org\/.*/,
handler: 'NetworkFirst',