mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: add TOTP QR code scanning functionality and related UI components
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import type { JSX, RefObject } from 'preact';
|
||||
import { CheckCheck, Download, GripVertical, Paperclip, Plus, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact';
|
||||
import { createPortal } from 'preact/compat';
|
||||
import { CheckCheck, Download, GripVertical, Paperclip, Plus, QrCode, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useDialogLifecycle } from '@/components/ConfirmDialog';
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
@@ -137,8 +139,16 @@ function SortableWebsiteRow(props: SortableWebsiteRowProps) {
|
||||
export default function VaultEditor(props: VaultEditorProps) {
|
||||
const createTypeOptions = getCreateTypeOptions();
|
||||
const uriIdSeedRef = useRef(0);
|
||||
const totpQrVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const totpQrFileRef = useRef<HTMLInputElement | null>(null);
|
||||
const totpQrStreamRef = useRef<MediaStream | null>(null);
|
||||
const totpQrFrameRef = useRef<number | null>(null);
|
||||
const [uriItemIds, setUriItemIds] = useState<string[]>([]);
|
||||
const [activeUriId, setActiveUriId] = useState<string | null>(null);
|
||||
const [totpQrOpen, setTotpQrOpen] = useState(false);
|
||||
const [totpQrStatus, setTotpQrStatus] = useState('');
|
||||
const [totpQrBusy, setTotpQrBusy] = useState(false);
|
||||
useDialogLifecycle(totpQrOpen, () => setTotpQrOpen(false));
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
@@ -155,6 +165,63 @@ export default function VaultEditor(props: VaultEditorProps) {
|
||||
|
||||
const createUriId = () => `login-uri-${uriIdSeedRef.current++}`;
|
||||
|
||||
const stopTotpQrScanner = () => {
|
||||
if (totpQrFrameRef.current != null) {
|
||||
window.cancelAnimationFrame(totpQrFrameRef.current);
|
||||
totpQrFrameRef.current = null;
|
||||
}
|
||||
if (totpQrStreamRef.current) {
|
||||
for (const track of totpQrStreamRef.current.getTracks()) track.stop();
|
||||
totpQrStreamRef.current = null;
|
||||
}
|
||||
if (totpQrVideoRef.current) {
|
||||
totpQrVideoRef.current.srcObject = null;
|
||||
}
|
||||
};
|
||||
|
||||
const applyTotpQrValue = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return false;
|
||||
props.onUpdateDraft({ loginTotp: trimmed });
|
||||
setTotpQrStatus(t('txt_totp_qr_scanned'));
|
||||
setTotpQrOpen(false);
|
||||
return true;
|
||||
};
|
||||
|
||||
const createTotpQrDetector = (): BarcodeDetector | null => {
|
||||
if (typeof window === 'undefined' || !window.BarcodeDetector) return null;
|
||||
return new window.BarcodeDetector({ formats: ['qr_code'] });
|
||||
};
|
||||
|
||||
const decodeTotpQrImage = async (source: ImageBitmapSource): Promise<boolean> => {
|
||||
const detector = createTotpQrDetector();
|
||||
if (!detector) {
|
||||
setTotpQrStatus(t('txt_totp_qr_unsupported'));
|
||||
return false;
|
||||
}
|
||||
const results = await detector.detect(source);
|
||||
const value = String(results[0]?.rawValue || '').trim();
|
||||
if (!value) return false;
|
||||
return applyTotpQrValue(value);
|
||||
};
|
||||
|
||||
const handleTotpQrFile = async (file: File | null) => {
|
||||
if (!file) return;
|
||||
setTotpQrBusy(true);
|
||||
setTotpQrStatus(t('txt_totp_qr_scanning'));
|
||||
let bitmap: ImageBitmap | null = null;
|
||||
try {
|
||||
bitmap = await createImageBitmap(file);
|
||||
const found = await decodeTotpQrImage(bitmap);
|
||||
if (!found) setTotpQrStatus(t('txt_totp_qr_not_found'));
|
||||
} catch {
|
||||
setTotpQrStatus(t('txt_totp_qr_scan_failed'));
|
||||
} finally {
|
||||
bitmap?.close();
|
||||
setTotpQrBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setUriItemIds((prev) => {
|
||||
if (prev.length === props.draft.loginUris.length) return prev;
|
||||
@@ -170,6 +237,77 @@ export default function VaultEditor(props: VaultEditorProps) {
|
||||
setActiveUriId(null);
|
||||
}, [props.draft.id, props.isCreating]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!totpQrOpen) {
|
||||
stopTotpQrScanner();
|
||||
return;
|
||||
}
|
||||
let stopped = false;
|
||||
const detector = createTotpQrDetector();
|
||||
if (!detector) {
|
||||
setTotpQrStatus(t('txt_totp_qr_unsupported'));
|
||||
return () => {
|
||||
stopped = true;
|
||||
stopTotpQrScanner();
|
||||
};
|
||||
}
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
setTotpQrStatus(t('txt_totp_qr_camera_unavailable'));
|
||||
return () => {
|
||||
stopped = true;
|
||||
stopTotpQrScanner();
|
||||
};
|
||||
}
|
||||
|
||||
const scan = async () => {
|
||||
if (stopped) return;
|
||||
const video = totpQrVideoRef.current;
|
||||
if (!video || video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
|
||||
totpQrFrameRef.current = window.requestAnimationFrame(scan);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const results = await detector.detect(video);
|
||||
const value = String(results[0]?.rawValue || '').trim();
|
||||
if (value && applyTotpQrValue(value)) return;
|
||||
} catch {
|
||||
// Keep the camera active; transient frame decode failures are common.
|
||||
}
|
||||
totpQrFrameRef.current = window.requestAnimationFrame(scan);
|
||||
};
|
||||
|
||||
setTotpQrBusy(true);
|
||||
setTotpQrStatus(t('txt_totp_qr_starting_camera'));
|
||||
navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' }, audio: false })
|
||||
.then((stream) => {
|
||||
if (stopped) {
|
||||
for (const track of stream.getTracks()) track.stop();
|
||||
return;
|
||||
}
|
||||
totpQrStreamRef.current = stream;
|
||||
const video = totpQrVideoRef.current;
|
||||
if (!video) return;
|
||||
video.srcObject = stream;
|
||||
setTotpQrStatus(t('txt_totp_qr_point_camera'));
|
||||
void video.play().then(() => {
|
||||
setTotpQrBusy(false);
|
||||
totpQrFrameRef.current = window.requestAnimationFrame(scan);
|
||||
}).catch(() => {
|
||||
setTotpQrBusy(false);
|
||||
setTotpQrStatus(t('txt_totp_qr_camera_unavailable'));
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setTotpQrBusy(false);
|
||||
setTotpQrStatus(t('txt_totp_qr_camera_unavailable'));
|
||||
});
|
||||
|
||||
return () => {
|
||||
stopped = true;
|
||||
stopTotpQrScanner();
|
||||
};
|
||||
}, [totpQrOpen]);
|
||||
|
||||
const formatDownloadLabel = (attachmentId: string) => {
|
||||
const downloadKey = `${props.selectedCipher?.id || ''}:${attachmentId}`;
|
||||
if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download');
|
||||
@@ -274,7 +412,22 @@ export default function VaultEditor(props: VaultEditorProps) {
|
||||
</div>
|
||||
<label className="field">
|
||||
<span>{t('txt_totp_secret')}</span>
|
||||
<input className="input" value={props.draft.loginTotp} onInput={(e) => props.onUpdateDraft({ loginTotp: (e.currentTarget as HTMLInputElement).value })} />
|
||||
<div className="input-action-wrap">
|
||||
<input className="input" value={props.draft.loginTotp} onInput={(e) => props.onUpdateDraft({ loginTotp: (e.currentTarget as HTMLInputElement).value })} />
|
||||
<button
|
||||
type="button"
|
||||
className="input-icon-btn"
|
||||
title={t('txt_scan_totp_qr')}
|
||||
aria-label={t('txt_scan_totp_qr')}
|
||||
disabled={props.busy}
|
||||
onClick={() => {
|
||||
setTotpQrStatus('');
|
||||
setTotpQrOpen(true);
|
||||
}}
|
||||
>
|
||||
<QrCode size={18} className="btn-icon" />
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<div className="section-head">
|
||||
<h4>{t('txt_websites')}</h4>
|
||||
@@ -571,6 +724,52 @@ export default function VaultEditor(props: VaultEditorProps) {
|
||||
)}
|
||||
</div>
|
||||
{props.localError && <div className="local-error">{props.localError}</div>}
|
||||
{totpQrOpen && typeof document !== 'undefined' ? createPortal((
|
||||
<div className="dialog-mask totp-scan-mask open" onClick={(event) => event.target === event.currentTarget && setTotpQrOpen(false)}>
|
||||
<section className="dialog-card totp-scan-dialog open" role="dialog" aria-modal="true" aria-label={t('txt_scan_totp_qr')}>
|
||||
<div className="totp-scan-head">
|
||||
<h3 className="dialog-title">{t('txt_scan_totp_qr')}</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="totp-scan-close"
|
||||
onClick={() => setTotpQrOpen(false)}
|
||||
title={t('txt_close')}
|
||||
aria-label={t('txt_close')}
|
||||
>
|
||||
<X size={20} className="btn-icon" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="totp-scan-frame">
|
||||
<video ref={totpQrVideoRef} className="totp-scan-video" muted playsInline />
|
||||
<div className="totp-scan-corners" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="totp-scan-footer">
|
||||
<div className="dialog-message totp-scan-status">{totpQrStatus || t('txt_totp_qr_point_camera')}</div>
|
||||
<div className="actions totp-scan-actions">
|
||||
<button type="button" className="btn btn-secondary dialog-btn" disabled={totpQrBusy} onClick={() => totpQrFileRef.current?.click()}>
|
||||
<Upload size={14} className="btn-icon" />
|
||||
{t('txt_totp_qr_choose_image')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary dialog-btn" onClick={() => setTotpQrOpen(false)}>
|
||||
<X size={14} className="btn-icon" />
|
||||
{t('txt_close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref={totpQrFileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="attachment-file-input"
|
||||
onChange={(event) => {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
void handleTotpQrFile(input.files?.[0] || null);
|
||||
input.value = '';
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
), document.body) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -697,6 +697,16 @@ const en: Record<string, string> = {
|
||||
"txt_totp_is_enabled_for_this_account": "TOTP is enabled for this account.",
|
||||
"txt_total_items_count": "{count} items",
|
||||
"txt_totp_secret": "TOTP Secret",
|
||||
"txt_scan_totp_qr": "Scan TOTP QR code",
|
||||
"txt_totp_qr_starting_camera": "Starting camera...",
|
||||
"txt_totp_qr_point_camera": "Point the camera at a TOTP QR code.",
|
||||
"txt_totp_qr_scanning": "Scanning QR code...",
|
||||
"txt_totp_qr_scanned": "TOTP value added.",
|
||||
"txt_totp_qr_not_found": "No QR code found in that image.",
|
||||
"txt_totp_qr_scan_failed": "Failed to scan QR code.",
|
||||
"txt_totp_qr_unsupported": "This browser does not support QR scanning. Try Chrome or Edge, or paste the TOTP link or secret manually.",
|
||||
"txt_totp_qr_camera_unavailable": "Camera is unavailable. Check browser permission, or choose an image.",
|
||||
"txt_totp_qr_choose_image": "Choose image",
|
||||
"txt_totp_verify_failed": "TOTP verify failed",
|
||||
"txt_attachments": "Attachments",
|
||||
"txt_upload_attachments": "Upload attachments",
|
||||
|
||||
@@ -697,6 +697,16 @@ const es: Record<string, string> = {
|
||||
"txt_totp_is_enabled_for_this_account": "TOTP está activado para esta cuenta.",
|
||||
"txt_total_items_count": "{count} elementos",
|
||||
"txt_totp_secret": "Secreto TOTP",
|
||||
"txt_scan_totp_qr": "Escanear QR TOTP",
|
||||
"txt_totp_qr_starting_camera": "Iniciando cámara...",
|
||||
"txt_totp_qr_point_camera": "Apunte la cámara a un código QR TOTP.",
|
||||
"txt_totp_qr_scanning": "Escaneando código QR...",
|
||||
"txt_totp_qr_scanned": "Valor TOTP agregado.",
|
||||
"txt_totp_qr_not_found": "No se encontró ningún código QR en esa imagen.",
|
||||
"txt_totp_qr_scan_failed": "No se pudo escanear el código QR.",
|
||||
"txt_totp_qr_unsupported": "Este navegador no admite escaneo QR. Pruebe Chrome o Edge, o pegue manualmente el enlace o secreto TOTP.",
|
||||
"txt_totp_qr_camera_unavailable": "La cámara no está disponible. Revise el permiso del navegador o elija una imagen.",
|
||||
"txt_totp_qr_choose_image": "Elegir imagen",
|
||||
"txt_totp_verify_failed": "Error al verificar TOTP",
|
||||
"txt_attachments": "Archivos adjuntos",
|
||||
"txt_upload_attachments": "Subir archivos adjuntos",
|
||||
@@ -845,4 +855,4 @@ const es: Record<string, string> = {
|
||||
"txt_language_saved_locally": "Esta preferencia se guarda en este navegador y se usa antes de que la aplicación cargue la próxima vez."
|
||||
};
|
||||
|
||||
export default es;
|
||||
export default es;
|
||||
|
||||
@@ -697,6 +697,16 @@ const ru: Record<string, string> = {
|
||||
"txt_totp_is_enabled_for_this_account": "TOTP включен для этой учетной записи.",
|
||||
"txt_total_items_count": "{count} товаров",
|
||||
"txt_totp_secret": "Секрет TOTP",
|
||||
"txt_scan_totp_qr": "Сканировать QR TOTP",
|
||||
"txt_totp_qr_starting_camera": "Запуск камеры...",
|
||||
"txt_totp_qr_point_camera": "Наведите камеру на QR-код TOTP.",
|
||||
"txt_totp_qr_scanning": "Сканирование QR-кода...",
|
||||
"txt_totp_qr_scanned": "Значение TOTP добавлено.",
|
||||
"txt_totp_qr_not_found": "QR-код на этом изображении не найден.",
|
||||
"txt_totp_qr_scan_failed": "Не удалось отсканировать QR-код.",
|
||||
"txt_totp_qr_unsupported": "Этот браузер не поддерживает сканирование QR. Попробуйте Chrome или Edge либо вставьте ссылку или секрет TOTP вручную.",
|
||||
"txt_totp_qr_camera_unavailable": "Камера недоступна. Проверьте разрешение браузера или выберите изображение.",
|
||||
"txt_totp_qr_choose_image": "Выбрать изображение",
|
||||
"txt_totp_verify_failed": "Проверка TOTP не удалась",
|
||||
"txt_attachments": "Вложения",
|
||||
"txt_upload_attachments": "Загрузить вложения",
|
||||
|
||||
@@ -697,6 +697,16 @@ const zhCN: Record<string, string> = {
|
||||
"txt_totp_is_enabled_for_this_account": "此账户已启用 TOTP。",
|
||||
"txt_total_items_count": "共 {count} 项",
|
||||
"txt_totp_secret": "TOTP 密钥",
|
||||
"txt_scan_totp_qr": "扫描 TOTP 二维码",
|
||||
"txt_totp_qr_starting_camera": "正在启动摄像头...",
|
||||
"txt_totp_qr_point_camera": "把摄像头对准 TOTP 二维码。",
|
||||
"txt_totp_qr_scanning": "正在扫描二维码...",
|
||||
"txt_totp_qr_scanned": "TOTP 内容已填入。",
|
||||
"txt_totp_qr_not_found": "这张图片里没有识别到二维码。",
|
||||
"txt_totp_qr_scan_failed": "二维码扫描失败。",
|
||||
"txt_totp_qr_unsupported": "当前浏览器不支持二维码扫描。可尝试 Chrome 或 Edge,或手动粘贴 TOTP 链接/密钥。",
|
||||
"txt_totp_qr_camera_unavailable": "无法使用摄像头。请检查浏览器权限,或选择图片。",
|
||||
"txt_totp_qr_choose_image": "选择图片",
|
||||
"txt_totp_verify_failed": "TOTP 验证失败",
|
||||
"txt_attachments": "附件",
|
||||
"txt_upload_attachments": "上传附件",
|
||||
|
||||
@@ -697,6 +697,16 @@ const zhTW: Record<string, string> = {
|
||||
"txt_totp_is_enabled_for_this_account": "此賬戶已啟用 TOTP。",
|
||||
"txt_total_items_count": "共 {count} 項",
|
||||
"txt_totp_secret": "TOTP 密鑰",
|
||||
"txt_scan_totp_qr": "掃描 TOTP 二維碼",
|
||||
"txt_totp_qr_starting_camera": "正在啟動攝影機...",
|
||||
"txt_totp_qr_point_camera": "把攝影機對準 TOTP 二維碼。",
|
||||
"txt_totp_qr_scanning": "正在掃描二維碼...",
|
||||
"txt_totp_qr_scanned": "TOTP 內容已填入。",
|
||||
"txt_totp_qr_not_found": "這張圖片裡沒有識別到二維碼。",
|
||||
"txt_totp_qr_scan_failed": "二維碼掃描失敗。",
|
||||
"txt_totp_qr_unsupported": "目前瀏覽器不支援二維碼掃描。可嘗試 Chrome 或 Edge,或手動貼上 TOTP 連結/密鑰。",
|
||||
"txt_totp_qr_camera_unavailable": "無法使用攝影機。請檢查瀏覽器權限,或選擇圖片。",
|
||||
"txt_totp_qr_choose_image": "選擇圖片",
|
||||
"txt_totp_verify_failed": "TOTP 驗證失敗",
|
||||
"txt_attachments": "附件",
|
||||
"txt_upload_attachments": "上傳附件",
|
||||
|
||||
@@ -74,6 +74,29 @@ input[type='file'].input::file-selector-button:hover {
|
||||
@apply pr-11;
|
||||
}
|
||||
|
||||
.input-action-wrap {
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
.input-action-wrap .input {
|
||||
@apply pr-12;
|
||||
}
|
||||
|
||||
.input-icon-btn {
|
||||
@apply absolute right-2 top-1/2 grid h-8 w-8 cursor-pointer place-items-center rounded-full border-0 bg-transparent text-slate-700 transition;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.input-icon-btn:hover:not(:disabled) {
|
||||
color: var(--primary);
|
||||
background: rgba(37, 99, 235, 0.08);
|
||||
transform: translateY(-50%) scale(1.04);
|
||||
}
|
||||
|
||||
.input-icon-btn:disabled {
|
||||
@apply cursor-not-allowed text-slate-400;
|
||||
}
|
||||
|
||||
.password-toggle {
|
||||
@apply absolute right-2 top-1/2 grid cursor-pointer place-items-center border-0 bg-transparent text-blue-700 transition;
|
||||
transform: translateY(-50%);
|
||||
|
||||
@@ -501,6 +501,10 @@
|
||||
height: 160px;
|
||||
}
|
||||
|
||||
.totp-scan-actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.invite-toolbar {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
@@ -663,6 +663,77 @@
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.dialog-mask.totp-scan-mask {
|
||||
@apply block p-0;
|
||||
background: #0f172a;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
.dialog-card.totp-scan-dialog {
|
||||
@apply flex h-dvh w-screen max-w-none flex-col overflow-hidden rounded-none border-0 p-0 text-left;
|
||||
max-height: 100dvh;
|
||||
background: #0f172a;
|
||||
color: #f8fafc;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.totp-scan-head {
|
||||
@apply flex shrink-0 items-center justify-between gap-3 px-4 py-3;
|
||||
padding-top: calc(12px + env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
.totp-scan-head .dialog-title {
|
||||
@apply m-0 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-xl;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.totp-scan-close {
|
||||
@apply grid h-10 w-10 shrink-0 cursor-pointer place-items-center rounded-full border-0 bg-white/10 text-white transition;
|
||||
}
|
||||
|
||||
.totp-scan-close:hover {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
.totp-scan-frame {
|
||||
@apply relative min-h-0 flex-1 overflow-hidden rounded-none;
|
||||
aspect-ratio: auto;
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.totp-scan-video {
|
||||
@apply h-full w-full object-cover;
|
||||
}
|
||||
|
||||
.totp-scan-corners {
|
||||
@apply pointer-events-none absolute rounded-[18px];
|
||||
inset: max(34px, 10vmin);
|
||||
border: 2px solid rgba(255, 255, 255, 0.88);
|
||||
box-shadow: 0 0 0 999px rgba(15, 23, 42, 0.28);
|
||||
}
|
||||
|
||||
.totp-scan-footer {
|
||||
@apply shrink-0 px-4 py-3;
|
||||
padding-bottom: calc(12px + env(safe-area-inset-bottom));
|
||||
background: linear-gradient(180deg, rgba(15, 23, 42, 0.74), #0f172a);
|
||||
}
|
||||
|
||||
.totp-scan-status {
|
||||
@apply mb-3 min-h-6 text-center text-sm;
|
||||
color: rgba(248, 250, 252, 0.86);
|
||||
}
|
||||
|
||||
.totp-scan-actions {
|
||||
@apply grid gap-2;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.totp-scan-actions .dialog-btn {
|
||||
@apply mt-0 text-base;
|
||||
}
|
||||
|
||||
.totp-codes-page {
|
||||
@apply flex min-h-full flex-col;
|
||||
}
|
||||
|
||||
Vendored
+16
@@ -8,3 +8,19 @@ declare module 'qrcode-generator' {
|
||||
}
|
||||
export default function qrcode(typeNumber: number, errorCorrectionLevel: 'L' | 'M' | 'Q' | 'H'): QrCode;
|
||||
}
|
||||
|
||||
interface BarcodeDetectorResult {
|
||||
rawValue: string;
|
||||
}
|
||||
|
||||
interface BarcodeDetector {
|
||||
detect(image: ImageBitmapSource): Promise<BarcodeDetectorResult[]>;
|
||||
}
|
||||
|
||||
interface BarcodeDetectorConstructor {
|
||||
new (options?: { formats?: string[] }): BarcodeDetector;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
BarcodeDetector?: BarcodeDetectorConstructor;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user