refactor: extract logic from QrScanner and UrlCleaner to composables

This commit is contained in:
2026-03-03 11:19:57 +00:00
parent ee387d9637
commit dff8a2a0ab
5 changed files with 453 additions and 357 deletions

View File

@@ -1,113 +1,41 @@
<script setup>
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 hasMultipleCameras = ref(false)
const isFullscreen = ref(false)
const videoAspect = ref(1)
const isMirrored = ref(false)
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
const {
stream,
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 { 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 error = computed(() => cameraError.value || detectionError.value)
// Background Loop
const updateVideoAspect = () => {
if (videoRef.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')
if (ctx) {
// cover horizontally: scale by width, crop top/bottom
const scale = cw / vw
const srcH = ch / scale
const sx = 0
@@ -157,107 +84,7 @@ const stopBackgroundLoop = () => {
}
}
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)
}
// Full screen styles
const desktopFullscreenStyle = computed(() => {
if (!isFullscreen.value) return {}
const isDesktop = window.innerWidth >= 768
@@ -282,48 +109,37 @@ const processCodes = (codes) => {
}
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
visibleW = videoHeight
visibleX = (videoWidth - videoHeight) / 2
visibleY = 0
} else {
// Portrait: top/bottom are cropped, width is fully visible
visibleW = videoWidth
visibleH = videoWidth // Square
visibleH = videoWidth
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 &&
@@ -335,33 +151,6 @@ const onDetect = (detectedCodes) => {
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')
@@ -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(() => {
checkCameras()
loadHistory()
window.addEventListener('resize', updateVideoAspect)
window.addEventListener('resize', startBackgroundLoop)
window.addEventListener('keydown', handleKeydown)
watch(isFullscreen, (fs) => {
if (fs) {
startBackgroundLoop()
@@ -404,12 +205,13 @@ onUnmounted(() => {
window.removeEventListener('resize', updateVideoAspect)
window.removeEventListener('resize', startBackgroundLoop)
window.removeEventListener('keydown', handleKeydown)
stopScan()
stopDetection()
stopCamera()
})
const switchCamera = (event) => {
event.stopPropagation()
facingMode.value = facingMode.value === 'environment' ? 'user' : 'environment'
if (event) event.stopPropagation()
baseSwitchCamera()
}
const toggleFullscreen = () => {

View File

@@ -2,7 +2,7 @@
import { ref, watch, onUnmounted } from 'vue'
import { Copy, Trash2, ExternalLink, Power, Zap, X, Settings, Download } from 'lucide-vue-next'
import { useExtension } from '../../composables/useExtension'
import { useLocalStorage } from '../../composables/useLocalStorage'
import { useUrlCleaner } from '../../composables/useUrlCleaner'
import ExtensionStatus from './common/ExtensionStatus.vue'
import UrlCleanerExceptionsModal from './UrlCleanerExceptionsModal.vue'
@@ -10,29 +10,22 @@ import UrlCleanerExceptionsModal from './UrlCleanerExceptionsModal.vue'
const { isExtensionReady, isListening, lastClipboardText, startListening, stopListening, writeClipboard } = useExtension()
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 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 matchDomain = (pattern, domain) => {
// Escape regex chars except *
const regexString = '^' + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$'
return new RegExp(regexString, 'i').test(domain)
}
const {
cleanedHistory,
isWatchEnabled,
exceptions,
defaultExceptions,
processUrl: baseProcessUrl,
removeEntry,
clearHistory
} = useUrlCleaner()
// Watch for clipboard changes from extension
watch(lastClipboardText, (newText) => {
if (isWatchEnabled.value && newText) {
processUrl(newText, true)
baseProcessUrl(newText, true, writeClipboard)
}
})
@@ -94,114 +87,16 @@ const handleClean = () => {
if (inputUrl.value) {
const urls = inputUrl.value.split(/\r?\n/).filter(line => line.trim().length > 0)
urls.forEach(url => {
processUrl(url.trim(), false)
baseProcessUrl(url.trim(), false, writeClipboard)
})
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) => {
navigator.clipboard.writeText(text)
}
const removeEntry = (id) => {
cleanedHistory.value = cleanedHistory.value.filter(item => item.id !== id)
}
const clearHistory = () => {
cleanedHistory.value = []
}
onUnmounted(() => {
if (isListening.value) {
stopListening()

View 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
}
}

View 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
}
}

View 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
}
}