Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
1765742574
|
|||
|
5b1a50f148
|
|||
|
613604f3c4
|
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "tools-app",
|
"name": "tools-app",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "tools-app",
|
"name": "tools-app",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lucide-vue-next": "^0.575.0",
|
"lucide-vue-next": "^0.575.0",
|
||||||
"vue": "^3.5.25",
|
"vue": "^3.5.25",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "tools-app",
|
"name": "tools-app",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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,69 @@ 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 isFront = computed(() => facingMode.value === 'user')
|
||||||
|
const wrapperRef = ref(null)
|
||||||
|
let frontMirrorObserver = null
|
||||||
|
const bgCanvas = ref(null)
|
||||||
|
let bgRafId = null
|
||||||
|
const updateVideoAspect = () => {
|
||||||
|
const videoEl = document.querySelector('.camera-wrapper video')
|
||||||
|
if (videoEl && videoEl.videoWidth && videoEl.videoHeight) {
|
||||||
|
videoAspect.value = videoEl.videoWidth / videoEl.videoHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const startBackgroundLoop = () => {
|
||||||
|
const draw = () => {
|
||||||
|
const videoEl = document.querySelector('.camera-wrapper video')
|
||||||
|
const canvas = bgCanvas.value
|
||||||
|
if (!videoEl || !canvas) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// front mirror canvas removed to restore stable behavior
|
||||||
|
const stopBackgroundLoop = () => {
|
||||||
|
if (bgRafId) {
|
||||||
|
cancelAnimationFrame(bgRafId)
|
||||||
|
bgRafId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 +142,59 @@ const onDetect = (detectedCodes) => {
|
|||||||
|
|
||||||
const onCameraOn = async (capabilities) => {
|
const onCameraOn = async (capabilities) => {
|
||||||
// Camera is ready
|
// Camera is ready
|
||||||
|
setTimeout(updateVideoAspect, 100)
|
||||||
|
setTimeout(startBackgroundLoop, 150)
|
||||||
|
// Flip is handled via global CSS; no JS flips needed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ensureFrontMirror = () => {
|
||||||
|
// No-op: mirror is applied via CSS selectors
|
||||||
|
}
|
||||||
|
|
||||||
|
const startFrontMirrorObserver = () => {
|
||||||
|
// No-op: mirror is applied via CSS selectors
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopFrontMirrorObserver = () => {
|
||||||
|
// No-op
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(isFront, () => {
|
||||||
|
// CSS-based; nothing to do
|
||||||
|
})
|
||||||
|
|
||||||
|
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 +236,21 @@ watch(scannedCodes, (newVal) => {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
checkCameras()
|
checkCameras()
|
||||||
loadHistory()
|
loadHistory()
|
||||||
|
window.addEventListener('resize', updateVideoAspect)
|
||||||
|
window.addEventListener('resize', startBackgroundLoop)
|
||||||
|
watch(isFullscreen, (fs) => {
|
||||||
|
if (fs) {
|
||||||
|
startBackgroundLoop()
|
||||||
|
} else {
|
||||||
|
stopBackgroundLoop()
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', updateVideoAspect)
|
||||||
|
window.removeEventListener('resize', startBackgroundLoop)
|
||||||
|
stopBackgroundLoop()
|
||||||
})
|
})
|
||||||
|
|
||||||
const switchCamera = (event) => {
|
const switchCamera = (event) => {
|
||||||
@@ -191,16 +320,28 @@ const isUrl = (string) => {
|
|||||||
|
|
||||||
<Teleport to="body" :disabled="!isFullscreen">
|
<Teleport to="body" :disabled="!isFullscreen">
|
||||||
<div class="scanner-content" :class="{ 'is-fullscreen': 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">
|
<button v-if="isFullscreen" class="close-fullscreen-btn" @click="toggleFullscreen">
|
||||||
<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, 'is-front': isFront }"
|
||||||
|
:style="desktopFullscreenStyle"
|
||||||
|
ref="wrapperRef"
|
||||||
|
@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 +355,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>
|
||||||
|
|
||||||
@@ -334,9 +471,22 @@ const isUrl = (string) => {
|
|||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
border: none;
|
border: none;
|
||||||
margin: 0;
|
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;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* front mirror canvas removed */
|
||||||
|
|
||||||
.error-overlay {
|
.error-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -395,25 +545,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 +713,21 @@ const isUrl = (string) => {
|
|||||||
position: absolute !important;
|
position: absolute !important;
|
||||||
inset: 0 !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>
|
</style>
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -123,6 +125,8 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Removed global front camera mirror to restore stability */
|
||||||
|
|
||||||
/* --- Shared styles for all tools (moved from tools.css) --- */
|
/* --- Shared styles for all tools (moved from tools.css) --- */
|
||||||
|
|
||||||
.tool-container {
|
.tool-container {
|
||||||
|
|||||||
Reference in New Issue
Block a user