|
<!DOCTYPE html> |
|
<html lang="en"> |
|
|
|
<head> |
|
<meta charset="UTF-8"> |
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
<title>Local Image Viewer</title> |
|
|
|
<script src="https://cdn.tailwindcss.com"></script> |
|
<script> |
|
tailwind.config = { |
|
theme: { |
|
extend: { |
|
colors: { |
|
primary: '#6366F1', |
|
secondary: '#8B5CF6', |
|
accent: '#EC4899', |
|
dark: '#1E293B', |
|
light: '#F8FAFC' |
|
}, |
|
fontFamily: { |
|
sans: ['Inter', 'sans-serif'], |
|
}, |
|
} |
|
} |
|
} |
|
</script> |
|
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> |
|
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"> |
|
<style> |
|
:root { |
|
--transition-speed: 0.3s; |
|
} |
|
|
|
body { |
|
font-family: 'Inter', sans-serif; |
|
background-color: #F1F5F9; |
|
} |
|
|
|
.dropzone { |
|
border: 3px dashed #CBD5E1; |
|
transition: all var(--transition-speed) ease; |
|
background-color: rgba(248, 250, 252, 0.7); |
|
backdrop-filter: blur(4px); |
|
} |
|
|
|
.dropzone.active { |
|
border-color: #6366F1; |
|
background-color: rgba(99, 102, 241, 0.1); |
|
box-shadow: 0 4px 15px rgba(99, 102, 241, 0.1); |
|
} |
|
|
|
.image-container { |
|
transition: transform var(--transition-speed) ease; |
|
box-shadow: 0 4px 25px rgba(0, 0, 0, 0.05); |
|
background: linear-gradient(135deg, #F8FAFC 0%, #E2E8F0 100%); |
|
} |
|
|
|
.image-container:hover { |
|
transform: translateY(-2px); |
|
} |
|
|
|
.nav-btn { |
|
transition: all 0.2s ease; |
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); |
|
} |
|
|
|
.nav-btn:hover { |
|
transform: scale(1.1); |
|
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15); |
|
} |
|
|
|
.nav-btn:active { |
|
transform: scale(0.95); |
|
} |
|
|
|
.file-item { |
|
transition: all var(--transition-speed) ease; |
|
} |
|
|
|
.file-item:hover { |
|
background-color: rgba(99, 102, 241, 0.05); |
|
transform: translateX(2px); |
|
} |
|
|
|
.file-item.active { |
|
background-color: rgba(99, 102, 241, 0.1); |
|
border-left: 3px solid #6366F1; |
|
} |
|
|
|
@keyframes fadeIn { |
|
from { |
|
opacity: 0; |
|
transform: translateY(10px); |
|
} |
|
to { |
|
opacity: 1; |
|
transform: translateY(0); |
|
} |
|
} |
|
|
|
.fade-in { |
|
animation: fadeIn 0.4s ease-out forwards; |
|
} |
|
|
|
#fullscreenContainer { |
|
display: none; |
|
position: fixed; |
|
top: 0; |
|
left: 0; |
|
width: 100%; |
|
height: 100%; |
|
background-color: rgba(15, 23, 42, 0.95); |
|
z-index: 1000; |
|
justify-content: center; |
|
align-items: center; |
|
flex-direction: column; |
|
} |
|
|
|
#fullscreenImage { |
|
max-width: 90%; |
|
max-height: 90%; |
|
object-fit: contain; |
|
transform: translate3d(0, 0, 0); |
|
} |
|
|
|
#fullscreenControls { |
|
position: absolute; |
|
bottom: 20px; |
|
display: flex; |
|
gap: 10px; |
|
} |
|
|
|
.sort-option:hover { |
|
background-color: rgba(99, 102, 241, 0.05); |
|
} |
|
|
|
.thumbnail-placeholder { |
|
background: linear-gradient(135deg, #E2E8F0 0%, #CBD5E1 100%); |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
border-radius: 4px; |
|
} |
|
|
|
.thumbnail-placeholder i { |
|
color: #94A3B8; |
|
} |
|
|
|
.progress-track { |
|
background-color: #E2E8F0; |
|
} |
|
|
|
.progress-thumb { |
|
background: linear-gradient(90deg, #6366F1 0%, #8B5CF6 100%); |
|
} |
|
|
|
.btn-primary { |
|
background: linear-gradient(135deg, #6366F1 0%, #8B5CF6 100%); |
|
color: white; |
|
transition: all var(--transition-speed) ease; |
|
box-shadow: 0 4px 6px rgba(99, 102, 241, 0.2); |
|
} |
|
|
|
.btn-primary:hover { |
|
transform: translateY(-2px); |
|
box-shadow: 0 6px 12px rgba(99, 102, 241, 0.3); |
|
} |
|
|
|
.btn-primary:active { |
|
transform: translateY(0); |
|
} |
|
|
|
.glass-effect { |
|
background: rgba(255, 255, 255, 0.2); |
|
backdrop-filter: blur(8px); |
|
-webkit-backdrop-filter: blur(8px); |
|
border: 1px solid rgba(255, 255, 255, 0.2); |
|
} |
|
|
|
.zoom-controls { |
|
background: rgba(248, 250, 252, 0.8); |
|
backdrop-filter: blur(4px); |
|
border-radius: 8px; |
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); |
|
} |
|
|
|
.file-list-container { |
|
scrollbar-width: thin; |
|
scrollbar-color: #6366F1 #E2E8F0; |
|
} |
|
|
|
.file-list-container::-webkit-scrollbar { |
|
width: 6px; |
|
} |
|
|
|
.file-list-container::-webkit-scrollbar-track { |
|
background: #E2E8F0; |
|
} |
|
|
|
.file-list-container::-webkit-scrollbar-thumb { |
|
background-color: #6366F1; |
|
border-radius: 3px; |
|
} |
|
|
|
.gpu-accelerate { |
|
transform: translate3d(0, 0, 0); |
|
will-change: transform; |
|
} |
|
</style> |
|
</head> |
|
|
|
<body class="bg-light min-h-screen"> |
|
<div class="container mx-auto px-4 py-8"> |
|
<div class="max-w-6xl mx-auto"> |
|
<div class="text-center mb-8"> |
|
<h1 class="text-4xl font-bold text-dark mb-2">Local Image Viewer</h1> |
|
<p class="text-lg text-slate-600">View your local WebP, PNG, JPEG, AVIF, and HEIC files with ease</p> |
|
</div> |
|
|
|
<div class="bg-white rounded-xl shadow-xl overflow-hidden mb-8"> |
|
|
|
<div id="dropzone" class="dropzone p-12 text-center cursor-pointer rounded-xl" role="region" |
|
aria-label="File drop zone"> |
|
<div class="flex flex-col items-center justify-center"> |
|
<div class="relative mb-6"> |
|
<div class="absolute inset-0 bg-primary opacity-10 rounded-full blur-md"></div> |
|
<i class="fas fa-images text-5xl text-primary relative z-10" aria-hidden="true"></i> |
|
</div> |
|
<h3 class="text-xl font-semibold text-dark mb-2">Drag & Drop Images Here</h3> |
|
<p class="text-slate-500 mb-4">or</p> |
|
<button id="browseBtn" |
|
class="btn-primary font-medium py-3 px-8 rounded-lg transition" |
|
aria-label="Browse files"> |
|
Browse Files |
|
</button> |
|
<input type="file" id="fileInput" class="hidden" |
|
accept=".webp,.png,.jpg,.jpeg,.avif,.heic,.heif" multiple> |
|
</div> |
|
</div> |
|
|
|
|
|
<div id="viewerArea" class="hidden"> |
|
<div class="flex flex-col md:flex-row h-[70vh]"> |
|
|
|
<div class="w-full md:w-1/4 bg-slate-50 border-r border-slate-200 file-list-container overflow-y-auto"> |
|
<div class="p-4 border-b border-slate-200 flex justify-between items-center bg-white"> |
|
<h3 class="font-medium text-dark">Files (<span id="fileCount">0</span>)</h3> |
|
<div class="relative"> |
|
<button id="sortBtn" class="text-slate-600 hover:text-primary transition-colors" |
|
aria-label="Sort options" aria-haspopup="true" aria-expanded="false"> |
|
<i class="fas fa-sort" aria-hidden="true"></i> |
|
</button> |
|
<div id="sortDropdown" |
|
class="hidden absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg z-10 py-1 border border-slate-200"> |
|
<div class="sort-option px-4 py-2 text-sm text-slate-700 cursor-pointer hover:bg-slate-50 transition-colors" |
|
data-sort="name-asc" role="menuitem">Name (A-Z)</div> |
|
<div class="sort-option px-4 py-2 text-sm text-slate-700 cursor-pointer hover:bg-slate-50 transition-colors" |
|
data-sort="name-desc" role="menuitem">Name (Z-A)</div> |
|
<div class="sort-option px-4 py-2 text-sm text-slate-700 cursor-pointer hover:bg-slate-50 transition-colors" |
|
data-sort="size-asc" role="menuitem">Size (Small to Large)</div> |
|
<div class="sort-option px-4 py-2 text-sm text-slate-700 cursor-pointer hover:bg-slate-50 transition-colors" |
|
data-sort="size-desc" role="menuitem">Size (Large to Small)</div> |
|
<div class="sort-option px-4 py-2 text-sm text-slate-700 cursor-pointer hover:bg-slate-50 transition-colors" |
|
data-sort="date-asc" role="menuitem">Date (Oldest First)</div> |
|
<div class="sort-option px-4 py-2 text-sm text-slate-700 cursor-pointer hover:bg-slate-50 transition-colors" |
|
data-sort="date-desc" role="menuitem">Date (Newest First)</div> |
|
</div> |
|
</div> |
|
</div> |
|
<ul id="fileList" class="divide-y divide-slate-200" role="list"> |
|
|
|
</ul> |
|
</div> |
|
|
|
|
|
<div class="w-full md:w-3/4 p-4 flex flex-col items-center justify-center bg-white"> |
|
<div class="relative w-full h-full max-w-4xl"> |
|
|
|
<button id="prevBtn" |
|
class="nav-btn absolute left-0 top-1/2 -translate-y-1/2 bg-white hover:bg-primary text-primary hover:text-white p-3 rounded-full shadow-md ml-4 z-10 glass-effect" |
|
aria-label="Previous image"> |
|
<i class="fas fa-chevron-left text-xl" aria-hidden="true"></i> |
|
</button> |
|
|
|
<button id="nextBtn" |
|
class="nav-btn absolute right-0 top-1/2 -translate-y-1/2 bg-white hover:bg-primary text-primary hover:text-white p-3 rounded-full shadow-md mr-4 z-10 glass-effect" |
|
aria-label="Next image"> |
|
<i class="fas fa-chevron-right text-xl" aria-hidden="true"></i> |
|
</button> |
|
|
|
|
|
<div class="image-container bg-gradient-to-br from-slate-50 to-slate-100 rounded-xl overflow-hidden flex items-center justify-center h-full w-full gpu-accelerate"> |
|
<div id="imageDisplay" class="p-4 w-full h-full flex items-center justify-center"> |
|
<p class="text-slate-400">Select an image to view</p> |
|
</div> |
|
</div> |
|
|
|
|
|
<div class="mt-4 bg-slate-50 rounded-lg p-4 zoom-controls"> |
|
<div class="flex justify-between items-center"> |
|
<div class="max-w-[70%]"> |
|
<h4 id="fileName" class="font-medium text-dark truncate">No image selected</h4> |
|
<p id="fileInfo" class="text-sm text-slate-500">-</p> |
|
</div> |
|
<div class="flex space-x-2"> |
|
<button id="zoomInBtn" |
|
class="nav-btn bg-white hover:bg-primary hover:text-white text-slate-700 p-2 rounded transition-colors" |
|
aria-label="Zoom in"> |
|
<i class="fas fa-search-plus" aria-hidden="true"></i> |
|
</button> |
|
<button id="zoomOutBtn" |
|
class="nav-btn bg-white hover:bg-primary hover:text-white text-slate-700 p-2 rounded transition-colors" |
|
aria-label="Zoom out"> |
|
<i class="fas fa-search-minus" aria-hidden="true"></i> |
|
</button> |
|
<button id="resetZoomBtn" |
|
class="nav-btn bg-white hover:bg-primary hover:text-white text-slate-700 p-2 rounded transition-colors" |
|
aria-label="Reset zoom"> |
|
<i class="fas fa-expand" aria-hidden="true"></i> |
|
</button> |
|
<button id="fullscreenBtn" |
|
class="nav-btn bg-white hover:bg-primary hover:text-white text-slate-700 p-2 rounded transition-colors" |
|
aria-label="Fullscreen"> |
|
<i class="fas fa-expand-arrows-alt" aria-hidden="true"></i> |
|
</button> |
|
</div> |
|
</div> |
|
<div class="mt-3"> |
|
<div class="w-full progress-track rounded-full h-2"> |
|
<div id="progressBar" class="progress-thumb h-2 rounded-full" |
|
style="width: 0%"></div> |
|
</div> |
|
<div class="flex justify-between text-xs text-slate-500 mt-1"> |
|
<span id="currentIndex">0</span> |
|
<span id="totalImages">0</span> |
|
</div> |
|
</div> |
|
</div> |
|
</div> |
|
</div> |
|
</div> |
|
</div> |
|
</div> |
|
|
|
<div class="text-center text-slate-500 text-sm mt-8"> |
|
<p>Use arrow keys to navigate between images • Press F for fullscreen</p> |
|
<p class="mt-1">Supported formats: WebP, PNG, JPEG, AVIF, HEIC</p> |
|
</div> |
|
</div> |
|
</div> |
|
|
|
|
|
<div id="fullscreenContainer" role="dialog" aria-modal="true" aria-label="Fullscreen image viewer"> |
|
<img id="fullscreenImage" src="" alt="Fullscreen Image" class="gpu-accelerate"> |
|
<div id="fullscreenControls"> |
|
<button id="fsPrevBtn" class="nav-btn bg-white/20 hover:bg-white/40 text-white p-3 rounded-full glass-effect" |
|
aria-label="Previous image"> |
|
<i class="fas fa-chevron-left text-xl" aria-hidden="true"></i> |
|
</button> |
|
<button id="fsCloseBtn" class="nav-btn bg-white/20 hover:bg-white/40 text-white p-3 rounded-full glass-effect" |
|
aria-label="Close fullscreen"> |
|
<i class="fas fa-times text-xl" aria-hidden="true"></i> |
|
</button> |
|
<button id="fsNextBtn" class="nav-btn bg-white/20 hover:bg-white/40 text-white p-3 rounded-full glass-effect" |
|
aria-label="Next image"> |
|
<i class="fas fa-chevron-right text-xl" aria-hidden="true"></i> |
|
</button> |
|
</div> |
|
</div> |
|
|
|
<script> |
|
document.addEventListener('DOMContentLoaded', function () { |
|
|
|
const throttleRAF = (func) => { |
|
let running = false; |
|
return function() { |
|
if (!running) { |
|
running = true; |
|
window.requestAnimationFrame(() => { |
|
func.apply(this, arguments); |
|
running = false; |
|
}); |
|
} |
|
}; |
|
}; |
|
|
|
const debounce = (func, delay) => { |
|
let timeoutId; |
|
return (...args) => { |
|
clearTimeout(timeoutId); |
|
timeoutId = setTimeout(() => func.apply(this, args), delay); |
|
}; |
|
}; |
|
|
|
|
|
const cleanupHandlers = []; |
|
const addCleanupHandler = (handler) => cleanupHandlers.push(handler); |
|
|
|
|
|
window.addEventListener('beforeunload', () => { |
|
cleanupHandlers.forEach(handler => handler()); |
|
}); |
|
|
|
|
|
const elements = { |
|
dropzone: document.getElementById('dropzone'), |
|
browseBtn: document.getElementById('browseBtn'), |
|
fileInput: document.getElementById('fileInput'), |
|
viewerArea: document.getElementById('viewerArea'), |
|
fileList: document.getElementById('fileList'), |
|
imageDisplay: document.getElementById('imageDisplay'), |
|
fileName: document.getElementById('fileName'), |
|
fileInfo: document.getElementById('fileInfo'), |
|
fileCount: document.getElementById('fileCount'), |
|
prevBtn: document.getElementById('prevBtn'), |
|
nextBtn: document.getElementById('nextBtn'), |
|
zoomInBtn: document.getElementById('zoomInBtn'), |
|
zoomOutBtn: document.getElementById('zoomOutBtn'), |
|
resetZoomBtn: document.getElementById('resetZoomBtn'), |
|
fullscreenBtn: document.getElementById('fullscreenBtn'), |
|
progressBar: document.getElementById('progressBar'), |
|
currentIndex: document.getElementById('currentIndex'), |
|
totalImages: document.getElementById('totalImages'), |
|
sortBtn: document.getElementById('sortBtn'), |
|
sortDropdown: document.getElementById('sortDropdown'), |
|
fullscreenContainer: document.getElementById('fullscreenContainer'), |
|
fullscreenImage: document.getElementById('fullscreenImage'), |
|
fsPrevBtn: document.getElementById('fsPrevBtn'), |
|
fsNextBtn: document.getElementById('fsNextBtn'), |
|
fsCloseBtn: document.getElementById('fsCloseBtn') |
|
}; |
|
|
|
|
|
const state = { |
|
files: [], |
|
currentFileIndex: -1, |
|
zoomLevel: 1, |
|
maxZoom: 3, |
|
minZoom: 0.5, |
|
zoomStep: 0.1, |
|
currentSortMethod: 'name-asc', |
|
thumbnailObserver: null, |
|
isDragging: false, |
|
startX: 0, |
|
startY: 0, |
|
translateX: 0, |
|
translateY: 0, |
|
activeImage: null |
|
}; |
|
|
|
|
|
const init = () => { |
|
setupEventListeners(); |
|
setupThumbnailObserver(); |
|
}; |
|
|
|
|
|
const setupEventListeners = () => { |
|
|
|
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { |
|
elements.dropzone.addEventListener(eventName, preventDefaults, false); |
|
addCleanupHandler(() => { |
|
elements.dropzone.removeEventListener(eventName, preventDefaults, false); |
|
}); |
|
}); |
|
|
|
['dragenter', 'dragover'].forEach(eventName => { |
|
elements.dropzone.addEventListener(eventName, highlight, false); |
|
addCleanupHandler(() => { |
|
elements.dropzone.removeEventListener(eventName, highlight, false); |
|
}); |
|
}); |
|
|
|
['dragleave', 'drop'].forEach(eventName => { |
|
elements.dropzone.addEventListener(eventName, unhighlight, false); |
|
addCleanupHandler(() => { |
|
elements.dropzone.removeEventListener(eventName, unhighlight, false); |
|
}); |
|
}); |
|
|
|
elements.dropzone.addEventListener('drop', handleDrop, false); |
|
addCleanupHandler(() => { |
|
elements.dropzone.removeEventListener('drop', handleDrop, false); |
|
}); |
|
|
|
elements.browseBtn.addEventListener('click', () => elements.fileInput.click()); |
|
elements.fileInput.addEventListener('change', () => { |
|
if (elements.fileInput.files.length > 0) { |
|
handleFiles(elements.fileInput.files); |
|
} |
|
}); |
|
addCleanupHandler(() => { |
|
elements.browseBtn.removeEventListener('click', () => elements.fileInput.click()); |
|
elements.fileInput.removeEventListener('change', () => { |
|
if (elements.fileInput.files.length > 0) { |
|
handleFiles(elements.fileInput.files); |
|
} |
|
}); |
|
}); |
|
|
|
|
|
elements.prevBtn.addEventListener('click', showPreviousImage); |
|
elements.nextBtn.addEventListener('click', showNextImage); |
|
addCleanupHandler(() => { |
|
elements.prevBtn.removeEventListener('click', showPreviousImage); |
|
elements.nextBtn.removeEventListener('click', showNextImage); |
|
}); |
|
|
|
|
|
elements.zoomInBtn.addEventListener('click', debounce(zoomIn, 100)); |
|
elements.zoomOutBtn.addEventListener('click', debounce(zoomOut, 100)); |
|
elements.resetZoomBtn.addEventListener('click', resetZoom); |
|
elements.fullscreenBtn.addEventListener('click', openFullscreen); |
|
addCleanupHandler(() => { |
|
elements.zoomInBtn.removeEventListener('click', debounce(zoomIn, 100)); |
|
elements.zoomOutBtn.removeEventListener('click', debounce(zoomOut, 100)); |
|
elements.resetZoomBtn.removeEventListener('click', resetZoom); |
|
elements.fullscreenBtn.removeEventListener('click', openFullscreen); |
|
}); |
|
|
|
|
|
elements.sortBtn.addEventListener('click', toggleSortDropdown); |
|
document.querySelectorAll('.sort-option').forEach(option => { |
|
option.addEventListener('click', handleSortOptionClick); |
|
}); |
|
document.addEventListener('click', closeSortDropdown); |
|
addCleanupHandler(() => { |
|
elements.sortBtn.removeEventListener('click', toggleSortDropdown); |
|
document.querySelectorAll('.sort-option').forEach(option => { |
|
option.removeEventListener('click', handleSortOptionClick); |
|
}); |
|
document.removeEventListener('click', closeSortDropdown); |
|
}); |
|
|
|
|
|
elements.fsPrevBtn.addEventListener('click', () => { |
|
showPreviousImage(); |
|
updateFullscreenImage(); |
|
}); |
|
elements.fsNextBtn.addEventListener('click', () => { |
|
showNextImage(); |
|
updateFullscreenImage(); |
|
}); |
|
elements.fsCloseBtn.addEventListener('click', closeFullscreen); |
|
addCleanupHandler(() => { |
|
elements.fsPrevBtn.removeEventListener('click', () => { |
|
showPreviousImage(); |
|
updateFullscreenImage(); |
|
}); |
|
elements.fsNextBtn.removeEventListener('click', () => { |
|
showNextImage(); |
|
updateFullscreenImage(); |
|
}); |
|
elements.fsCloseBtn.removeEventListener('click', closeFullscreen); |
|
}); |
|
|
|
|
|
const keyboardHandler = handleKeyboardNavigation; |
|
document.addEventListener('keydown', keyboardHandler); |
|
addCleanupHandler(() => { |
|
document.removeEventListener('keydown', keyboardHandler); |
|
}); |
|
}; |
|
|
|
|
|
const setupThumbnailObserver = () => { |
|
state.thumbnailObserver = new IntersectionObserver((entries) => { |
|
entries.forEach(entry => { |
|
if (entry.isIntersecting) { |
|
const thumbnail = entry.target; |
|
const index = parseInt(thumbnail.dataset.index); |
|
loadThumbnail(index); |
|
state.thumbnailObserver.unobserve(thumbnail); |
|
} |
|
}); |
|
}, { |
|
root: elements.fileList, |
|
rootMargin: '100px', |
|
threshold: 0.1 |
|
}); |
|
|
|
addCleanupHandler(() => { |
|
if (state.thumbnailObserver) { |
|
state.thumbnailObserver.disconnect(); |
|
} |
|
}); |
|
}; |
|
|
|
|
|
const preventDefaults = (e) => { |
|
e.preventDefault(); |
|
e.stopPropagation(); |
|
}; |
|
|
|
const highlight = () => { |
|
elements.dropzone.classList.add('active'); |
|
}; |
|
|
|
const unhighlight = () => { |
|
elements.dropzone.classList.remove('active'); |
|
}; |
|
|
|
const handleDrop = (e) => { |
|
const dt = e.dataTransfer; |
|
const droppedFiles = dt.files; |
|
handleFiles(droppedFiles); |
|
}; |
|
|
|
|
|
const handleFiles = (newFiles) => { |
|
const supportedTypes = [ |
|
'image/webp', |
|
'image/png', |
|
'image/jpeg', |
|
'image/avif', |
|
'image/heic', |
|
'image/heif' |
|
]; |
|
|
|
const imageFiles = Array.from(newFiles).filter(file => { |
|
if (supportedTypes.includes(file.type)) return true; |
|
const extension = file.name.split('.').pop().toLowerCase(); |
|
return ['webp', 'png', 'jpg', 'jpeg', 'avif', 'heic', 'heif'].includes(extension); |
|
}); |
|
|
|
if (imageFiles.length === 0) { |
|
alert('No supported image files found. Please upload WebP, PNG, JPEG, AVIF, or HEIC files.'); |
|
return; |
|
} |
|
|
|
|
|
if (state.activeImage) { |
|
cleanupImageDragHandlers(state.activeImage); |
|
} |
|
|
|
state.files = imageFiles; |
|
state.currentFileIndex = 0; |
|
state.zoomLevel = 1; |
|
state.translateX = 0; |
|
state.translateY = 0; |
|
|
|
sortFiles(); |
|
updateFileList(); |
|
showImage(state.currentFileIndex); |
|
elements.viewerArea.classList.remove('hidden'); |
|
window.scrollTo(0, 0); |
|
}; |
|
|
|
|
|
const cleanupImageDragHandlers = (img) => { |
|
img.removeEventListener('mousedown', handleImageMouseDown); |
|
img.removeEventListener('mouseenter', handleImageMouseEnter); |
|
img.removeEventListener('mouseleave', handleImageMouseLeave); |
|
}; |
|
|
|
|
|
const sortFiles = () => { |
|
switch (state.currentSortMethod) { |
|
case 'name-asc': |
|
state.files.sort((a, b) => a.name.localeCompare(b.name)); |
|
break; |
|
case 'name-desc': |
|
state.files.sort((a, b) => b.name.localeCompare(a.name)); |
|
break; |
|
case 'size-asc': |
|
state.files.sort((a, b) => a.size - b.size); |
|
break; |
|
case 'size-desc': |
|
state.files.sort((a, b) => b.size - a.size); |
|
break; |
|
case 'date-asc': |
|
state.files.sort((a, b) => a.lastModified - b.lastModified); |
|
break; |
|
case 'date-desc': |
|
state.files.sort((a, b) => b.lastModified - a.lastModified); |
|
break; |
|
} |
|
|
|
if (state.currentFileIndex >= 0 && state.files.length > 0) { |
|
state.currentFileIndex = 0; |
|
} |
|
}; |
|
|
|
const toggleSortDropdown = (e) => { |
|
e.stopPropagation(); |
|
const isExpanded = elements.sortDropdown.classList.toggle('hidden'); |
|
elements.sortBtn.setAttribute('aria-expanded', !isExpanded); |
|
}; |
|
|
|
const handleSortOptionClick = (e) => { |
|
state.currentSortMethod = e.target.dataset.sort; |
|
sortFiles(); |
|
updateFileList(); |
|
showImage(state.currentFileIndex); |
|
closeSortDropdown(); |
|
}; |
|
|
|
const closeSortDropdown = () => { |
|
elements.sortDropdown.classList.add('hidden'); |
|
elements.sortBtn.setAttribute('aria-expanded', 'false'); |
|
}; |
|
|
|
|
|
const updateFileList = () => { |
|
elements.fileList.innerHTML = ''; |
|
elements.fileCount.textContent = state.files.length; |
|
elements.totalImages.textContent = state.files.length; |
|
|
|
state.files.forEach((file, index) => { |
|
const listItem = document.createElement('li'); |
|
listItem.className = `file-item cursor-pointer ${index === state.currentFileIndex ? 'active' : ''}`; |
|
listItem.setAttribute('role', 'listitem'); |
|
listItem.innerHTML = ` |
|
<div class="flex items-center p-3"> |
|
<div class="flex-shrink-0 h-10 w-10 rounded overflow-hidden thumbnail-placeholder"> |
|
<i class="fas fa-image text-lg"></i> |
|
<img src="#" alt="Thumbnail" class="h-full w-full object-cover hidden thumbnail" data-index="${index}"> |
|
</div> |
|
<div class="ml-3 overflow-hidden"> |
|
<p class="text-sm font-medium text-dark truncate">${file.name}</p> |
|
<p class="text-sm text-slate-500">${formatFileSize(file.size)}</p> |
|
</div> |
|
</div> |
|
`; |
|
|
|
listItem.addEventListener('click', () => showImage(index)); |
|
listItem.addEventListener('keydown', (e) => { |
|
if (e.key === 'Enter' || e.key === ' ') { |
|
e.preventDefault(); |
|
showImage(index); |
|
} |
|
}); |
|
|
|
listItem.setAttribute('tabindex', '0'); |
|
elements.fileList.appendChild(listItem); |
|
|
|
|
|
const thumbnail = listItem.querySelector('.thumbnail'); |
|
state.thumbnailObserver.observe(thumbnail); |
|
}); |
|
}; |
|
|
|
|
|
const loadThumbnail = (index) => { |
|
const thumbnail = document.querySelector(`.thumbnail[data-index="${index}"]`); |
|
if (!thumbnail || thumbnail.src !== '#') return; |
|
|
|
const file = state.files[index]; |
|
const reader = new FileReader(); |
|
reader.onload = (e) => { |
|
thumbnail.src = e.target.result; |
|
thumbnail.classList.remove('hidden'); |
|
thumbnail.previousElementSibling?.remove(); |
|
}; |
|
reader.readAsDataURL(file); |
|
}; |
|
|
|
|
|
const showImage = (index) => { |
|
if (index < 0 || index >= state.files.length) return; |
|
|
|
|
|
if (state.activeImage) { |
|
cleanupImageDragHandlers(state.activeImage); |
|
} |
|
|
|
state.currentFileIndex = index; |
|
const file = state.files[index]; |
|
|
|
|
|
document.querySelectorAll('.file-item').forEach((item, i) => { |
|
if (i === index) { |
|
item.classList.add('active'); |
|
item.setAttribute('aria-selected', 'true'); |
|
} else { |
|
item.classList.remove('active'); |
|
item.setAttribute('aria-selected', 'false'); |
|
} |
|
}); |
|
|
|
|
|
elements.currentIndex.textContent = index + 1; |
|
elements.progressBar.style.width = `${((index + 1) / state.files.length) * 100}%`; |
|
|
|
|
|
const reader = new FileReader(); |
|
reader.onload = (e) => { |
|
elements.imageDisplay.innerHTML = ''; |
|
|
|
const img = document.createElement('img'); |
|
img.src = e.target.result; |
|
img.className = 'max-w-full max-h-[70vh] object-contain fade-in gpu-accelerate'; |
|
img.style.transform = `scale(${state.zoomLevel}) translate3d(${state.translateX}px, ${state.translateY}px, 0)`; |
|
img.style.transformOrigin = 'center center'; |
|
img.style.transition = 'transform 0.2s ease'; |
|
img.setAttribute('alt', `Preview of ${file.name}`); |
|
|
|
|
|
img.addEventListener('mousedown', handleImageMouseDown); |
|
img.addEventListener('mouseenter', handleImageMouseEnter); |
|
img.addEventListener('mouseleave', handleImageMouseLeave); |
|
|
|
elements.imageDisplay.appendChild(img); |
|
state.activeImage = img; |
|
|
|
|
|
elements.fileName.textContent = file.name; |
|
elements.fileInfo.textContent = `${getFileType(file)} • ${formatFileSize(file.size)}`; |
|
|
|
|
|
img.onload = () => { |
|
elements.fileInfo.textContent = `${img.naturalWidth}×${img.naturalHeight} • ${getFileType(file)} • ${formatFileSize(file.size)}`; |
|
}; |
|
}; |
|
reader.readAsDataURL(file); |
|
|
|
|
|
elements.prevBtn.disabled = index === 0; |
|
elements.nextBtn.disabled = index === state.files.length - 1; |
|
}; |
|
|
|
|
|
const handleImageMouseDown = (e) => { |
|
if (state.zoomLevel <= 1) return; |
|
|
|
state.isDragging = true; |
|
state.startX = e.clientX - state.translateX; |
|
state.startY = e.clientY - state.translateY; |
|
e.target.style.cursor = 'grabbing'; |
|
|
|
|
|
const mouseMoveHandler = throttleRAF(handleImageMouseMove); |
|
const mouseUpHandler = handleImageMouseUp; |
|
|
|
document.addEventListener('mousemove', mouseMoveHandler); |
|
document.addEventListener('mouseup', mouseUpHandler); |
|
|
|
|
|
const cleanup = () => { |
|
document.removeEventListener('mousemove', mouseMoveHandler); |
|
document.removeEventListener('mouseup', mouseUpHandler); |
|
}; |
|
|
|
addCleanupHandler(cleanup); |
|
}; |
|
|
|
const handleImageMouseMove = (e) => { |
|
if (!state.isDragging) return; |
|
|
|
state.translateX = e.clientX - state.startX; |
|
state.translateY = e.clientY - state.startY; |
|
|
|
const img = elements.imageDisplay.querySelector('img'); |
|
if (img) { |
|
img.style.transform = `scale(${state.zoomLevel}) translate3d(${state.translateX}px, ${state.translateY}px, 0)`; |
|
} |
|
}; |
|
|
|
const handleImageMouseUp = () => { |
|
state.isDragging = false; |
|
const img = elements.imageDisplay.querySelector('img'); |
|
if (img) img.style.cursor = 'grab'; |
|
}; |
|
|
|
const handleImageMouseEnter = (e) => { |
|
if (state.zoomLevel > 1) { |
|
e.target.style.cursor = 'grab'; |
|
} |
|
}; |
|
|
|
const handleImageMouseLeave = (e) => { |
|
e.target.style.cursor = 'default'; |
|
}; |
|
|
|
|
|
const showPreviousImage = () => { |
|
if (state.currentFileIndex > 0) { |
|
showImage(state.currentFileIndex - 1); |
|
} |
|
}; |
|
|
|
const showNextImage = () => { |
|
if (state.currentFileIndex < state.files.length - 1) { |
|
showImage(state.currentFileIndex + 1); |
|
} |
|
}; |
|
|
|
|
|
const zoomIn = () => { |
|
if (state.zoomLevel < state.maxZoom) { |
|
state.zoomLevel += state.zoomStep; |
|
applyZoom(); |
|
} |
|
}; |
|
|
|
const zoomOut = () => { |
|
if (state.zoomLevel > state.minZoom) { |
|
state.zoomLevel -= state.zoomStep; |
|
applyZoom(); |
|
} |
|
}; |
|
|
|
const resetZoom = () => { |
|
state.zoomLevel = 1; |
|
state.translateX = 0; |
|
state.translateY = 0; |
|
applyZoom(); |
|
}; |
|
|
|
const applyZoom = () => { |
|
const img = elements.imageDisplay.querySelector('img'); |
|
if (img) { |
|
img.style.transform = `scale(${state.zoomLevel}) translate3d(${state.translateX}px, ${state.translateY}px, 0)`; |
|
|
|
|
|
if (state.zoomLevel <= 1) { |
|
state.translateX = 0; |
|
state.translateY = 0; |
|
img.style.transform = `scale(${state.zoomLevel}) translate3d(0, 0, 0)`; |
|
} |
|
|
|
|
|
if (state.zoomLevel > 1) { |
|
img.style.cursor = 'grab'; |
|
} else { |
|
img.style.cursor = 'default'; |
|
} |
|
} |
|
}; |
|
|
|
|
|
const openFullscreen = () => { |
|
const img = elements.imageDisplay.querySelector('img'); |
|
if (!img) return; |
|
|
|
elements.fullscreenImage.src = img.src; |
|
elements.fullscreenContainer.style.display = 'flex'; |
|
document.body.style.overflow = 'hidden'; |
|
elements.fullscreenContainer.setAttribute('aria-hidden', 'false'); |
|
}; |
|
|
|
const closeFullscreen = () => { |
|
elements.fullscreenContainer.style.display = 'none'; |
|
document.body.style.overflow = ''; |
|
elements.fullscreenContainer.setAttribute('aria-hidden', 'true'); |
|
}; |
|
|
|
const updateFullscreenImage = () => { |
|
const img = elements.imageDisplay.querySelector('img'); |
|
if (img) { |
|
elements.fullscreenImage.src = img.src; |
|
} |
|
}; |
|
|
|
|
|
const handleKeyboardNavigation = (e) => { |
|
if (state.files.length === 0) return; |
|
|
|
switch (e.key) { |
|
case 'ArrowLeft': |
|
if (elements.fullscreenContainer.style.display === 'flex') { |
|
showPreviousImage(); |
|
updateFullscreenImage(); |
|
} else { |
|
showPreviousImage(); |
|
} |
|
break; |
|
case 'ArrowRight': |
|
if (elements.fullscreenContainer.style.display === 'flex') { |
|
showNextImage(); |
|
updateFullscreenImage(); |
|
} else { |
|
showNextImage(); |
|
} |
|
break; |
|
case '+': |
|
case '=': |
|
zoomIn(); |
|
break; |
|
case '-': |
|
zoomOut(); |
|
break; |
|
case '0': |
|
resetZoom(); |
|
break; |
|
case 'Escape': |
|
if (elements.fullscreenContainer.style.display === 'flex') { |
|
closeFullscreen(); |
|
} |
|
break; |
|
case 'f': |
|
case 'F': |
|
if (elements.imageDisplay.querySelector('img')) { |
|
if (elements.fullscreenContainer.style.display === 'flex') { |
|
closeFullscreen(); |
|
} else { |
|
openFullscreen(); |
|
} |
|
} |
|
break; |
|
} |
|
}; |
|
|
|
|
|
const getFileType = (file) => { |
|
if (file.type) { |
|
const type = file.type.split('/')[1]; |
|
if (type) return type.toUpperCase(); |
|
} |
|
|
|
const extension = file.name.split('.').pop().toLowerCase(); |
|
switch (extension) { |
|
case 'jpg': |
|
case 'jpeg': return 'JPEG'; |
|
case 'png': return 'PNG'; |
|
case 'webp': return 'WEBP'; |
|
case 'avif': return 'AVIF'; |
|
case 'heic': |
|
case 'heif': return 'HEIC'; |
|
default: return extension.toUpperCase(); |
|
} |
|
}; |
|
|
|
const formatFileSize = (bytes) => { |
|
if (bytes === 0) return '0 Bytes'; |
|
|
|
const k = 1024; |
|
const sizes = ['Bytes', 'KB', 'MB', 'GB']; |
|
const i = Math.floor(Math.log(bytes) / Math.log(k)); |
|
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; |
|
}; |
|
|
|
|
|
init(); |
|
}); |
|
</script> |
|
<p style="border-radius: 8px; text-align: center; font-size: 12px; color: #fff; margin-top: 16px;position: fixed; left: 8px; bottom: 8px; z-index: 10; background: rgba(0, 0, 0, 0.8); padding: 4px 8px;">Made with <img src="https://enzostvs-deepsite.hf.space/logo.svg" alt="DeepSite Logo" style="width: 16px; height: 16px; vertical-align: middle;display:inline-block;margin-right:3px;filter:brightness(0) invert(1);"><a href="https://enzostvs-deepsite.hf.space" style="color: #fff;text-decoration: underline;" target="_blank" >DeepSite</a> - 🧬 <a href="https://enzostvs-deepsite.hf.space?remix=jsfs11/local-image-viewer-v2" style="color: #fff;text-decoration: underline;" target="_blank" >Remix</a></p></body> |
|
|
|
</html> |