feat(ui): extend QR scanner hook with lifecycle control and flash API

Add cornerPoints, hasFlash/toggleFlash, and manual start/stop/pause
to useQrScanner. Change enabled default to false for click-to-start.
Add scoped CSS animations for scanner bracket breathing and error shake.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-02-05 08:22:42 +08:00
parent b74a5ea1a7
commit d0681ef46d
2 changed files with 159 additions and 55 deletions

View file

@ -0,0 +1,29 @@
/* Scanner corner bracket breathing pulse */
@keyframes scan-breathe {
0%, 100% { opacity: 0.5; transform: scale(1); }
50% { opacity: 1; transform: scale(1.05); }
}
/* Error shake */
@keyframes scan-shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-4px); }
40% { transform: translateX(4px); }
60% { transform: translateX(-3px); }
80% { transform: translateX(2px); }
}
@utility animate-scan-breathe {
animation: scan-breathe 2s ease-in-out infinite;
}
@utility animate-scan-shake {
animation: scan-shake 0.4s ease-in-out;
}
@media (prefers-reduced-motion: reduce) {
.animate-scan-breathe,
.animate-scan-shake {
animation: none;
}
}

View file

@ -3,6 +3,11 @@
import { useRef, useState, useEffect, useCallback } from "react"
import type QrScannerLib from "qr-scanner"
export interface Point {
x: number
y: number
}
export interface UseQrScannerOptions {
onScan: (data: string) => void
onError?: (error: string) => void
@ -14,25 +19,34 @@ export interface UseQrScannerResult {
isScanning: boolean
error: string | null
hasCamera: boolean
cornerPoints: Point[] | null
hasFlash: boolean
toggleFlash: () => Promise<void>
start: () => Promise<void>
stop: () => void
pause: () => void
}
/**
* Hook wrapping qr-scanner lifecycle.
*
* - Dynamically imports qr-scanner (keeps it out of SSR bundles)
* - Creates/destroys scanner instance based on `enabled`
* - Creates/destroys scanner instance based on `enabled` or manual start/stop
* - Exposes cornerPoints from scan results, flash control, and lifecycle methods
* - Releases camera stream on cleanup
*/
export function useQrScanner({
onScan,
onError,
enabled = true,
enabled = false,
}: UseQrScannerOptions): UseQrScannerResult {
const videoRef = useRef<HTMLVideoElement | null>(null)
const scannerRef = useRef<QrScannerLib | null>(null)
const [isScanning, setIsScanning] = useState(false)
const [error, setError] = useState<string | null>(null)
const [hasCamera, setHasCamera] = useState(true)
const [cornerPoints, setCornerPoints] = useState<Point[] | null>(null)
const [hasFlash, setHasFlash] = useState(false)
// Stable callback refs to avoid re-creating scanner on every render
const onScanRef = useRef(onScan)
@ -52,67 +66,128 @@ export function useQrScanner({
return () => { cancelled = true }
}, [])
// Start/stop scanner based on `enabled` and video element
const createScanner = useCallback(async () => {
if (!videoRef.current || !hasCamera) return
const mod = await import("qr-scanner")
const QrScanner = mod.default
// Destroy previous instance if any
if (scannerRef.current) {
scannerRef.current.stop()
scannerRef.current.destroy()
scannerRef.current = null
}
const scanner = new QrScanner(
videoRef.current,
(result) => {
const points = result.cornerPoints as Point[] | undefined
setCornerPoints(points?.length ? points : null)
onScanRef.current(result.data)
},
{
preferredCamera: "environment",
maxScansPerSecond: 5,
returnDetailedScanResult: true,
highlightScanRegion: false,
highlightCodeOutline: false,
onDecodeError: (err) => {
// "No QR code found" fires every frame — ignore it
if (typeof err === "string" && err.includes("No QR code found")) return
console.warn("[QrScanner] decode error:", err)
},
},
)
scannerRef.current = scanner
return scanner
}, [hasCamera])
const start = useCallback(async () => {
setError(null)
setCornerPoints(null)
try {
let scanner = scannerRef.current
if (!scanner) {
scanner = (await createScanner()) ?? null
if (!scanner) return
}
await scanner.start()
setIsScanning(true)
// Check flash availability after camera starts
try {
const flash = await scanner.hasFlash()
setHasFlash(flash)
} catch {
setHasFlash(false)
}
} catch (err) {
const msg = (err as Error).message || "Camera access failed"
setError(msg)
setIsScanning(false)
onErrorRef.current?.(msg)
}
}, [createScanner])
const stop = useCallback(() => {
if (scannerRef.current) {
scannerRef.current.stop()
scannerRef.current.destroy()
scannerRef.current = null
}
setIsScanning(false)
setCornerPoints(null)
setHasFlash(false)
}, [])
const pause = useCallback(() => {
scannerRef.current?.pause()
setIsScanning(false)
}, [])
const toggleFlash = useCallback(async () => {
if (!scannerRef.current) return
try {
await scannerRef.current.toggleFlash()
} catch {
// Flash not supported or other error — silently ignore
}
}, [])
// Auto-start/stop based on `enabled` prop (backwards compatible)
useEffect(() => {
if (!enabled || !videoRef.current || !hasCamera) return
let destroyed = false
const video = videoRef.current
import("qr-scanner").then((mod) => {
if (destroyed) return
const QrScanner = mod.default
const scanner = new QrScanner(
video,
(result) => {
console.log("[QrScanner] scanned:", result.data)
onScanRef.current(result.data)
},
{
preferredCamera: "environment",
maxScansPerSecond: 5,
returnDetailedScanResult: true,
highlightScanRegion: false,
highlightCodeOutline: false,
onDecodeError: (err) => {
// "No QR code found" fires every frame — ignore it
if (typeof err === "string" && err.includes("No QR code found")) return
console.warn("[QrScanner] decode error:", err)
},
},
)
scannerRef.current = scanner
scanner
.start()
.then(() => {
if (!destroyed) {
console.log("[QrScanner] started successfully")
setIsScanning(true)
setError(null)
}
})
.catch((err: Error) => {
if (destroyed) return
const msg = err.message || "Camera access failed"
setError(msg)
setIsScanning(false)
onErrorRef.current?.(msg)
})
})
if (enabled) {
start()
} else if (!enabled && scannerRef.current) {
stop()
}
}, [enabled, start, stop])
// Cleanup on unmount
useEffect(() => {
return () => {
destroyed = true
if (scannerRef.current) {
scannerRef.current.stop()
scannerRef.current.destroy()
scannerRef.current = null
}
setIsScanning(false)
}
}, [enabled, hasCamera])
}, [])
return { videoRef, isScanning, error, hasCamera }
return {
videoRef,
isScanning,
error,
hasCamera,
cornerPoints,
hasFlash,
toggleFlash,
start,
stop,
pause,
}
}