Files
tools-app/src/components/tools/QrScanner.vue

895 lines
21 KiB
Vue

<script setup>
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
import { SwitchCamera, Trash2, Copy, Download, X, Maximize2, Minimize2 } from 'lucide-vue-next'
const error = ref('')
const facingMode = ref('environment')
const scannedCodes = ref([])
const hasMultipleCameras = ref(false)
const isFullscreen = ref(false)
const videoAspect = ref(1)
const isFront = computed(() => facingMode.value === 'user')
const wrapperRef = ref(null)
const bgCanvas = ref(null)
let bgRafId = null
// Native scanner state
const videoRef = ref(null)
let stream = null
let scanRafId = null
let barcodeDetector = null
const overlayCanvas = ref(null)
const paintDetections = (codes) => {
const canvas = overlayCanvas.value
const video = videoRef.value
if (!canvas || !video) return
const ctx = canvas.getContext('2d')
const { width, height } = canvas.getBoundingClientRect()
// Update canvas size if needed (to match CSS size)
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width
canvas.height = height
}
ctx.clearRect(0, 0, width, height)
if (!codes || codes.length === 0) return
const vw = video.videoWidth
const vh = video.videoHeight
if (!vw || !vh) return
// Calculate object-fit: cover scaling
const videoRatio = vw / vh
const canvasRatio = width / height
let drawWidth, drawHeight, startX, startY
if (canvasRatio > videoRatio) {
// Canvas is wider than video (video cropped top/bottom)
drawWidth = width
drawHeight = width / videoRatio
startX = 0
startY = (height - drawHeight) / 2
} else {
// Canvas is taller than video (video cropped left/right)
drawHeight = height
drawWidth = height * videoRatio
startY = 0
startX = (width - drawWidth) / 2
}
const scale = drawWidth / vw
const isMirrored = isFront.value
// Styles
const styles = getComputedStyle(document.documentElement)
const accent = styles.getPropertyValue('--primary-accent').trim() || '#00f2fe'
ctx.lineWidth = 4
ctx.strokeStyle = accent
ctx.fillStyle = accent
codes.forEach(code => {
const points = code.cornerPoints
if (!points || points.length < 4) return
ctx.beginPath()
const transform = (p) => {
let x = p.x * scale + startX
let y = p.y * scale + startY
if (isMirrored) {
x = width - x
}
return { x, y }
}
const p0 = transform(points[0])
ctx.moveTo(p0.x, p0.y)
for (let i = 1; i < points.length; i++) {
const p = transform(points[i])
ctx.lineTo(p.x, p.y)
}
ctx.closePath()
ctx.stroke()
// Draw corners
points.forEach(p => {
const tp = transform(p)
ctx.beginPath()
ctx.arc(tp.x, tp.y, 4, 0, Math.PI * 2)
ctx.fill()
})
})
}
const updateVideoAspect = () => {
if (videoRef.value && videoRef.value.videoWidth && videoRef.value.videoHeight) {
videoAspect.value = videoRef.value.videoWidth / videoRef.value.videoHeight
}
}
const startBackgroundLoop = () => {
const draw = () => {
const videoEl = videoRef.value
const canvas = bgCanvas.value
if (!videoEl || !canvas || videoEl.paused || videoEl.ended) {
bgRafId = requestAnimationFrame(draw)
return
}
const vw = videoEl.videoWidth || 0
const vh = videoEl.videoHeight || 0
if (!vw || !vh) {
bgRafId = requestAnimationFrame(draw)
return
}
const cw = Math.floor(window.innerWidth)
const ch = Math.floor(window.innerHeight * 0.5)
if (canvas.width !== cw || canvas.height !== ch) {
canvas.width = cw
canvas.height = ch
}
const ctx = canvas.getContext('2d')
if (ctx) {
// cover horizontally: scale by width, crop top/bottom
const scale = cw / vw
const srcH = ch / scale
const sx = 0
const sy = Math.max(0, (vh - srcH) / 2)
ctx.clearRect(0, 0, cw, ch)
ctx.drawImage(videoEl, sx, sy, vw, srcH, 0, 0, cw, ch)
}
bgRafId = requestAnimationFrame(draw)
}
if (bgRafId) cancelAnimationFrame(bgRafId)
bgRafId = requestAnimationFrame(draw)
}
const stopBackgroundLoop = () => {
if (bgRafId) {
cancelAnimationFrame(bgRafId)
bgRafId = null
}
}
const initDetector = async () => {
if (!barcodeDetector) {
if ('BarcodeDetector' in window) {
try {
// Formats are optional, but specifying qr_code might be faster
const formats = await window.BarcodeDetector.getSupportedFormats()
if (formats.includes('qr_code')) {
barcodeDetector = new window.BarcodeDetector({ formats: ['qr_code'] })
} else {
barcodeDetector = new window.BarcodeDetector()
}
} catch (e) {
// Fallback
barcodeDetector = new window.BarcodeDetector()
}
} else {
error.value = 'Barcode Detection API not supported'
}
}
}
const startScan = async () => {
stopScan()
error.value = ''
try {
await initDetector()
if (!barcodeDetector) {
error.value = 'Barcode Detector failed to initialize'
return
}
const constraints = {
video: {
facingMode: facingMode.value,
width: { ideal: 1280 },
height: { ideal: 720 }
}
}
stream = await navigator.mediaDevices.getUserMedia(constraints)
if (videoRef.value) {
videoRef.value.srcObject = stream
// Wait for metadata to play
videoRef.value.onloadedmetadata = () => {
videoRef.value.play().catch(e => console.error('Play error', e))
updateVideoAspect()
detectLoop()
startBackgroundLoop()
}
}
} catch (err) {
onError(err)
}
}
const stopScan = () => {
if (scanRafId) cancelAnimationFrame(scanRafId)
if (stream) {
stream.getTracks().forEach(t => t.stop())
stream = null
}
stopBackgroundLoop()
}
const detectLoop = async () => {
if (!videoRef.value || videoRef.value.paused || videoRef.value.ended) {
scanRafId = requestAnimationFrame(detectLoop)
return
}
try {
const codes = await barcodeDetector.detect(videoRef.value)
paintDetections(codes)
if (codes.length > 0) {
onDetect(codes)
}
} catch (e) {
// console.error('Detection error', e)
}
scanRafId = requestAnimationFrame(detectLoop)
}
const desktopFullscreenStyle = computed(() => {
if (!isFullscreen.value) return {}
const isDesktop = window.innerWidth >= 768
if (!isDesktop) return {}
const halfHeight = Math.floor(window.innerHeight * 0.5)
const widthPx = Math.min(window.innerWidth, Math.floor(halfHeight * videoAspect.value))
return { height: `${halfHeight}px`, width: `${widthPx}px`, margin: '0 auto' }
})
const processCodes = (codes) => {
codes.forEach(code => {
const value = code.rawValue
if (value && !scannedCodes.value.some(c => c.value === value)) {
scannedCodes.value.unshift({
id: Date.now() + Math.random(),
value,
format: code.format,
timestamp: new Date().toLocaleTimeString()
})
}
})
}
const onDetect = (detectedCodes) => {
// If fullscreen, accept all detected codes (as the user sees the full camera view mostly)
if (isFullscreen.value) {
processCodes(detectedCodes)
return
}
// Try to find video element to calculate visible area
const videoEl = document.querySelector('.camera-wrapper video')
if (!videoEl || !videoEl.videoWidth || !videoEl.videoHeight) {
processCodes(detectedCodes)
return
}
const { videoWidth, videoHeight } = videoEl
// Calculate visible square area (assuming object-fit: cover and 1:1 container)
const isLandscape = videoWidth > videoHeight
let visibleX, visibleY, visibleW, visibleH
if (isLandscape) {
// Landscape: sides are cropped, height is fully visible
visibleH = videoHeight
visibleW = videoHeight // Square
visibleX = (videoWidth - videoHeight) / 2
visibleY = 0
} else {
// Portrait: top/bottom are cropped, width is fully visible
visibleW = videoWidth
visibleH = videoWidth // Square
visibleX = 0
visibleY = (videoHeight - videoWidth) / 2
}
// Add margin to be safe (code center must be within visible area)
// We allow codes slightly outside if their center is inside
const validCodes = detectedCodes.filter(code => {
if (!code.boundingBox) return true
const { x, y, width, height } = code.boundingBox
const centerX = x + width / 2
const centerY = y + height / 2
return (
centerX >= visibleX &&
centerX <= visibleX + visibleW &&
centerY >= visibleY &&
centerY <= visibleY + visibleH
)
})
processCodes(validCodes)
}
watch(facingMode, () => {
startScan()
})
const onError = (err) => {
if (err.name === 'NotAllowedError') {
error.value = 'Camera permission denied'
} else if (err.name === 'NotFoundError') {
error.value = 'No camera found'
} else {
error.value = `Camera error: ${err.name}`
}
}
const checkCameras = async () => {
try {
if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) {
return
}
const devices = await navigator.mediaDevices.enumerateDevices()
const cameras = devices.filter(d => d.kind === 'videoinput')
hasMultipleCameras.value = cameras.length > 1
} catch (e) {
console.error('Error checking cameras:', e)
}
}
const loadHistory = () => {
try {
const saved = localStorage.getItem('qr-history')
if (saved) {
scannedCodes.value = JSON.parse(saved)
}
} catch (e) {
console.error('Failed to load QR history', e)
}
}
watch(scannedCodes, (newVal) => {
localStorage.setItem('qr-history', JSON.stringify(newVal))
}, { deep: true })
onMounted(() => {
checkCameras()
loadHistory()
window.addEventListener('resize', updateVideoAspect)
window.addEventListener('resize', startBackgroundLoop)
watch(isFullscreen, (fs) => {
if (fs) {
startBackgroundLoop()
} else {
stopBackgroundLoop()
}
}, { immediate: true })
startScan()
})
onUnmounted(() => {
window.removeEventListener('resize', updateVideoAspect)
window.removeEventListener('resize', startBackgroundLoop)
stopScan()
})
const switchCamera = (event) => {
event.stopPropagation()
facingMode.value = facingMode.value === 'environment' ? 'user' : 'environment'
}
const toggleFullscreen = () => {
isFullscreen.value = !isFullscreen.value
}
const clearHistory = () => {
scannedCodes.value = []
}
const removeCode = (id) => {
scannedCodes.value = scannedCodes.value.filter(c => c.id !== id)
}
const copyAll = async () => {
if (scannedCodes.value.length === 0) return
const text = scannedCodes.value.map(c => c.value).join('\n')
try {
await navigator.clipboard.writeText(text)
} catch (err) {
console.error('Failed to copy', err)
}
}
const downloadJson = () => {
if (scannedCodes.value.length === 0) return
const data = JSON.stringify(scannedCodes.value, null, 2)
const blob = new Blob([data], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `qr-scan-history-${new Date().toISOString().slice(0, 10)}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
const copyToClipboard = async (text) => {
try {
await navigator.clipboard.writeText(text)
} catch (err) {
console.error('Failed to copy', err)
}
}
const isUrl = (string) => {
try {
return Boolean(new URL(string))
} catch (_) {
return false
}
}
</script>
<template>
<div class="tool-container full-width">
<div class="tool-panel">
<div class="panel-header" v-if="!isFullscreen">
<h2 class="tool-title">QR Scanner</h2>
</div>
<Teleport to="body" :disabled="!isFullscreen">
<div class="scanner-content" :class="{ 'is-fullscreen': isFullscreen }">
<canvas
v-if="isFullscreen"
ref="bgCanvas"
class="camera-bg"
></canvas>
<button v-if="isFullscreen" class="close-fullscreen-btn" @click="toggleFullscreen">
<X size="24" />
</button>
<div
class="camera-wrapper"
:class="{ 'clickable': !isFullscreen, 'is-front': isFront }"
:style="desktopFullscreenStyle"
ref="wrapperRef"
@click="!isFullscreen && toggleFullscreen()"
>
<video
ref="videoRef"
class="camera-feed"
:class="{ 'is-front': isFront }"
autoplay
playsinline
muted
></video>
<canvas ref="overlayCanvas" class="scan-overlay-canvas"></canvas>
<div v-if="error" class="error-overlay">
<p>{{ error }}</p>
<button @click="startScan" class="retry-btn">Retry</button>
</div>
<button
v-if="hasMultipleCameras"
class="switch-camera-btn"
@click.stop="switchCamera"
title="Switch Camera"
>
<SwitchCamera size="24" />
</button>
</div>
<div class="results-section">
<div class="results-header">
<h3>Scanned Codes ({{ scannedCodes.length }})</h3>
<div v-if="scannedCodes.length > 0" class="header-actions">
<button class="icon-btn" @click="copyAll" title="Copy All">
<Copy size="18" />
</button>
<button class="icon-btn" @click="downloadJson" title="Download JSON">
<Download size="18" />
</button>
<button class="icon-btn delete-btn" @click="clearHistory" title="Clear All">
<Trash2 size="18" />
</button>
</div>
</div>
<div v-if="scannedCodes.length > 0" class="codes-list">
<div v-for="code in scannedCodes" :key="code.id" class="code-item">
<div class="code-content">
<div class="code-value">
<a v-if="isUrl(code.value)" :href="code.value" target="_blank" rel="noopener noreferrer">{{ code.value }}</a>
<span v-else>{{ code.value }}</span>
</div>
<div class="code-meta">
<span class="timestamp">{{ code.timestamp }}</span>
<span class="format-badge">{{ code.format }}</span>
</div>
</div>
<div class="item-actions">
<button class="icon-btn" @click="copyToClipboard(code.value)" title="Copy">
<Copy size="18" />
</button>
<button class="icon-btn delete-btn" @click="removeCode(code.id)" title="Remove">
<Trash2 size="18" />
</button>
</div>
</div>
</div>
<div v-else class="empty-state">
Point camera at a QR code to scan
</div>
</div>
</div>
</Teleport>
</div>
</div>
</template>
<style scoped>
.tool-container.full-width {
max-width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.tool-panel {
display: flex;
flex-direction: column;
height: 100%;
gap: 1.5rem;
padding: 1.5rem;
}
.panel-header {
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 0.5rem;
margin-top: 0;
position: relative;
width: 100%;
}
.scanner-content {
display: flex;
flex-direction: column;
height: 100%;
gap: 1.5rem;
}
.scanner-content.is-fullscreen {
position: fixed;
inset: 0;
z-index: 9999;
background: #000;
gap: 0;
}
.camera-wrapper {
width: 100%;
max-width: 500px;
aspect-ratio: 1;
margin: 0 auto;
border-radius: 12px;
overflow: hidden;
position: relative;
background: #000;
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow);
transition: all 0.3s ease;
}
.camera-wrapper.clickable {
cursor: pointer;
}
.scanner-content.is-fullscreen .camera-wrapper {
position: relative;
width: 100%;
aspect-ratio: 1;
max-width: none;
height: auto;
border-radius: 0;
border: none;
margin: 0;
z-index: 1;
}
.camera-bg {
position: absolute;
left: 0;
top: 0;
width: 100vw;
height: 50vh;
filter: blur(16px) saturate(110%);
opacity: 0.9;
z-index: 0;
}
.camera-feed {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.camera-feed.is-front {
transform: scaleX(-1);
}
.scan-overlay-canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 5;
}
/* front mirror canvas removed */
.error-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.8);
color: #ff4444;
padding: 1rem;
text-align: center;
z-index: 10;
}
.switch-camera-btn {
position: absolute;
top: 1rem;
right: 1rem;
background: rgba(0, 0, 0, 0.4);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff;
border-radius: 50%;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 20;
backdrop-filter: blur(4px);
transition: all 0.2s;
}
.switch-camera-btn:hover {
background: rgba(0, 0, 0, 0.6);
transform: scale(1.05);
}
.close-fullscreen-btn {
position: absolute;
top: 1rem;
left: 1rem;
z-index: 20;
background: rgba(0, 0, 0, 0.4);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff;
border-radius: 50%;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
backdrop-filter: blur(4px);
}
/* Removed legacy scan frame overlay - using shape detection rendering via track instead */
.results-section {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 12px;
}
.scanner-content.is-fullscreen .results-section {
position: relative;
flex: 1;
width: 100%;
height: auto;
z-index: 2;
border-radius: 0;
background: var(--glass-bg);
backdrop-filter: blur(10px);
border: none;
border-top: 1px solid rgba(255, 255, 255, 0.2);
display: flex;
flex-direction: column;
}
.results-header {
padding: 1rem;
border-bottom: 1px solid var(--glass-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.results-header h3 {
margin: 0;
font-size: 1.1rem;
color: var(--text-strong);
}
.header-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.codes-list {
flex: 1;
overflow-y: auto;
padding: 0;
}
.code-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--glass-border);
transition: background 0.2s;
}
.code-item:last-child {
border-bottom: none;
}
.code-item:hover {
background: var(--list-hover-bg);
}
.code-content {
flex: 1;
overflow: hidden;
padding-right: 1rem;
}
.code-value {
color: var(--primary-accent);
font-family: monospace;
word-break: break-all;
margin-bottom: 0.4rem;
}
.code-value a {
color: var(--primary-accent);
text-decoration: underline;
}
.code-value a:hover {
opacity: 0.8;
}
.code-meta {
display: flex;
gap: 1rem;
font-size: 0.85rem;
color: var(--text-secondary);
}
.item-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.format-badge {
background: rgba(255, 255, 255, 0.1);
padding: 0 0.4rem;
border-radius: 4px;
font-size: 0.75rem;
}
:global(:root[data-theme="light"]) .format-badge {
background: rgba(0, 0, 0, 0.1);
}
.icon-btn {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 0.4rem;
border-radius: 4px;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.icon-btn:hover {
color: var(--text-color);
background: rgba(255, 255, 255, 0.1);
}
:global(:root[data-theme="light"]) .icon-btn:hover {
background: rgba(0, 0, 0, 0.05);
}
.delete-btn:hover {
color: #ef4444;
}
.empty-state {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
color: var(--text-secondary);
padding: 2rem;
text-align: center;
}
:deep(video) {
object-fit: cover !important;
width: 100% !important;
height: 100% !important;
position: absolute !important;
left: 0 !important;
top: 0 !important;
}
:deep(.qrcode-stream-wrapper),
:deep(.qrcode-stream-overlay) {
width: 100% !important;
height: 100% !important;
position: absolute !important;
inset: 0 !important;
}
/* Front camera mirror (CSS-only) */
.camera-wrapper.is-front :deep(video) {
transform: scaleX(-1);
transform-origin: center;
}
.camera-wrapper.is-front :deep(#qrcode-stream-pause-frame),
.camera-wrapper.is-front :deep(#qrcode-stream-overlay) {
transform: scaleX(-1);
transform-origin: center;
}
@media (min-width: 768px) {
:deep(.scanner-content.is-fullscreen .camera-wrapper video) {
object-fit: contain !important;
}
}
</style>