React スキャナー
ブラウザーで識別子を取得し、DUST API を呼び出すお客様のバックエンドへ渡す小さな React コンポーネントです。次の 3 つのモードに対応しています。
- DUST — DUST スキャン画像を Identifier エンドポイントへ送信して解決します。
- その他 — デバイスのカメラを使用して、QR、バーコード、Data Matrix の識別子を読み取ります。
- 手動 — テキストフィールドから QR、バーコード、Data Matrix、NFC の識別子を送信します。
始める前に
- ロール
- スキャン先のチームに所属するサービスアカウント
- デバイス
- DUST 撮影には DUST 光学アクセサリー、QR、バーコード、Data Matrix にはデバイスのカメラのみ
認証情報の保管場所
Section titled “認証情報の保管場所”この統合は 2 か所に配置する 2 つのファイルで構成されており、この分離自体がセキュリティ設計の要です。
| ファイル | 実行場所 | DUST 認証情報を保持するか |
|---|---|---|
DustScanner.tsx | ブラウザー | いいえ。認証情報を受け取る prop もありません。 |
お客様の /api/dust-scan ハンドラー | お客様のサーバー | はい。Bearer トークンを発行し、コンテキストを選択して、/api/v1/tags/* を呼び出します。 |
インストール
Section titled “インストール”bun add barcode-detectorこのコンポーネントは、アプリに React がすでに導入されていることを前提とします。
bun add react react-dom barcode-detectorDustScanner.tsx と scanner.css をプロジェクトに保存します。
その他モードは、デスクトップとスマートフォンのどちらのブラウザーでも動作します。既定では背面カメラを開き、ユーザー側を向いたカメラの場合にのみ映像を左右反転するため、映像はデバイスを動かした方向に動きます。押すボタンはありません。連続して読み取り、複数のコードが映っている場合は中央に最も近いものを選びます。QR、Data Matrix、Code 128、EAN-13 を読み取れる組み込みのバーコード検出機能がブラウザーにある場合(Android と macOS の Chrome)、コンポーネントはそれを使用します。それ以外の環境では、Safari も含めて、ZXing の WebAssembly ビルドである barcode-detector にフォールバックします。ブラウザーがカメラを開くのは HTTPS のページに限られます。
1. サーバーハンドラー
Section titled “1. サーバーハンドラー”最初にこれを作成してください。このハンドラーがなければ、コンポーネントは機能しません。コンポーネントが送信した multipart body を受信し、お客様の認可を適用して、DUST 認証情報とコンテキストヘッダーを追加し、対応する Identifier エンドポイントへ転送します。
お客様が用意するものは 2 つです。サインイン中のユーザーについてアプリがすでに保持している情報を表す Session と、認証で説明している、キャッシュされた API キー交換を行う getDustToken() です。
const APID_URL = "https://apid.dustid.io";
const ENDPOINTS = { identify: "/api/v1/tags/identify", bind: "/api/v1/tags/bind", verify: "/api/v1/tags/verify",} as const;
type Operation = keyof typeof ENDPOINTS;
/** Your app's session, however you already model it. */type Session = { mayScan: boolean; organizationId: string; teamId?: string; allowedTeamIds: string[];};
const fail = (code: string, message: string, status: number) => Response.json({ code, message, status }, { status });
export async function handleDustScan(request: Request, session: Session) { const form = await request.formData(); const operation = String(form.get("operation") ?? ""); if (!Object.hasOwn(ENDPOINTS, operation)) return fail("INVALID_REQUEST", "Unknown operation", 400);
// YOUR rules, not DUST's: may this user scan, and into which Teams? if (!session.mayScan) return fail("FORBIDDEN", "Not permitted", 403);
// Never trust the browser's scope. Replace whatever it sent. form.delete("operation"); if (operation === "identify") { form.set("searchTeamIds", JSON.stringify(session.allowedTeamIds)); }
const response = await fetch(`${APID_URL}${ENDPOINTS[operation as Operation]}`, { method: "POST", headers: { Authorization: `Bearer ${await getDustToken()}`, // server-held credential "Dust-Ctx-Org-Id": session.organizationId, ...(session.teamId ? { "Dust-Ctx-Team-Id": session.teamId } : {}), }, body: form, });
// Pass the DUST body and status straight through: the component branches on // `code`, and a 404 IDENTIFIER_NOT_FOUND is an ANSWER, not a proxy failure. return new Response(await response.text(), { status: response.status, headers: { "Content-Type": "application/json" }, });}DUST のステータスと body は、そのまま転送してください。すべての失敗を 500 にまとめると、ブラウザーが「一致なし」と「再試行」を区別するために必要な情報が失われます。
2. スキャナーをレンダリングする
Section titled “2. スキャナーをレンダリングする”import { DustScanner } from "./DustScanner";import "./scanner.css";
export function IdentifyPage() { return ( <DustScanner endpoint="/api/dust-scan" operation="identify" onResult={(result) => console.log("scan result", result)} onError={(error) => console.warn(error.code, error.message, error.scanId)} /> );}取得するトークンはなく、トークンを待つ読み込みゲートもありません。endpoint はお客様自身の Origin 上のパスです。コンポーネントは credentials: "same-origin" を指定してそこへ POST するため、既存のセッション Cookie によって呼び出しが認証されます。
searchTeamIds は、ページ側で正当に独自の範囲を選択する場合に使用できる prop です。ただし、上記のサーバーハンドラーはその値を上書きします。ブラウザーから指定された検索範囲は要求にすぎず、認可ではありません。
スレッドにバインドする
Section titled “スレッドにバインドする”<DustScanner endpoint="/api/dust-scan" operation="bind" threadId={threadId} tagDescription="Receiving scan" onResult={setResult}/>識別子を検証する
Section titled “識別子を検証する”検証には、候補となる識別子がそのスレッドにすでにバインドされている必要があります。候補は ID 文字列ではなく、tagId と tagType を持つオブジェクトとして指定します。サーバーでスレッドから候補を読み取り(GET /api/v1/threads/{thread_id} は thread.tags に候補を返します)、ブラウザー側へ渡します。
<DustScanner endpoint="/api/dust-scan" operation="verify" threadId={threadId} verifyTags={dustIdentifiers} // [{ tagId: "8f2b…", tagType: "DUST" }] onResult={setResult}/>結果を処理する
Section titled “結果を処理する”onError は、DUST の code、status、detail、scanId を保持する ScanSubmitError を受け取ります。code に応じて分岐してください。これらの一部は障害ではなく、スキャンの結果です。
code | 意味 | オペレーターに表示する内容 |
|---|---|---|
IDENTIFIER_NOT_FOUND | 検索対象のチームで何も一致しなかった | 「一致なし」— エラーバナーではありません |
SCAN_SEARCH_INCOMPLETE | 検索を完了できなかった | 「再試行してください」— アイテムが登録されている可能性は十分にあります |
SCAN_LOW_KEYPOINTS / SCAN_NO_KEYPOINTS | 撮影結果を使用できなかった | 「もう一度スキャンしてください」 |
IDENTIFIER_ALREADY_BOUND | すでに別のスレッドにバインド済みのため、バインドが拒否された | 競合を表示します |
IDENTIFIER_VERIFY_FAILED | 単一候補の検証で一致しなかった | 「一致しませんでした」 |
error.scanId は、失敗した撮影のスキャン受領 ID です。ログに記録し、結果に異議がある場合はサポートへ伝えてください。完全な一覧はエラーとスキャン結果にあります。
コンポーネントのソース
Section titled “コンポーネントのソース”DustScanner.tsx — ブラウザーで実行。クリックして展開
/** * RUNS IN THE BROWSER. * * No DUST credential appears in this file, and none should: a DUST bearer token * carries the Service Account's full access and cannot be narrowed for a browser * session. The component captures an Identifier and posts it to an endpoint on * YOUR OWN origin; your server adds the DUST credential and context headers and * calls /api/v1/tags/*. See the server handler in the React Scanner guide: * https://docs.dustid.io/integrate/react-scanner/ * * The "Other" mode reads QR codes, barcodes and Data Matrix live from the device * camera with the Barcode Detection API: the browser's own BarcodeDetector where * it reads the formats below, otherwise the `barcode-detector` ponyfill * (zxing-cpp compiled to WebAssembly), which works in every browser, Safari * included. */import { useCallback, useEffect, useRef, useState } from "react";
type ScannerMode = "dust" | "other" | "manual";type ScanOperation = "identify" | "bind" | "verify";type NonDustTagType = "QR" | "BAR_CODE" | "DATA_MATRIX" | "NFC";type TagType = "DUST" | NonDustTagType;
type VerifyTag = { tagId: string; tagType: TagType;};
type DustScannerProps = { /** * An endpoint on your own origin that forwards the capture to the DUST API. * It receives the same multipart fields the DUST API takes, plus `operation`. */ endpoint: string; operation: ScanOperation; threadId?: string; /** Verify candidates: the identifiers already bound to `threadId`. */ verifyTags?: VerifyTag[]; /** Identify scope: the Team ids to search. Your server may override this. */ searchTeamIds?: string[]; tagDescription?: string; onResult?: (result: unknown) => void; onError?: (error: ScanSubmitError) => void;};
/** * A failed submission. `code` is the DUST error code when your endpoint passes * the DUST error body through — branch on it, never on `message`. * * Note that IDENTIFIER_NOT_FOUND is an ANSWER ("nothing matched"), not a fault, * and SCAN_SEARCH_INCOMPLETE means the search could not be completed and should * be retried. See https://docs.dustid.io/api/errors/ */export class ScanSubmitError extends Error { readonly status: number; readonly code?: string; readonly detail?: Record<string, unknown>; /** The Scan Receipt id, when the failure stored a scan. Worth logging. */ readonly scanId?: string;
constructor( message: string, init: { status: number; code?: string; detail?: Record<string, unknown> }, ) { super(message); this.name = "ScanSubmitError"; this.status = init.status; this.code = init.code; this.detail = init.detail; const scan = init.detail?.scan as { scanId?: string } | undefined; this.scanId = scan?.scanId; }}
const OUTCOME_MESSAGES: Record<string, string> = { IDENTIFIER_NOT_FOUND: "No match in the searched Teams.", IDENTIFIER_NOT_BOUND: "That Identifier is not bound to a Thread yet.", SCAN_SEARCH_INCOMPLETE: "The search could not be completed. Try again.", SCAN_BACKEND_UNAVAILABLE: "Scanning is temporarily unavailable. Try again.", SCAN_LOW_KEYPOINTS: "The capture was too indistinct. Scan again.", SCAN_NO_KEYPOINTS: "No usable detail in the capture. Scan again.", SCAN_IDENTICAL_SCAN: "That is the same image as the last capture. Scan again.", SCAN_AMBIGUOUS_MATCH: "More than one enrolled DUST matched. Contact support.", IDENTIFIER_ALREADY_BOUND: "That Identifier is already bound to another Thread.", IDENTIFIER_VERIFY_FAILED: "The capture did not match this Thread.",};
type Detection = { tagType: NonDustTagType; value: string; format?: string;};
/** * Symbologies the camera asks the decoder for, as Barcode Detection API format * names. Every entry needs a mapping in FORMAT_TO_TAG, or its reads are ignored. */const CAMERA_BARCODE_FORMATS = [ "qr_code", "micro_qr_code", "data_matrix", "aztec", "maxi_code", "pdf417", "code_128", "code_39", "code_93", "codabar", "itf", "ean_13", "ean_8", "upc_a", "upc_e", "databar", "databar_expanded",] as const;
/** * A browser's own BarcodeDetector is only used when it reads at least these; * with fewer, the ponyfill (which reads every format above) is the better engine. */const NATIVE_REQUIRED_FORMATS = ["qr_code", "data_matrix", "code_128", "ean_13"];
/** * Keyed by the format name the decoder reports (the native detector and the * ponyfill use the same names, and the ponyfill reports some variants). `format` * is sent as bind metadata, so several variants collapse onto one value. */const QR_CODE = { tagType: "QR", format: "qrcode" } as const;const AZTEC = { tagType: "DATA_MATRIX", format: "azteccode" } as const;const CODE_39 = { tagType: "BAR_CODE", format: "code39" } as const;const ITF = { tagType: "BAR_CODE", format: "interleaved2of5" } as const;const PDF_417 = { tagType: "BAR_CODE", format: "pdf417" } as const;const DATABAR = { tagType: "BAR_CODE", format: "databaromni" } as const;
const FORMAT_TO_TAG: Record<string, { tagType: NonDustTagType; format: string }> = { qr_code: QR_CODE, qr_code_model_1: QR_CODE, qr_code_model_2: QR_CODE, micro_qr_code: QR_CODE, rm_qr_code: QR_CODE, data_matrix: { tagType: "DATA_MATRIX", format: "datamatrix" }, aztec: AZTEC, aztec_code: AZTEC, aztec_rune: AZTEC, maxi_code: { tagType: "DATA_MATRIX", format: "maxicode" }, ean_13: { tagType: "BAR_CODE", format: "ean13" }, ean_8: { tagType: "BAR_CODE", format: "ean8" }, upc_a: { tagType: "BAR_CODE", format: "upca" }, upc_e: { tagType: "BAR_CODE", format: "upce" }, code_39: CODE_39, code_39_standard: CODE_39, code_39_extended: CODE_39, code_93: { tagType: "BAR_CODE", format: "code93" }, code_128: { tagType: "BAR_CODE", format: "code128" }, itf: ITF, itf_14: ITF, codabar: { tagType: "BAR_CODE", format: "rationalizedCodabar" }, pdf417: PDF_417, compact_pdf417: PDF_417, micro_pdf417: PDF_417, databar: DATABAR, databar_omni: DATABAR, databar_expanded: { tagType: "BAR_CODE", format: "databarexpanded" },};
// Manual entry can't know the concrete symbology, so choose a canonical// format from the same vocabulary the camera path uses.const MANUAL_FORMATS: Record<NonDustTagType, string> = { QR: FORMAT_TO_TAG.qr_code.format, BAR_CODE: FORMAT_TO_TAG.code_128.format, DATA_MATRIX: FORMAT_TO_TAG.data_matrix.format, NFC: "nfc",};
function normalizeIdentifier(value: string) { const trimmed = value.trim(); if (!trimmed || trimmed.length > 2000) return null;
try { const parsed = JSON.parse(trimmed); if (parsed && typeof parsed === "object" && typeof parsed.id === "string") { return parsed.id.trim() || null; } } catch { // Plain text identifiers are valid. }
return trimmed;}
function detectNonDust(barcode: { rawValue: string; format: string }): Detection | null { const value = normalizeIdentifier(barcode.rawValue); if (!value || !Object.hasOwn(FORMAT_TO_TAG, barcode.format)) return null;
const mapped = FORMAT_TO_TAG[barcode.format]; return { tagType: mapped.tagType, value, format: mapped.format, };}
// --- Barcode decoder --------------------------------------------------------
type DecodedBarcode = { rawValue: string; /** Barcode Detection API format name: `qr_code`, `data_matrix`, `code_128`, ... */ format: string; boundingBox: { x: number; y: number; width: number; height: number };};
type BarcodeReader = { detect: (source: HTMLCanvasElement) => Promise<DecodedBarcode[]>;};
// The Barcode Detection API is not in TypeScript's DOM types yet.type NativeBarcodeDetectorClass = { new (options: { formats: string[] }): { detect: (source: CanvasImageSource) => Promise<DecodedBarcode[]>; }; getSupportedFormats: () => Promise<string[]>;};
/** * Where the ponyfill loads `zxing_reader.wasm` (about 1 MB) from. Left alone, * it fetches the file from the jsDelivr CDN at runtime. Serve it from your own * origin instead: copy `node_modules/zxing-wasm/dist/reader/zxing_reader.wasm` * into your static files at this path, or let your bundler emit it, e.g. with Vite: * * import ZXING_WASM_URL from "zxing-wasm/reader/zxing_reader.wasm?url"; * * Use the zxing-wasm version that barcode-detector itself depends on. */const ZXING_WASM_URL = "/zxing_reader.wasm";
let readerPromise: Promise<BarcodeReader> | null = null;
/** One decoder per page, created on first use. */function getBarcodeReader(): Promise<BarcodeReader> { readerPromise ??= createBarcodeReader().catch((error: unknown) => { // A failed WebAssembly fetch (flaky network) must be retryable, not cached. readerPromise = null; throw error; }); return readerPromise;}
async function createBarcodeReader(): Promise<BarcodeReader> { const NativeBarcodeDetector = (globalThis as { BarcodeDetector?: NativeBarcodeDetectorClass }) .BarcodeDetector;
// Chrome on Android and macOS: the platform's own, hardware-accelerated detector. if (NativeBarcodeDetector) { try { const supported = new Set(await NativeBarcodeDetector.getSupportedFormats()); if (NATIVE_REQUIRED_FORMATS.every((format) => supported.has(format))) { const detector = new NativeBarcodeDetector({ formats: CAMERA_BARCODE_FORMATS.filter((format) => supported.has(format)), }); return { detect: (source) => detector.detect(source) }; } } catch { // Some Chromium builds expose the class but have no platform backend. } }
// Everywhere else (Safari on every iOS browser, Firefox): the ponyfill. // Imported on demand, so browsers with a native detector never download it. const { BarcodeDetector, prepareZXingModule } = await import("barcode-detector/ponyfill"); // Awaiting the load surfaces a failed fetch here, not on every frame. await prepareZXingModule({ overrides: { locateFile: (path: string, prefix: string) => path.endsWith(".wasm") ? ZXING_WASM_URL : prefix + path, }, fireImmediately: true, }); const detector = new BarcodeDetector({ formats: [...CAMERA_BARCODE_FORMATS] }); return { detect: (source) => detector.detect(source) };}
// --- Camera helpers ---------------------------------------------------------
/** About 8 decodes a second: responsive, and leaves a phone's CPU for the preview. */const SCAN_INTERVAL_MS = 120;/** Decode at native resolution up to this long edge; small Data Matrix needs the pixels. */const MAX_DECODE_SIDE = 1600;/** How long a code that was just submitted is ignored, so one label isn't sent twice. */const REPEAT_HOLD_MS = 2000;
function stopStream(stream: MediaStream | null) { for (const track of stream?.getTracks() ?? []) track.stop();}
/** * Mirror only a camera that faces the user; that reads like a mirror. A rear * camera shown mirrored moves the picture against the phone, which makes aiming * impossible. Desktop webcams usually report no facingMode and face the user. */function shouldMirrorCamera(facingMode: string | undefined, label: string | undefined) { if (facingMode === "user") return true; if (facingMode) return false; return !/\b(back|rear|environment)\b/i.test(label ?? "");}
/** Continuous autofocus where the browser exposes it (Chrome on Android); a no-op elsewhere. */function applyContinuousFocus(track: MediaStreamTrack) { const capabilities = track.getCapabilities?.() as { focusMode?: string[] } | undefined; if (!capabilities?.focusMode?.includes("continuous")) return; track .applyConstraints({ advanced: [{ focusMode: "continuous" } as MediaTrackConstraintSet] }) .catch(() => undefined);}
/** * The part of the video frame the viewfinder actually shows. The <video> is * drawn with `object-fit: cover`, so the visible region is a centred crop; * decoding exactly that region means "in the box on screen" is what gets read. */function getObjectCoverSourceRect(video: HTMLVideoElement) { const { videoWidth, videoHeight, clientWidth, clientHeight } = video; if (clientWidth <= 0 || clientHeight <= 0) { return { x: 0, y: 0, width: videoWidth, height: videoHeight }; }
const viewAspect = clientWidth / clientHeight; if (videoWidth / videoHeight > viewAspect) { const width = videoHeight * viewAspect; return { x: (videoWidth - width) / 2, y: 0, width, height: videoHeight }; }
const height = videoWidth / viewAspect; return { x: 0, y: (videoHeight - height) / 2, width: videoWidth, height };}
/** With several codes in view, the one nearest the centre is the one being aimed at. */function pickCentralBarcode(barcodes: DecodedBarcode[], width: number, height: number) { let best: DecodedBarcode | null = null; let bestDistance = Number.POSITIVE_INFINITY; for (const barcode of barcodes) { const box = barcode.boundingBox; const distance = Math.hypot( box.x + box.width / 2 - width / 2, box.y + box.height / 2 - height / 2, ); if (distance < bestDistance) { best = barcode; bestDistance = distance; } } return best;}
function cameraErrorMessage(error: unknown, fallback: string) { if (error instanceof DOMException) { if (error.name === "NotAllowedError") { return "Camera access was blocked. Allow the camera for this site and try again."; } if (error.name === "NotFoundError" || error.name === "OverconstrainedError") { return "No camera was found."; } if (error.name === "NotReadableError") return "The camera is in use by another app."; } return fallback;}
function appendJson(form: FormData, key: string, value: unknown) { if (value === undefined) return; form.set(key, typeof value === "string" ? value : JSON.stringify(value));}
export function DustScanner({ endpoint, operation, threadId, verifyTags = [], searchTeamIds, tagDescription, onResult, onError,}: DustScannerProps) { const [mode, setMode] = useState<ScannerMode>("dust"); const [manualType, setManualType] = useState<NonDustTagType>("QR"); const [manualValue, setManualValue] = useState(""); const [busy, setBusy] = useState(false); const [message, setMessage] = useState<string | null>(null); const videoRef = useRef<HTMLVideoElement | null>(null); const [mirrored, setMirrored] = useState(false); const [cameraRestart, setCameraRestart] = useState(0); const lastDetectionRef = useRef<string | null>(null); const cameraScanInFlightRef = useRef(false);
// Posts to YOUR endpoint, with your own session cookie. The DUST credential // lives on the other side of this call and never enters the browser. const submitForm = useCallback( async (form: FormData) => { const response = await fetch(endpoint, { method: "POST", body: form, credentials: "same-origin", });
const text = await response.text(); let data: Record<string, unknown> | null = null; try { data = text ? (JSON.parse(text) as Record<string, unknown>) : null; } catch { // A non-JSON body (an HTML error page, a proxy timeout) is still a failure. }
if (!response.ok) { const code = typeof data?.code === "string" ? data.code : undefined; const message = (code && OUTCOME_MESSAGES[code]) ?? (typeof data?.message === "string" ? data.message : null) ?? `Scan request failed with ${response.status}`; throw new ScanSubmitError(message, { status: response.status, code, detail: data?.detail as Record<string, unknown> | undefined, }); }
return data; }, [endpoint], );
const runScan = useCallback( async (scan: { tagType: TagType; data: string | Blob; metadata?: Record<string, unknown> }) => { setBusy(true); setMessage(null);
try { const form = new FormData(); // Your endpoint switches on this to pick the DUST operation. form.set("operation", operation); form.set("tagType", scan.tagType); form.set("data", scan.data);
if (operation === "identify") { // JSON array of Team UUIDs. Omitted entirely when not supplied, so the // server's own default scope applies. appendJson(form, "searchTeamIds", searchTeamIds); const result = await submitForm(form); onResult?.(result); setMessage("Identify complete."); return; }
if (!threadId) { throw new ScanSubmitError("threadId is required for bind and verify operations.", { status: 0, }); }
form.set("threadId", threadId);
if (operation === "bind") { if (tagDescription) form.set("tagDescription", tagDescription);
if (scan.tagType === "DUST") { // enrollmentSessionId is OPTIONAL. A fresh id per capture is only // meaningful if each capture really is its own session; share one id // across a batch when the captures belong together. appendJson(form, "options", { enrollmentSessionId: crypto.randomUUID() }); } else if (scan.metadata) { appendJson(form, "options", { metadata: scan.metadata }); }
const result = await submitForm(form); onResult?.(result); setMessage("Bind complete."); return; }
if (verifyTags.length === 0) { throw new ScanSubmitError("verifyTags is required for verify operations.", { status: 0 }); }
// `tags` is an array of OBJECTS ({ tagId, tagType }), not of id strings, // and it is required on every verify. appendJson(form, "tags", verifyTags); const result = await submitForm(form); // With two or more candidates the DUST API answers 200 with // `success: false` when nothing matched — read `success`, not the status. const failedBulk = verifyTags.length > 1 && result !== null && typeof result === "object" && (result as { success?: boolean }).success === false; onResult?.(result); setMessage(failedBulk ? "No identifier on this Thread matched." : "Verify complete."); } catch (error) { const err = error instanceof ScanSubmitError ? error : new ScanSubmitError( error instanceof Error ? error.message : "Unknown scanner error", { status: 0 }, ); setMessage(err.message); onError?.(err); } finally { setBusy(false); } }, [onError, onResult, operation, searchTeamIds, submitForm, tagDescription, threadId, verifyTags], );
const runScanRef = useRef(runScan); runScanRef.current = runScan; const onErrorRef = useRef(onError); onErrorRef.current = onError;
const handleDustFile = useCallback( async (file: File | null) => { if (!file) return; await runScan({ tagType: "DUST", data: file }); }, [runScan], );
const handleManualSubmit = useCallback(async () => { const value = normalizeIdentifier(manualValue); if (!value) { setMessage("Enter an identifier value."); return; }
await runScan({ tagType: manualType, data: value, metadata: { format: MANUAL_FORMATS[manualType] }, }); setManualValue(""); }, [manualType, manualValue, runScan]);
// Called for every frame a code is read in, so it de-duplicates: a code just // submitted (matched or not) is held off for REPEAT_HOLD_MS after it finishes, // and two submissions never overlap. const handleBarcode = useCallback(async (barcode: DecodedBarcode) => { const detection = detectNonDust(barcode); if (!detection) return;
const key = `${detection.tagType}:${detection.format}:${detection.value}`; if (cameraScanInFlightRef.current || lastDetectionRef.current === key) return; lastDetectionRef.current = key; cameraScanInFlightRef.current = true;
try { await runScanRef.current({ tagType: detection.tagType, data: detection.value, metadata: detection.format ? { format: detection.format } : undefined, }); } finally { cameraScanInFlightRef.current = false; }
window.setTimeout(() => { if (lastDetectionRef.current === key) lastDetectionRef.current = null; }, REPEAT_HOLD_MS); }, []);
// The live camera: rear camera by default, read continuously, no shutter button. useEffect(() => { if (mode !== "other") return; // Reopening the camera is keyed on this counter; see handleVisibility. void cameraRestart; const video = videoRef.current; if (!video) return;
let cancelled = false; let stream: MediaStream | null = null; let timer: number | undefined;
const fail = (error: unknown, fallback: string) => { if (cancelled) return; const message = cameraErrorMessage(error, fallback); setMessage(message); onErrorRef.current?.(new ScanSubmitError(message, { status: 0 })); };
// iOS ends the camera track while the page is in the background; reopen it on return. const handleVisibility = () => { if (document.visibilityState !== "visible") return; if (stream?.getVideoTracks().some((track) => track.readyState === "ended")) { setCameraRestart((count) => count + 1); } };
const start = async () => { if (typeof navigator.mediaDevices?.getUserMedia !== "function") { fail(null, "This browser cannot open the camera. Camera access needs HTTPS."); return; }
// Start loading the decoder while the camera permission prompt is up. const pendingReader = getBarcodeReader(); pendingReader.catch(() => undefined);
try { stream = await navigator.mediaDevices.getUserMedia({ audio: false, video: { // The rear camera on phones and tablets; a desktop's webcam otherwise. facingMode: { ideal: "environment" }, // Enough pixels for a small Data Matrix at arm's length. Without a // request, Safari delivers 640x480. width: { ideal: 1920 }, height: { ideal: 1080 }, }, }); } catch (error) { fail(error, "Could not start the camera."); return; }
if (cancelled) { stopStream(stream); return; }
const track = stream.getVideoTracks()[0]; setMirrored(shouldMirrorCamera(track?.getSettings().facingMode, track?.label)); if (track) applyContinuousFocus(track); video.srcObject = stream; await video.play().catch(() => undefined);
let reader: BarcodeReader; try { reader = await pendingReader; } catch (error) { fail(error, "Could not load the barcode decoder."); return; }
const canvas = document.createElement("canvas"); const context = canvas.getContext("2d", { willReadFrequently: true }); if (!context) { fail(null, "Could not load the barcode decoder."); return; }
const tick = async () => { if (cancelled) return;
if ( document.visibilityState === "visible" && video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && video.videoWidth > 0 ) { const visible = getObjectCoverSourceRect(video); const scale = Math.min(1, MAX_DECODE_SIDE / Math.max(visible.width, visible.height)); const width = Math.max(1, Math.round(visible.width * scale)); const height = Math.max(1, Math.round(visible.height * scale)); if (canvas.width !== width) canvas.width = width; if (canvas.height !== height) canvas.height = height; context.drawImage( video, visible.x, visible.y, visible.width, visible.height, 0, 0, width, height, );
try { const barcode = pickCentralBarcode(await reader.detect(canvas), width, height); if (barcode && !cancelled) void handleBarcode(barcode); } catch { // A frame the decoder rejects is skipped; the next one is tried. } }
if (!cancelled) timer = window.setTimeout(tick, SCAN_INTERVAL_MS); };
void tick(); };
document.addEventListener("visibilitychange", handleVisibility); void start();
return () => { cancelled = true; window.clearTimeout(timer); document.removeEventListener("visibilitychange", handleVisibility); stopStream(stream); video.srcObject = null; }; }, [mode, cameraRestart, handleBarcode]);
return ( <section className="dust-scanner"> <div className="dust-scanner__modes" role="group" aria-label="Scanner mode"> {(["dust", "other", "manual"] as const).map((nextMode) => ( <button key={nextMode} type="button" aria-pressed={mode === nextMode} onClick={() => setMode(nextMode)} > {nextMode === "dust" ? "DUST" : nextMode === "other" ? "Other" : "Manual"} </button> ))} </div>
{mode === "dust" ? ( <label className="dust-scanner__dropzone"> <span>DUST image scan</span> <input disabled={busy} type="file" accept="image/*" capture="environment" onChange={(event) => void handleDustFile(event.currentTarget.files?.[0] ?? null)} /> </label> ) : null}
{mode === "other" ? ( <div className="dust-scanner__camera"> <video ref={videoRef} muted playsInline autoPlay disablePictureInPicture // The decode loop reads exactly what `object-fit: cover` shows. style={{ objectFit: "cover", transform: mirrored ? "scaleX(-1)" : undefined }} /> <p>Point the camera at a QR code, barcode or Data Matrix.</p> </div> ) : null}
{mode === "manual" ? ( <div className="dust-scanner__manual"> <select value={manualType} disabled={busy} aria-label="Identifier type" onChange={(event) => setManualType(event.currentTarget.value as NonDustTagType)} > <option value="QR">QR</option> <option value="BAR_CODE">Barcode</option> <option value="DATA_MATRIX">Data Matrix</option> <option value="NFC">NFC</option> </select> <input data-manual-identifier-input aria-label="Identifier value" value={manualValue} disabled={busy} placeholder="Identifier value" onChange={(event) => setManualValue(event.currentTarget.value)} onKeyDown={(event) => { if (event.key === "Enter") void handleManualSubmit(); }} /> <button type="button" disabled={busy} onClick={() => void handleManualSubmit()}> Submit </button> </div> ) : null}
{message ? <p role="status">{message}</p> : null} {busy ? <p>Processing scan...</p> : null} </section> );}scanner.css — クリックして展開
.dust-scanner { display: grid; gap: 1rem; max-width: 42rem;}
.dust-scanner__modes { display: inline-flex; width: fit-content; gap: 0.25rem; border: 1px solid #d4d4d8; border-radius: 8px; padding: 0.25rem;}
.dust-scanner__modes button { border: 0; border-radius: 6px; background: transparent; padding: 0.45rem 0.75rem; cursor: pointer;}
.dust-scanner__modes button[aria-pressed="true"] { background: #111827; color: white;}
.dust-scanner__dropzone,.dust-scanner__camera,.dust-scanner__manual { border: 1px solid #d4d4d8; border-radius: 8px; padding: 1rem;}
.dust-scanner__dropzone { display: grid; gap: 0.75rem;}
.dust-scanner__camera { display: grid; gap: 0.75rem;}
.dust-scanner__camera video { display: block; width: 100%; aspect-ratio: 4 / 3; border-radius: 6px; background: #000;}
.dust-scanner__camera p { margin: 0;}
.dust-scanner__manual { display: flex; flex-wrap: wrap; gap: 0.5rem;}
.dust-scanner__manual input { min-width: min(100%, 18rem); flex: 1;}デバイスのカメラを直接使用するのではなく、DUST Go モバイルブラウザー内でアプリを実行する場合は、DUST Go と統合するをご覧ください。