diff --git a/kcal/package.json b/kcal/package.json index 1e7d6fe..5f93f99 100644 --- a/kcal/package.json +++ b/kcal/package.json @@ -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", diff --git a/kcal/pnpm-lock.yaml b/kcal/pnpm-lock.yaml index c485c1f..86393ac 100644 --- a/kcal/pnpm-lock.yaml +++ b/kcal/pnpm-lock.yaml @@ -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 diff --git a/kcal/src/components/BarcodeScanner.tsx b/kcal/src/components/BarcodeScanner.tsx index c039a7f..c212dc1 100644 --- a/kcal/src/components/BarcodeScanner.tsx +++ b/kcal/src/components/BarcodeScanner.tsx @@ -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; +} + +type NativeDetector = new (options?: { formats?: BarcodeFormat[] }) => Detector; + +/** + * Нативный детектор бесплатен и энергоэффективнее, поэтому wasm подключается + * только там, где своего распознавания нет. + */ +async function createDetector(): Promise { + 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 | 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 (
{error.value === '' ? (
-