25 Commits

Author SHA1 Message Date
5b31171964 0.6.15
All checks were successful
Deploy to Production / deploy (push) Successful in 13s
2026-03-03 00:02:34 +00:00
e98761a18e feat: make header title a link to home page 2026-03-03 00:02:17 +00:00
bc8168e724 0.6.14
All checks were successful
Deploy to Production / deploy (push) Successful in 14s
2026-03-02 23:59:04 +00:00
dcde3b0799 feat: add ESC key support to close all modals and fullscreen modes 2026-03-02 23:58:50 +00:00
60fc774586 0.6.13
All checks were successful
Deploy to Production / deploy (push) Successful in 14s
2026-03-02 23:37:12 +00:00
d65c0d0357 docs: replace problematic emoji in README.md with safer alternative 2026-03-02 23:36:57 +00:00
02736ecc70 0.6.12
All checks were successful
Deploy to Production / deploy (push) Successful in 14s
2026-03-02 23:32:21 +00:00
7b1d19ca7a docs: fix corrupted emoji in README.md 2026-03-02 23:32:12 +00:00
4846d0e61c 0.6.11
All checks were successful
Deploy to Production / deploy (push) Successful in 13s
2026-03-02 23:26:45 +00:00
3155e12b84 feat: display README.md on home page using marked 2026-03-02 23:26:36 +00:00
b8bbe84aa9 docs: add descriptions for all new tools (QR Gen, QR Scanner, URL Cleaner) to README 2026-03-02 23:24:03 +00:00
74984caf9e 0.6.10
All checks were successful
Deploy to Production / deploy (push) Successful in 13s
2026-02-28 18:07:22 +00:00
c8b799b078 fix: mirror background canvas for front camera in fullscreen mode 2026-02-28 18:07:10 +00:00
f3a4c1af05 0.6.9
All checks were successful
Deploy to Production / deploy (push) Successful in 14s
2026-02-28 18:05:14 +00:00
616f615d7c fix: improve front camera detection on macOS by checking video track label 2026-02-28 18:04:28 +00:00
4d572b55ca 0.6.8
All checks were successful
Deploy to Production / deploy (push) Successful in 13s
2026-02-28 17:51:40 +00:00
9822cab93e feat: restore QR code detection overlay in custom QrScanner 2026-02-28 17:50:29 +00:00
9409bd3e21 0.6.7
All checks were successful
Deploy to Production / deploy (push) Successful in 14s
2026-02-28 17:46:21 +00:00
346ded460a chore: remove wasm file from repo and add to gitignore 2026-02-28 17:45:21 +00:00
170539a62f 0.6.6
All checks were successful
Deploy to Production / deploy (push) Successful in 13s
2026-02-28 17:41:06 +00:00
cfc1785863 chore: cleanup unused code in QrScanner and remove vue-qrcode-reader dependency 2026-02-28 17:40:42 +00:00
712b1238a5 0.6.5
All checks were successful
Deploy to Production / deploy (push) Successful in 14s
2026-02-28 17:34:35 +00:00
3732d365dd refactor: replace vue-qrcode-reader with custom native QR scanner using local BarcodeDetector polyfill 2026-02-28 17:31:04 +00:00
34fd8bb2b3 0.6.4
All checks were successful
Deploy to Production / deploy (push) Successful in 14s
2026-02-28 17:21:46 +00:00
90dc663393 fix: use local zxing-wasm file to resolve CSP issues in QrScanner 2026-02-28 17:21:29 +00:00
11 changed files with 515 additions and 144 deletions

1
.gitignore vendored
View File

