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:
parent
b74a5ea1a7
commit
d0681ef46d
2 changed files with 159 additions and 55 deletions
29
packages/ui/src/components/qr-scanner.css
Normal file
29
packages/ui/src/components/qr-scanner.css
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue