refactor: extract logic from QrScanner and UrlCleaner to composables
This commit is contained in:
@@ -1,113 +1,41 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
|
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
|
||||||
import { SwitchCamera, Trash2, Copy, Download, X, Maximize2, Minimize2 } from 'lucide-vue-next'
|
import { SwitchCamera, Trash2, Copy, Download, X } from 'lucide-vue-next'
|
||||||
|
import { useCamera } from '../../composables/useCamera'
|
||||||
|
import { useQrDetection } from '../../composables/useQrDetection'
|
||||||
|
|
||||||
const error = ref('')
|
|
||||||
const facingMode = ref('environment')
|
|
||||||
const scannedCodes = ref([])
|
const scannedCodes = ref([])
|
||||||
const hasMultipleCameras = ref(false)
|
|
||||||
const isFullscreen = ref(false)
|
const isFullscreen = ref(false)
|
||||||
const videoAspect = ref(1)
|
const videoAspect = ref(1)
|
||||||
const isMirrored = ref(false)
|
|
||||||
const wrapperRef = ref(null)
|
const wrapperRef = ref(null)
|
||||||
const bgCanvas = ref(null)
|
const bgCanvas = ref(null)
|
||||||
let bgRafId = null
|
let bgRafId = null
|
||||||
|
|
||||||
// Native scanner state
|
|
||||||
const videoRef = ref(null)
|
const videoRef = ref(null)
|
||||||
let stream = null
|
|
||||||
let scanRafId = null
|
|
||||||
let barcodeDetector = null
|
|
||||||
|
|
||||||
const overlayCanvas = ref(null)
|
const overlayCanvas = ref(null)
|
||||||
|
|
||||||
const paintDetections = (codes) => {
|
const {
|
||||||
const canvas = overlayCanvas.value
|
stream,
|
||||||
const video = videoRef.value
|
facingMode,
|
||||||
|
hasMultipleCameras,
|
||||||
|
isMirrored,
|
||||||
|
error: cameraError,
|
||||||
|
checkCameras,
|
||||||
|
startCamera,
|
||||||
|
stopCamera,
|
||||||
|
switchCamera: baseSwitchCamera
|
||||||
|
} = useCamera(videoRef)
|
||||||
|
|
||||||
if (!canvas || !video) return
|
const {
|
||||||
|
error: detectionError,
|
||||||
|
isDetecting,
|
||||||
|
startDetection,
|
||||||
|
stopDetection
|
||||||
|
} = useQrDetection(videoRef, overlayCanvas)
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d')
|
const error = computed(() => cameraError.value || detectionError.value)
|
||||||
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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Background Loop
|
||||||
const updateVideoAspect = () => {
|
const updateVideoAspect = () => {
|
||||||
if (videoRef.value && videoRef.value.videoWidth && videoRef.value.videoHeight) {
|
if (videoRef.value && videoRef.value.videoWidth && videoRef.value.videoHeight) {
|
||||||
videoAspect.value = videoRef.value.videoWidth / videoRef.value.videoHeight
|
videoAspect.value = videoRef.value.videoWidth / videoRef.value.videoHeight
|
||||||
@@ -136,7 +64,6 @@ const startBackgroundLoop = () => {
|
|||||||
}
|
}
|
||||||
const ctx = canvas.getContext('2d')
|
const ctx = canvas.getContext('2d')
|
||||||
if (ctx) {
|
if (ctx) {
|
||||||
// cover horizontally: scale by width, crop top/bottom
|
|
||||||
const scale = cw / vw
|
const scale = cw / vw
|
||||||
const srcH = ch / scale
|
const srcH = ch / scale
|
||||||
const sx = 0
|
const sx = 0
|
||||||
@@ -157,107 +84,7 @@ const stopBackgroundLoop = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const initDetector = async () => {
|
// Full screen styles
|
||||||
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(() => {
|
const desktopFullscreenStyle = computed(() => {
|
||||||
if (!isFullscreen.value) return {}
|
if (!isFullscreen.value) return {}
|
||||||
const isDesktop = window.innerWidth >= 768
|
const isDesktop = window.innerWidth >= 768
|
||||||
@@ -282,48 +109,37 @@ const processCodes = (codes) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onDetect = (detectedCodes) => {
|
const onDetect = (detectedCodes) => {
|
||||||
// If fullscreen, accept all detected codes (as the user sees the full camera view mostly)
|
|
||||||
if (isFullscreen.value) {
|
if (isFullscreen.value) {
|
||||||
processCodes(detectedCodes)
|
processCodes(detectedCodes)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to find video element to calculate visible area
|
|
||||||
const videoEl = document.querySelector('.camera-wrapper video')
|
const videoEl = document.querySelector('.camera-wrapper video')
|
||||||
|
|
||||||
if (!videoEl || !videoEl.videoWidth || !videoEl.videoHeight) {
|
if (!videoEl || !videoEl.videoWidth || !videoEl.videoHeight) {
|
||||||
processCodes(detectedCodes)
|
processCodes(detectedCodes)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const { videoWidth, videoHeight } = videoEl
|
const { videoWidth, videoHeight } = videoEl
|
||||||
|
|
||||||
// Calculate visible square area (assuming object-fit: cover and 1:1 container)
|
|
||||||
const isLandscape = videoWidth > videoHeight
|
const isLandscape = videoWidth > videoHeight
|
||||||
let visibleX, visibleY, visibleW, visibleH
|
let visibleX, visibleY, visibleW, visibleH
|
||||||
|
|
||||||
if (isLandscape) {
|
if (isLandscape) {
|
||||||
// Landscape: sides are cropped, height is fully visible
|
|
||||||
visibleH = videoHeight
|
visibleH = videoHeight
|
||||||
visibleW = videoHeight // Square
|
visibleW = videoHeight
|
||||||
visibleX = (videoWidth - videoHeight) / 2
|
visibleX = (videoWidth - videoHeight) / 2
|
||||||
visibleY = 0
|
visibleY = 0
|
||||||
} else {
|
} else {
|
||||||
// Portrait: top/bottom are cropped, width is fully visible
|
|
||||||
visibleW = videoWidth
|
visibleW = videoWidth
|
||||||
visibleH = videoWidth // Square
|
visibleH = videoWidth
|
||||||
visibleX = 0
|
visibleX = 0
|
||||||
visibleY = (videoHeight - videoWidth) / 2
|
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 => {
|
const validCodes = detectedCodes.filter(code => {
|
||||||
if (!code.boundingBox) return true
|
if (!code.boundingBox) return true
|
||||||
const { x, y, width, height } = code.boundingBox
|
const { x, y, width, height } = code.boundingBox
|
||||||
const centerX = x + width / 2
|
const centerX = x + width / 2
|
||||||
const centerY = y + height / 2
|
const centerY = y + height / 2
|
||||||
|
|
||||||
return (
|
return (
|
||||||
centerX >= visibleX &&
|
centerX >= visibleX &&
|
||||||
centerX <= visibleX + visibleW &&
|
centerX <= visibleX + visibleW &&
|
||||||
@@ -335,33 +151,6 @@ const onDetect = (detectedCodes) => {
|
|||||||
processCodes(validCodes)
|
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 = () => {
|
const loadHistory = () => {
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem('qr-history')
|
const saved = localStorage.getItem('qr-history')
|
||||||
@@ -383,12 +172,24 @@ const handleKeydown = (e) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const startScan = async () => {
|
||||||
|
try {
|
||||||
|
await startCamera()
|
||||||
|
updateVideoAspect()
|
||||||
|
startBackgroundLoop()
|
||||||
|
startDetection(onDetect)
|
||||||
|
} catch (err) {
|
||||||
|
// Error is handled by error computed property
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
checkCameras()
|
checkCameras()
|
||||||
loadHistory()
|
loadHistory()
|
||||||
window.addEventListener('resize', updateVideoAspect)
|
window.addEventListener('resize', updateVideoAspect)
|
||||||
window.addEventListener('resize', startBackgroundLoop)
|
window.addEventListener('resize', startBackgroundLoop)
|
||||||
window.addEventListener('keydown', handleKeydown)
|
window.addEventListener('keydown', handleKeydown)
|
||||||
|
|
||||||
watch(isFullscreen, (fs) => {
|
watch(isFullscreen, (fs) => {
|
||||||
if (fs) {
|
if (fs) {
|
||||||
startBackgroundLoop()
|
startBackgroundLoop()
|
||||||
@@ -396,7 +197,7 @@ onMounted(() => {
|
|||||||
stopBackgroundLoop()
|
stopBackgroundLoop()
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
startScan()
|
startScan()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -404,12 +205,13 @@ onUnmounted(() => {
|
|||||||
window.removeEventListener('resize', updateVideoAspect)
|
window.removeEventListener('resize', updateVideoAspect)
|
||||||
window.removeEventListener('resize', startBackgroundLoop)
|
window.removeEventListener('resize', startBackgroundLoop)
|
||||||
window.removeEventListener('keydown', handleKeydown)
|
window.removeEventListener('keydown', handleKeydown)
|
||||||
stopScan()
|
stopDetection()
|
||||||
|
stopCamera()
|
||||||
})
|
})
|
||||||
|
|
||||||
const switchCamera = (event) => {
|
const switchCamera = (event) => {
|
||||||
event.stopPropagation()
|
if (event) event.stopPropagation()
|
||||||
facingMode.value = facingMode.value === 'environment' ? 'user' : 'environment'
|
baseSwitchCamera()
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleFullscreen = () => {
|
const toggleFullscreen = () => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { ref, watch, onUnmounted } from 'vue'
|
import { ref, watch, onUnmounted } from 'vue'
|
||||||
import { Copy, Trash2, ExternalLink, Power, Zap, X, Settings, Download } from 'lucide-vue-next'
|
import { Copy, Trash2, ExternalLink, Power, Zap, X, Settings, Download } from 'lucide-vue-next'
|
||||||
import { useExtension } from '../../composables/useExtension'
|
import { useExtension } from '../../composables/useExtension'
|
||||||
import { useLocalStorage } from '../../composables/useLocalStorage'
|
import { useUrlCleaner } from '../../composables/useUrlCleaner'
|
||||||
import ExtensionStatus from './common/ExtensionStatus.vue'
|
import ExtensionStatus from './common/ExtensionStatus.vue'
|
||||||
import UrlCleanerExceptionsModal from './UrlCleanerExceptionsModal.vue'
|
import UrlCleanerExceptionsModal from './UrlCleanerExceptionsModal.vue'
|
||||||
|
|
||||||
@@ -10,29 +10,22 @@ import UrlCleanerExceptionsModal from './UrlCleanerExceptionsModal.vue'
|
|||||||
const { isExtensionReady, isListening, lastClipboardText, startListening, stopListening, writeClipboard } = useExtension()
|
const { isExtensionReady, isListening, lastClipboardText, startListening, stopListening, writeClipboard } = useExtension()
|
||||||
|
|
||||||
const inputUrl = ref('')
|
const inputUrl = ref('')
|
||||||
// Use local storage for history persistence
|
|
||||||
const cleanedHistory = useLocalStorage('url-cleaner-history', [])
|
|
||||||
const isWatchEnabled = useLocalStorage('url-cleaner-watch-enabled', false)
|
|
||||||
|
|
||||||
// Exceptions management
|
|
||||||
const showExceptionsModal = ref(false)
|
const showExceptionsModal = ref(false)
|
||||||
const defaultExceptions = [
|
|
||||||
{ id: 'yt', domainPattern: '*.youtube.com', keepParams: ['v', 't'], keepHash: false, keepAllParams: false, isEnabled: true, isDefault: true },
|
|
||||||
{ id: 'yt-short', domainPattern: 'youtu.be', keepParams: ['t'], keepHash: false, keepAllParams: false, isEnabled: true, isDefault: true }
|
|
||||||
]
|
|
||||||
const exceptions = useLocalStorage('url-cleaner-exceptions', defaultExceptions)
|
|
||||||
|
|
||||||
// Helper to match domain with glob pattern
|
const {
|
||||||
const matchDomain = (pattern, domain) => {
|
cleanedHistory,
|
||||||
// Escape regex chars except *
|
isWatchEnabled,
|
||||||
const regexString = '^' + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$'
|
exceptions,
|
||||||
return new RegExp(regexString, 'i').test(domain)
|
defaultExceptions,
|
||||||
}
|
processUrl: baseProcessUrl,
|
||||||
|
removeEntry,
|
||||||
|
clearHistory
|
||||||
|
} = useUrlCleaner()
|
||||||
|
|
||||||
// Watch for clipboard changes from extension
|
// Watch for clipboard changes from extension
|
||||||
watch(lastClipboardText, (newText) => {
|
watch(lastClipboardText, (newText) => {
|
||||||
if (isWatchEnabled.value && newText) {
|
if (isWatchEnabled.value && newText) {
|
||||||
processUrl(newText, true)
|
baseProcessUrl(newText, true, writeClipboard)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -94,114 +87,16 @@ const handleClean = () => {
|
|||||||
if (inputUrl.value) {
|
if (inputUrl.value) {
|
||||||
const urls = inputUrl.value.split(/\r?\n/).filter(line => line.trim().length > 0)
|
const urls = inputUrl.value.split(/\r?\n/).filter(line => line.trim().length > 0)
|
||||||
urls.forEach(url => {
|
urls.forEach(url => {
|
||||||
processUrl(url.trim(), false)
|
baseProcessUrl(url.trim(), false, writeClipboard)
|
||||||
})
|
})
|
||||||
inputUrl.value = ''
|
inputUrl.value = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const processUrl = (text, autoClipboard = false) => {
|
|
||||||
try {
|
|
||||||
// Basic URL validation
|
|
||||||
if (!text.match(/^https?:\/\//i)) {
|
|
||||||
// Not a URL, ignore in watch mode
|
|
||||||
if (autoClipboard) return
|
|
||||||
}
|
|
||||||
|
|
||||||
const originalLength = text.length
|
|
||||||
let cleanedUrl = text
|
|
||||||
|
|
||||||
try {
|
|
||||||
const urlObj = new URL(text)
|
|
||||||
const hostname = urlObj.hostname
|
|
||||||
|
|
||||||
// Check for exceptions
|
|
||||||
const matchedRule = exceptions.value.find(rule =>
|
|
||||||
rule.isEnabled && matchDomain(rule.domainPattern, hostname)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (matchedRule) {
|
|
||||||
if (!matchedRule.keepAllParams) {
|
|
||||||
// Exception logic: keep specific params
|
|
||||||
const params = new URLSearchParams(urlObj.search)
|
|
||||||
const keys = Array.from(params.keys())
|
|
||||||
|
|
||||||
for (const key of keys) {
|
|
||||||
if (!matchedRule.keepParams.includes(key)) {
|
|
||||||
params.delete(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
urlObj.search = params.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!matchedRule.keepHash) {
|
|
||||||
urlObj.hash = ''
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Default behavior: remove all query params and hash
|
|
||||||
if (urlObj.search || urlObj.hash) {
|
|
||||||
urlObj.search = ''
|
|
||||||
urlObj.hash = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanedUrl = urlObj.toString()
|
|
||||||
// Remove trailing slash if it wasn't there before? usually keep it standard
|
|
||||||
} catch (e) {
|
|
||||||
// Invalid URL format
|
|
||||||
if (!autoClipboard) {
|
|
||||||
// Show error or just return original
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no change, ignore in watch mode to avoid loops
|
|
||||||
if (cleanedUrl === text && autoClipboard) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const newLength = cleanedUrl.length
|
|
||||||
const savedChars = originalLength - newLength
|
|
||||||
const savedPercent = originalLength > 0 ? Math.round((savedChars / originalLength) * 100) : 0
|
|
||||||
|
|
||||||
// Add to history
|
|
||||||
const entry = {
|
|
||||||
id: Date.now(),
|
|
||||||
original: text,
|
|
||||||
cleaned: cleanedUrl,
|
|
||||||
savedPercent,
|
|
||||||
timestamp: new Date().toLocaleTimeString()
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanedHistory.value.unshift(entry)
|
|
||||||
|
|
||||||
// Limit history
|
|
||||||
if (cleanedHistory.value.length > 50) {
|
|
||||||
cleanedHistory.value.pop()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-copy back to clipboard if in watch mode
|
|
||||||
if (autoClipboard && savedChars > 0) {
|
|
||||||
writeClipboard(cleanedUrl)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error processing URL:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const copyToClipboard = (text) => {
|
const copyToClipboard = (text) => {
|
||||||
navigator.clipboard.writeText(text)
|
navigator.clipboard.writeText(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeEntry = (id) => {
|
|
||||||
cleanedHistory.value = cleanedHistory.value.filter(item => item.id !== id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearHistory = () => {
|
|
||||||
cleanedHistory.value = []
|
|
||||||
}
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (isListening.value) {
|
if (isListening.value) {
|
||||||
stopListening()
|
stopListening()
|
||||||
|
|||||||
110
src/composables/useCamera.js
Normal file
110
src/composables/useCamera.js
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { ref, watch, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
export function useCamera(videoRef) {
|
||||||
|
const stream = ref(null)
|
||||||
|
const facingMode = ref('environment')
|
||||||
|
const hasMultipleCameras = ref(false)
|
||||||
|
const isMirrored = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
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 stopCamera = () => {
|
||||||
|
if (stream.value) {
|
||||||
|
stream.value.getTracks().forEach(t => t.stop())
|
||||||
|
stream.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startCamera = async () => {
|
||||||
|
stopCamera()
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const constraints = {
|
||||||
|
video: {
|
||||||
|
facingMode: facingMode.value,
|
||||||
|
width: { ideal: 1280 },
|
||||||
|
height: { ideal: 720 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaStream = await navigator.mediaDevices.getUserMedia(constraints)
|
||||||
|
stream.value = mediaStream
|
||||||
|
|
||||||
|
// Detect actual facing mode to mirror front camera correctly
|
||||||
|
const videoTrack = mediaStream.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 = mediaStream
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
videoRef.value.onloadedmetadata = () => {
|
||||||
|
videoRef.value.play().catch(e => console.error('Play error', e))
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (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}`
|
||||||
|
}
|
||||||
|
throw err // Let caller know it failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const switchCamera = () => {
|
||||||
|
facingMode.value = facingMode.value === 'environment' ? 'user' : 'environment'
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(facingMode, () => {
|
||||||
|
if (stream.value) {
|
||||||
|
// Re-start if already running
|
||||||
|
startCamera().catch(() => { })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopCamera()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
stream,
|
||||||
|
facingMode,
|
||||||
|
hasMultipleCameras,
|
||||||
|
isMirrored,
|
||||||
|
error,
|
||||||
|
checkCameras,
|
||||||
|
startCamera,
|
||||||
|
stopCamera,
|
||||||
|
switchCamera
|
||||||
|
}
|
||||||
|
}
|
||||||
174
src/composables/useQrDetection.js
Normal file
174
src/composables/useQrDetection.js
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
export function useQrDetection(videoRef, overlayCanvasRef) {
|
||||||
|
const barcodeDetector = ref(null)
|
||||||
|
const isDetecting = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
let scanRafId = null
|
||||||
|
|
||||||
|
// Function to initialize detector
|
||||||
|
const initDetector = async () => {
|
||||||
|
if (!barcodeDetector.value) {
|
||||||
|
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.value = new window.BarcodeDetector({ formats: ['qr_code'] })
|
||||||
|
} else {
|
||||||
|
barcodeDetector.value = new window.BarcodeDetector()
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback
|
||||||
|
barcodeDetector.value = new window.BarcodeDetector()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error.value = 'Barcode Detection API not supported on this device/browser.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const paintDetections = (codes) => {
|
||||||
|
const canvas = overlayCanvasRef.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
|
||||||
|
// 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 startDetection = async (onDetectCallback) => {
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
await initDetector()
|
||||||
|
if (!barcodeDetector.value) {
|
||||||
|
if (!error.value) error.value = 'Barcode Detector failed to initialize'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isDetecting.value = true
|
||||||
|
|
||||||
|
const detectLoop = async () => {
|
||||||
|
if (!isDetecting.value || !videoRef.value || videoRef.value.paused || videoRef.value.ended) {
|
||||||
|
scanRafId = requestAnimationFrame(detectLoop)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const codes = await barcodeDetector.value.detect(videoRef.value)
|
||||||
|
paintDetections(codes)
|
||||||
|
if (codes.length > 0 && onDetectCallback) {
|
||||||
|
onDetectCallback(codes)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Silent catch for intermittent detection frames failing
|
||||||
|
}
|
||||||
|
if (isDetecting.value) {
|
||||||
|
scanRafId = requestAnimationFrame(detectLoop)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
detectLoop() // start loop
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
error.value = `Detection error: ${e.message}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopDetection = () => {
|
||||||
|
isDetecting.value = false
|
||||||
|
if (scanRafId) cancelAnimationFrame(scanRafId)
|
||||||
|
// Clear canvas
|
||||||
|
if (overlayCanvasRef.value) {
|
||||||
|
const ctx = overlayCanvasRef.value.getContext('2d')
|
||||||
|
ctx.clearRect(0, 0, overlayCanvasRef.value.width, overlayCanvasRef.value.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopDetection()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
error,
|
||||||
|
isDetecting,
|
||||||
|
startDetection,
|
||||||
|
stopDetection
|
||||||
|
}
|
||||||
|
}
|
||||||
115
src/composables/useUrlCleaner.js
Normal file
115
src/composables/useUrlCleaner.js
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { useLocalStorage } from './useLocalStorage'
|
||||||
|
|
||||||
|
export function useUrlCleaner() {
|
||||||
|
const cleanedHistory = useLocalStorage('url-cleaner-history', [])
|
||||||
|
const isWatchEnabled = useLocalStorage('url-cleaner-watch-enabled', false)
|
||||||
|
|
||||||
|
const defaultExceptions = [
|
||||||
|
{ id: 'yt', domainPattern: '*.youtube.com', keepParams: ['v', 't'], keepHash: false, keepAllParams: false, isEnabled: true, isDefault: true },
|
||||||
|
{ id: 'yt-short', domainPattern: 'youtu.be', keepParams: ['t'], keepHash: false, keepAllParams: false, isEnabled: true, isDefault: true }
|
||||||
|
]
|
||||||
|
const exceptions = useLocalStorage('url-cleaner-exceptions', defaultExceptions)
|
||||||
|
|
||||||
|
const matchDomain = (pattern, domain) => {
|
||||||
|
// Escape regex chars except *
|
||||||
|
const regexString = '^' + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$'
|
||||||
|
return new RegExp(regexString, 'i').test(domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
const processUrl = (text, autoClipboard = false, writeClipboardFn = null) => {
|
||||||
|
try {
|
||||||
|
// Basic URL validation
|
||||||
|
if (!text.match(/^https?:\/\//i)) {
|
||||||
|
if (autoClipboard) return text
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalLength = text.length
|
||||||
|
let cleanedUrl = text
|
||||||
|
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(text)
|
||||||
|
const hostname = urlObj.hostname
|
||||||
|
|
||||||
|
const matchedRule = exceptions.value.find(rule =>
|
||||||
|
rule.isEnabled && matchDomain(rule.domainPattern, hostname)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (matchedRule) {
|
||||||
|
if (!matchedRule.keepAllParams) {
|
||||||
|
const params = new URLSearchParams(urlObj.search)
|
||||||
|
const keys = Array.from(params.keys())
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
if (!matchedRule.keepParams.includes(key)) {
|
||||||
|
params.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
urlObj.search = params.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!matchedRule.keepHash) {
|
||||||
|
urlObj.hash = ''
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (urlObj.search || urlObj.hash) {
|
||||||
|
urlObj.search = ''
|
||||||
|
urlObj.hash = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanedUrl = urlObj.toString()
|
||||||
|
} catch (e) {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cleanedUrl === text && autoClipboard) {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
const newLength = cleanedUrl.length
|
||||||
|
const savedChars = originalLength - newLength
|
||||||
|
const savedPercent = originalLength > 0 ? Math.round((savedChars / originalLength) * 100) : 0
|
||||||
|
|
||||||
|
const entry = {
|
||||||
|
id: Date.now(),
|
||||||
|
original: text,
|
||||||
|
cleaned: cleanedUrl,
|
||||||
|
savedPercent,
|
||||||
|
timestamp: new Date().toLocaleTimeString()
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanedHistory.value.unshift(entry)
|
||||||
|
|
||||||
|
if (cleanedHistory.value.length > 50) {
|
||||||
|
cleanedHistory.value.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (autoClipboard && savedChars > 0 && writeClipboardFn) {
|
||||||
|
writeClipboardFn(cleanedUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleanedUrl
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error processing URL:', e)
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeEntry = (id) => {
|
||||||
|
cleanedHistory.value = cleanedHistory.value.filter(item => item.id !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearHistory = () => {
|
||||||
|
cleanedHistory.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cleanedHistory,
|
||||||
|
isWatchEnabled,
|
||||||
|
exceptions,
|
||||||
|
defaultExceptions,
|
||||||
|
processUrl,
|
||||||
|
removeEntry,
|
||||||
|
clearHistory
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user