@@ -11,6 +11,7 @@ node_modules
dist
dist-ssr
*.local
public/wasm
# Editor directories and files
.vscode/*

View File

@@ -20,6 +20,26 @@ Monitor and capture your clipboard history in real-time.
- Clears history on demand.
- Privacy-focused: Data is processed locally and never sent to any server.
### 🔗 URL Cleaner
Clean tracking parameters and clutter from URLs.
- **Privacy:** Removes known tracking parameters (UTM, fbclid, gclid, etc.).
- **Bulk Processing:** Clean list of URLs at once.
- **Customizable:** Manage exceptions to keep specific parameters.
- **Smart:** "Direct Clean" mode for quick single-URL cleaning.
### ⬛ QR Generator
Create custom QR codes instantly.
- **Customizable:** Adjustable error correction level and size.
- **Download:** Save as PNG (raster) or SVG (vector) for high-quality printing.
- **Persistent Settings:** Remembers your preferences.
### 📷 QR Scanner
Scan QR codes directly from your camera or device.
- **Privacy First:** Works entirely in the browser using local `BarcodeDetector` API. No images are sent to any server.
- **Multi-Camera:** Support for front and back cameras with easy switching.
- **History:** Keeps a log of scanned codes with options to copy or download as JSON.
- **Responsive:** Fullscreen mode for immersive scanning experience.
---
## Chrome Extension 🧩

106
package-lock.json generated
View File

@@ -1,17 +1,19 @@
{
"name": "tools-app",
"version": "0.6.3",
"version": "0.6.15",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tools-app",
"version": "0.6.3",
"version": "0.6.15",
"hasInstallScript": true,
"dependencies": {
"barcode-detector": "^3.1.0",
"lucide-vue-next": "^0.575.0",
"marked": "^17.0.3",
"qrcode": "^1.5.4",
"vue": "^3.5.25",
"vue-qrcode-reader": "^5.7.3",
"vue-router": "^5.0.3"
},
"devDependencies": {
@@ -2516,12 +2518,6 @@
"sourcemap-codec": "^1.4.8"
}
},
"node_modules/@types/dom-webcodecs": {
"version": "0.1.18",
"resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.18.tgz",
"integrity": "sha512-vAvE8C9DGWR+tkb19xyjk1TSUlJ7RUzzp4a9Anu7mwBT+fpyePWK1UxmH14tMO5zHmrnrRIMg5NutnnDztLxgg==",
"license": "MIT"
},
"node_modules/@types/emscripten": {
"version": "1.41.5",
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz",
@@ -2946,13 +2942,12 @@
}
},
"node_modules/barcode-detector": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/barcode-detector/-/barcode-detector-2.2.2.tgz",
"integrity": "sha512-JcSekql+EV93evfzF9zBr+Y6aRfkR+QFvgyzbwQ0dbymZXoAI9+WgT7H1E429f+3RKNncHz2CW98VQtaaKpmfQ==",
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/barcode-detector/-/barcode-detector-3.1.0.tgz",
"integrity": "sha512-aQjGxrgsb/WTlw6pHZwFRO6NhFMhwHGEkd0pzV25fBn8dnRA1PA1G7bLeAzvSea646S/96nW5W3jD8wezQZ1vQ==",
"license": "MIT",
"dependencies": {
"@types/dom-webcodecs": "^0.1.11",
"zxing-wasm": "1.1.3"
"zxing-wasm": "3.0.0"
}
},
"node_modules/baseline-browser-mapping": {
@@ -4756,6 +4751,18 @@
"url": "https://github.com/sponsors/sxzz"
}
},
"node_modules/marked": {
"version": "17.0.3",
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.3.tgz",
"integrity": "sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -5471,12 +5478,6 @@
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
"license": "MIT"
},
"node_modules/sdp": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.1.tgz",
"integrity": "sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw==",
"license": "MIT"
},
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@@ -5912,6 +5913,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tagged-tag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
"integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/temp-dir": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
@@ -6368,19 +6381,6 @@
}
}
},
"node_modules/vue-qrcode-reader": {
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/vue-qrcode-reader/-/vue-qrcode-reader-5.7.3.tgz",
"integrity": "sha512-iSGko42FsEvdHyizBMBs/X+HMO9Z5ONDxjW+mQdoraOR5emRNedmjC5SEJdYzGz8ZP5ME3lwB4iHy3S7MOt5Qw==",
"license": "MIT",
"dependencies": {
"barcode-detector": "2.2.2",
"webrtc-adapter": "8.2.3"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/vue-router": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.3.tgz",
@@ -6439,19 +6439,6 @@
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"license": "MIT"
},
"node_modules/webrtc-adapter": {
"version": "8.2.3",
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-8.2.3.tgz",
"integrity": "sha512-gnmRz++suzmvxtp3ehQts6s2JtAGPuDPjA1F3a9ckNpG1kYdYuHWYpazoAnL9FS5/B21tKlhkorbdCXat0+4xQ==",
"license": "BSD-3-Clause",
"dependencies": {
"sdp": "^3.2.0"
},
"engines": {
"node": ">=6.0.0",
"npm": ">=3.10.0"
}
},
"node_modules/whatwg-url": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz",
@@ -6982,12 +6969,31 @@
}
},
"node_modules/zxing-wasm": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-1.1.3.tgz",
"integrity": "sha512-MYm9k/5YVs4ZOTIFwlRjfFKD0crhefgbnt1+6TEpmKUDFp3E2uwqGSKwQOd2hOIsta/7Usq4hnpNRYTLoljnfA==",
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-3.0.0.tgz",
"integrity": "sha512-s7ASCPKX+QnH7Y83f4Byxmq/vDzYW7B9m6jMP5S30JGfN2A6WAUn6P3vcBmNguDhPLE6ny2fjTooQVyKBXI1qA==",
"license": "MIT",
"dependencies": {
"@types/emscripten": "^1.39.10"
"@types/emscripten": "^1.41.5",
"type-fest": "^5.4.4"
},
"peerDependencies": {
"@types/emscripten": ">=1.39.6"
}
},
"node_modules/zxing-wasm/node_modules/type-fest": {
"version": "5.4.4",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.4.4.tgz",
"integrity": "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==",
"license": "(MIT OR CC0-1.0)",
"dependencies": {
"tagged-tag": "^1.0.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
}
}

View File

@@ -1,18 +1,20 @@
{
"name": "tools-app",
"private": true,
"version": "0.6.3",
"version": "0.6.15",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"postinstall": "mkdir -p public/wasm && cp node_modules/zxing-wasm/dist/reader/zxing_reader.wasm public/wasm/"
},
"dependencies": {
"barcode-detector": "^3.1.0",
"lucide-vue-next": "^0.575.0",
"marked": "^17.0.3",
"qrcode": "^1.5.4",
"vue": "^3.5.25",
"vue-qrcode-reader": "^5.7.3",
"vue-router": "^5.0.3"
},
"devDependencies": {

View File

@@ -44,7 +44,7 @@ onMounted(() => {
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
</button>
<h1 class="app-title">Tools App</h1>
<router-link to="/" class="app-title">Tools App</router-link>
</div>
<button
@@ -115,10 +115,11 @@ onMounted(() => {
.app-title {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
background: var(--title-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
color: transparent;
text-decoration: none;
font-weight: bold;
}
</style>

View File

@@ -51,12 +51,20 @@ const dismissPrompt = () => {
deferredPrompt = null
}
const handleKeydown = (e) => {
if (e.key === 'Escape' && showInstallPrompt.value) {
dismissPrompt()
}
}
onMounted(() => {
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
window.addEventListener('keydown', handleKeydown)
})
onBeforeUnmount(() => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
window.removeEventListener('keydown', handleKeydown)
})
</script>

View File

@@ -1,11 +1,99 @@
<script setup>
import { ref } from 'vue'
import { marked } from 'marked'
import { computed } from 'vue'
import readmeContent from '../../README.md?raw'
const htmlContent = computed(() => {
return marked(readmeContent)
})
</script>
<template>
main
<div class="readme-container glass-panel">
<div class="markdown-body" v-html="htmlContent"></div>
</div>
</template>
<style scoped>
.readme-container {
padding: 2rem;
max-width: 800px;
margin: 0 auto;
}
.markdown-body :deep(h1) {
font-size: 2rem;
margin-bottom: 1rem;
color: var(--primary-accent);
}
.markdown-body :deep(h2) {
font-size: 1.5rem;
margin-top: 2rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--glass-border);
padding-bottom: 0.5rem;
color: var(--text-strong);
}
.markdown-body :deep(h3) {
font-size: 1.25rem;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
color: var(--text-strong);
}
.markdown-body :deep(p) {
margin-bottom: 1rem;
line-height: 1.6;
color: var(--text-color);
}
.markdown-body :deep(ul) {
margin-bottom: 1rem;
padding-left: 1.5rem;
}
.markdown-body :deep(li) {
margin-bottom: 0.5rem;
color: var(--text-color);
}
.markdown-body :deep(a) {
color: var(--primary-accent);
text-decoration: none;
}
.markdown-body :deep(a:hover) {
text-decoration: underline;
}
.markdown-body :deep(code) {
background: rgba(255, 255, 255, 0.1);
padding: 0.2em 0.4em;
border-radius: 4px;
font-family: monospace;
font-size: 0.9em;
}
.markdown-body :deep(pre) {
background: rgba(0, 0, 0, 0.3);
padding: 1rem;
border-radius: 8px;
overflow-x: auto;
margin-bottom: 1rem;
border: 1px solid var(--glass-border);
}
.markdown-body :deep(pre code) {
background: none;
padding: 0;
color: #e6e6e6;
}
.markdown-body :deep(hr) {
border: none;
border-top: 1px solid var(--glass-border);
margin: 2rem 0;
}
</style>

View File

@@ -1,7 +1,6 @@
<script setup>
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
import { QrcodeStream } from 'vue-qrcode-reader'
import { SwitchCamera, Trash2, Copy, Download, X } from 'lucide-vue-next'
import { SwitchCamera, Trash2, Copy, Download, X, Maximize2, Minimize2 } from 'lucide-vue-next'
const error = ref('')
const facingMode = ref('environment')
@@ -9,22 +8,117 @@ const scannedCodes = ref([])
const hasMultipleCameras = ref(false)
const isFullscreen = ref(false)
const videoAspect = ref(1)
const isFront = computed(() => facingMode.value === 'user')
const isMirrored = ref(false)
const wrapperRef = ref(null)
let frontMirrorObserver = 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
// Canvas is mirrored via CSS if isMirrored is true, so no manual coordinate mirroring needed
// 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
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 = () => {
const videoEl = document.querySelector('.camera-wrapper video')
if (videoEl && videoEl.videoWidth && videoEl.videoHeight) {
videoAspect.value = videoEl.videoWidth / videoEl.videoHeight
if (videoRef.value && videoRef.value.videoWidth && videoRef.value.videoHeight) {
videoAspect.value = videoRef.value.videoWidth / videoRef.value.videoHeight
}
}
const startBackgroundLoop = () => {
const draw = () => {
const videoEl = document.querySelector('.camera-wrapper video')
const videoEl = videoRef.value
const canvas = bgCanvas.value
if (!videoEl || !canvas) {
if (!videoEl || !canvas || videoEl.paused || videoEl.ended) {
bgRafId = requestAnimationFrame(draw)
return
}
@@ -56,13 +150,114 @@ const startBackgroundLoop = () => {
bgRafId = requestAnimationFrame(draw)
}
// front mirror canvas removed to restore stable behavior
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)
// Detect actual facing mode to mirror front camera correctly
const videoTrack = stream.getVideoTracks()[0]
if (videoTrack) {
const settings = videoTrack.getSettings()
if (settings.facingMode) {
isMirrored.value = settings.facingMode === 'user'
} else {
// Fallback: check label for desktop cameras or assume requested mode
const label = videoTrack.label ? videoTrack.label.toLowerCase() : ''
if (label.includes('front') || label.includes('facetime') || label.includes('macbook')) {
isMirrored.value = true
} else {
isMirrored.value = facingMode.value === 'user'
}
}
}
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
@@ -140,61 +335,10 @@ const onDetect = (detectedCodes) => {
processCodes(validCodes)
}
const onCameraOn = async (capabilities) => {
// 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
watch(facingMode, () => {
startScan()
})
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) => {
if (err.name === 'NotAllowedError') {
error.value = 'Camera permission denied'
@@ -233,11 +377,18 @@ watch(scannedCodes, (newVal) => {
localStorage.setItem('qr-history', JSON.stringify(newVal))
}, { deep: true })
const handleKeydown = (e) => {
if (e.key === 'Escape' && isFullscreen.value) {
toggleFullscreen()
}
}
onMounted(() => {
checkCameras()
loadHistory()
window.addEventListener('resize', updateVideoAspect)
window.addEventListener('resize', startBackgroundLoop)
window.addEventListener('keydown', handleKeydown)
watch(isFullscreen, (fs) => {
if (fs) {
startBackgroundLoop()
@@ -245,12 +396,15 @@ onMounted(() => {
stopBackgroundLoop()
}
}, { immediate: true })
startScan()
})
onUnmounted(() => {
window.removeEventListener('resize', updateVideoAspect)
window.removeEventListener('resize', startBackgroundLoop)
stopBackgroundLoop()
window.removeEventListener('keydown', handleKeydown)
stopScan()
})
const switchCamera = (event) => {
@@ -324,6 +478,7 @@ const isUrl = (string) => {
v-if="isFullscreen"
ref="bgCanvas"
class="camera-bg"
:class="{ 'is-mirrored': isMirrored }"
></canvas>
<button v-if="isFullscreen" class="close-fullscreen-btn" @click="toggleFullscreen">
<X size="24" />
@@ -331,31 +486,35 @@ const isUrl = (string) => {
<div
class="camera-wrapper"
:class="{ 'clickable': !isFullscreen, 'is-front': isFront }"
:class="{ 'clickable': !isFullscreen, 'is-mirrored': isMirrored }"
:style="desktopFullscreenStyle"
ref="wrapperRef"
@click="!isFullscreen && toggleFullscreen()"
>
<QrcodeStream
:constraints="{ facingMode }"
@detect="onDetect"
@error="onError"
@camera-on="onCameraOn"
:track="paintDetections"
>
<div v-if="error" class="error-overlay">
<p>{{ error }}</p>
</div>
<video
ref="videoRef"
class="camera-feed"
:class="{ 'is-mirrored': isMirrored }"
autoplay
playsinline
muted
></video>
<button
v-if="hasMultipleCameras"
class="switch-camera-btn"
@click.stop="switchCamera"
title="Switch Camera"
>
<SwitchCamera size="24" />
</button>
</QrcodeStream>
<canvas ref="overlayCanvas" class="scan-overlay-canvas" :class="{ 'is-mirrored': isMirrored }"></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">
@@ -488,6 +647,35 @@ const isUrl = (string) => {
z-index: 0;
}
.camera-bg.is-mirrored {
transform: scaleX(-1);
}
.camera-feed {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.camera-feed.is-mirrored {
transform: scaleX(-1);
}
.scan-overlay-canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 5;
}
.scan-overlay-canvas.is-mirrored {
transform: scaleX(-1);
}
/* front mirror canvas removed */
.error-overlay {

View File

@@ -1,5 +1,5 @@
<script setup>
import { ref, computed } from 'vue'
import { ref, computed, watch, onUnmounted } from 'vue'
import { X, Plus, Trash2, RotateCcw } from 'lucide-vue-next'
const props = defineProps({
@@ -10,6 +10,24 @@ const props = defineProps({
const emit = defineEmits(['close', 'update:exceptions'])
const handleKeydown = (e) => {
if (e.key === 'Escape') {
emit('close')
}
}
watch(() => props.isOpen, (isOpen) => {
if (isOpen) {
window.addEventListener('keydown', handleKeydown)
} else {
window.removeEventListener('keydown', handleKeydown)
}
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
})
const newRule = ref({
domainPattern: '',
keepParams: '',

View File

@@ -5,7 +5,7 @@ export default {
</script>
<script setup>
import { ref } from 'vue'
import { ref, watch, onUnmounted } from 'vue'
import { Plug, Plus, X } from 'lucide-vue-next'
const props = defineProps({
@@ -13,6 +13,24 @@ const props = defineProps({
})
const showModal = ref(false)
const handleKeydown = (e) => {
if (e.key === 'Escape' && showModal.value) {
showModal.value = false
}
}
watch(showModal, (isOpen) => {
if (isOpen) {
window.addEventListener('keydown', handleKeydown)
} else {
window.removeEventListener('keydown', handleKeydown)
}
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
})
</script>
<template>

View File

@@ -3,6 +3,27 @@ import './style.css'
import App from './App.vue'
import router from './router'
import Ripple from './directives/ripple'
import { BarcodeDetector, prepareZXingModule } from 'barcode-detector/ponyfill'
// Configure BarcodeDetector polyfill to use local WASM file
try {
prepareZXingModule({
overrides: {
locateFile: (path, prefix) => {
if (path.endsWith('.wasm')) {
return '/wasm/zxing_reader.wasm'
}
return prefix + path
}
}
})
// Force usage of polyfill to avoid CSP issues and ensure consistent behavior
// Native implementation might fail or be missing in some browsers
window.BarcodeDetector = BarcodeDetector
} catch (e) {
console.error('Failed to initialize BarcodeDetector polyfill', e)
}
const app = createApp(App)