feat(qr-scanner): remove frame, add shape detection overlay, improve fullscreen desktop layout

This commit is contained in:
2026-02-27 17:52:36 +00:00
parent a699b432d7
commit 613604f3c4
4 changed files with 69 additions and 29 deletions

View File

@@ -83,12 +83,12 @@ onUnmounted(() => {
@media (min-width: 768px) { @media (min-width: 768px) {
.app-body { .app-body {
overflow: hidden; overflow: visible;
} }
.main-content { .main-content {
overflow-y: auto; overflow: visible;
height: 100%; height: auto;
padding-bottom: 2rem; padding-bottom: 2rem;
} }
} }

View File

@@ -63,7 +63,7 @@ onMounted(() => {
<style scoped> <style scoped>
.app-header { .app-header {
/* Remove hardcoded colors and use theme variables */ /* Remove hardcoded colors and use theme variables */
background: var(--glass-bg); background: var(--header-bg);
color: var(--text-color); color: var(--text-color);
padding: 1rem; padding: 1rem;
/* box-shadow handled by glass-panel class */ /* box-shadow handled by glass-panel class */

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, onMounted, watch } from 'vue' import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
import { QrcodeStream } from 'vue-qrcode-reader' import { QrcodeStream } from 'vue-qrcode-reader'
import { SwitchCamera, Trash2, Copy, Download, X } from 'lucide-vue-next' import { SwitchCamera, Trash2, Copy, Download, X } from 'lucide-vue-next'
@@ -8,6 +8,21 @@ const facingMode = ref('environment')
const scannedCodes = ref([]) const scannedCodes = ref([])
const hasMultipleCameras = ref(false) const hasMultipleCameras = ref(false)
const isFullscreen = ref(false) const isFullscreen = ref(false)
const videoAspect = ref(1)
const updateVideoAspect = () => {
const videoEl = document.querySelector('.camera-wrapper video')
if (videoEl && videoEl.videoWidth && videoEl.videoHeight) {
videoAspect.value = videoEl.videoWidth / videoEl.videoHeight
}
}
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) => { const processCodes = (codes) => {
codes.forEach(code => { codes.forEach(code => {
@@ -79,8 +94,41 @@ const onDetect = (detectedCodes) => {
const onCameraOn = async (capabilities) => { const onCameraOn = async (capabilities) => {
// Camera is ready // Camera is ready
setTimeout(updateVideoAspect, 100)
} }
const paintDetections = (detectedCodes, ctx) => {
try {
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height)
const styles = getComputedStyle(document.documentElement)
const accent = styles.getPropertyValue('--primary-accent').trim() || '#00f2fe'
detectedCodes.forEach(code => {
if (code.format && code.format !== 'qr_code') return
const points = code.cornerPoints || []
if (points.length < 4) return
ctx.beginPath()
ctx.moveTo(points[0].x, points[0].y)
for (let i = 1; i < points.length; i++) {
ctx.lineTo(points[i].x, points[i].y)
}
ctx.closePath()
ctx.lineWidth = 3
ctx.strokeStyle = accent
ctx.shadowColor = accent
ctx.shadowBlur = 8
ctx.stroke()
ctx.shadowBlur = 0
ctx.fillStyle = accent
points.forEach(p => {
ctx.beginPath()
ctx.arc(p.x, p.y, 2.5, 0, Math.PI * 2)
ctx.fill()
})
})
} catch (e) {
// ignore drawing errors
}
}
const onError = (err) => { const onError = (err) => {
if (err.name === 'NotAllowedError') { if (err.name === 'NotAllowedError') {
error.value = 'Camera permission denied' error.value = 'Camera permission denied'
@@ -122,6 +170,11 @@ watch(scannedCodes, (newVal) => {
onMounted(() => { onMounted(() => {
checkCameras() checkCameras()
loadHistory() loadHistory()
window.addEventListener('resize', updateVideoAspect)
})
onUnmounted(() => {
window.removeEventListener('resize', updateVideoAspect)
}) })
const switchCamera = (event) => { const switchCamera = (event) => {
@@ -195,12 +248,13 @@ const isUrl = (string) => {
<X size="24" /> <X size="24" />
</button> </button>
<div class="camera-wrapper" :class="{ 'clickable': !isFullscreen }" @click="!isFullscreen && toggleFullscreen()"> <div class="camera-wrapper" :class="{ 'clickable': !isFullscreen }" :style="desktopFullscreenStyle" @click="!isFullscreen && toggleFullscreen()">
<QrcodeStream <QrcodeStream
:constraints="{ facingMode }" :constraints="{ facingMode }"
@detect="onDetect" @detect="onDetect"
@error="onError" @error="onError"
@camera-on="onCameraOn" @camera-on="onCameraOn"
:track="paintDetections"
> >
<div v-if="error" class="error-overlay"> <div v-if="error" class="error-overlay">
<p>{{ error }}</p> <p>{{ error }}</p>
@@ -214,10 +268,6 @@ const isUrl = (string) => {
> >
<SwitchCamera size="24" /> <SwitchCamera size="24" />
</button> </button>
<div class="scan-overlay">
<div class="scan-frame"></div>
</div>
</QrcodeStream> </QrcodeStream>
</div> </div>
@@ -395,25 +445,7 @@ const isUrl = (string) => {
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
} }
.scan-overlay { /* Removed legacy scan frame overlay - using shape detection rendering via track instead */
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
display: flex;
align-items: center;
justify-content: center;
}
.scan-frame {
width: 70%;
height: 70%;
border: 2px solid rgba(255, 255, 255, 0.5);
border-radius: 12px;
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.3);
}
.results-section { .results-section {
flex: 1; flex: 1;
@@ -581,4 +613,10 @@ const isUrl = (string) => {
position: absolute !important; position: absolute !important;
inset: 0 !important; inset: 0 !important;
} }
@media (min-width: 768px) {
:deep(.scanner-content.is-fullscreen .camera-wrapper video) {
object-fit: contain !important;
}
}
</style> </style>

View File

@@ -43,6 +43,7 @@
--ripple-color: rgba(255, 255, 255, 0.3); --ripple-color: rgba(255, 255, 255, 0.3);
--nav-item-weight: 400; --nav-item-weight: 400;
--list-hover-bg: rgba(255, 255, 255, 0.05); --list-hover-bg: rgba(255, 255, 255, 0.05);
--header-bg: rgba(0, 0, 0, 0.6);
color: var(--text-color); color: var(--text-color);
background-color: #242424; /* Fallback */ background-color: #242424; /* Fallback */
@@ -86,6 +87,7 @@
--title-gradient: linear-gradient(45deg, #0ea5e9, #6366f1); --title-gradient: linear-gradient(45deg, #0ea5e9, #6366f1);
--ripple-color: rgba(0, 0, 0, 0.1); --ripple-color: rgba(0, 0, 0, 0.1);
--list-hover-bg: rgba(0, 0, 0, 0.05); --list-hover-bg: rgba(0, 0, 0, 0.05);
--header-bg: rgba(255, 255, 255, 0.9);
} }
body { body {