local-image-viewer-v2 / index.html
jsfs11's picture
Add 2 files
79d7410 verified
<!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>
<!-- Tailwind CSS -->
<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>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Google Fonts -->
<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">
<!-- Dropzone area -->
<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>
<!-- Main viewer area (hidden initially) -->
<div id="viewerArea" class="hidden">
<div class="flex flex-col md:flex-row h-[70vh]">
<!-- Sidebar with file list -->
<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">
<!-- Files will be listed here -->
</ul>
</div>
<!-- Main image display -->
<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">
<!-- Navigation buttons -->
<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>
<!-- Image display area -->
<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>
<!-- Image info -->
<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>
<!-- Fullscreen container -->
<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 () {
// Performance optimization utilities
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);
};
};
// Memory leak prevention
const cleanupHandlers = [];
const addCleanupHandler = (handler) => cleanupHandlers.push(handler);
// Clean up all event listeners when the page unloads
window.addEventListener('beforeunload', () => {
cleanupHandlers.forEach(handler => handler());
});
// DOM elements
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')
};
// State variables
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
};
// Initialize the app
const init = () => {
setupEventListeners();
setupThumbnailObserver();
};
// Set up all event listeners
const setupEventListeners = () => {
// Dropzone events
['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);
}
});
});
// Navigation events
elements.prevBtn.addEventListener('click', showPreviousImage);
elements.nextBtn.addEventListener('click', showNextImage);
addCleanupHandler(() => {
elements.prevBtn.removeEventListener('click', showPreviousImage);
elements.nextBtn.removeEventListener('click', showNextImage);
});
// Zoom events with debouncing
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);
});
// Sort events
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);
});
// Fullscreen events
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);
});
// Keyboard events
const keyboardHandler = handleKeyboardNavigation;
document.addEventListener('keydown', keyboardHandler);
addCleanupHandler(() => {
document.removeEventListener('keydown', keyboardHandler);
});
};
// Set up Intersection Observer for lazy loading thumbnails
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();
}
});
};
// Dropzone helper functions
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);
};
// File handling
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;
}
// Clean up previous files and resources
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);
};
// Clean up image drag handlers
const cleanupImageDragHandlers = (img) => {
img.removeEventListener('mousedown', handleImageMouseDown);
img.removeEventListener('mouseenter', handleImageMouseEnter);
img.removeEventListener('mouseleave', handleImageMouseLeave);
};
// Sort functionality
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');
};
// File list management
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);
// Observe the thumbnail for lazy loading
const thumbnail = listItem.querySelector('.thumbnail');
state.thumbnailObserver.observe(thumbnail);
});
};
// Lazy load thumbnail when it comes into view
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);
};
// Image display
const showImage = (index) => {
if (index < 0 || index >= state.files.length) return;
// Clean up previous image handlers
if (state.activeImage) {
cleanupImageDragHandlers(state.activeImage);
}
state.currentFileIndex = index;
const file = state.files[index];
// Update active item in file list
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');
}
});
// Update progress
elements.currentIndex.textContent = index + 1;
elements.progressBar.style.width = `${((index + 1) / state.files.length) * 100}%`;
// Display the image
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}`);
// Add drag to pan functionality
img.addEventListener('mousedown', handleImageMouseDown);
img.addEventListener('mouseenter', handleImageMouseEnter);
img.addEventListener('mouseleave', handleImageMouseLeave);
elements.imageDisplay.appendChild(img);
state.activeImage = img;
// Update file info
elements.fileName.textContent = file.name;
elements.fileInfo.textContent = `${getFileType(file)}${formatFileSize(file.size)}`;
// Load image dimensions after the image is loaded
img.onload = () => {
elements.fileInfo.textContent = `${img.naturalWidth}×${img.naturalHeight}${getFileType(file)}${formatFileSize(file.size)}`;
};
};
reader.readAsDataURL(file);
// Enable/disable navigation buttons
elements.prevBtn.disabled = index === 0;
elements.nextBtn.disabled = index === state.files.length - 1;
};
// Image drag handlers with throttle
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';
// Add global mouse move and up handlers
const mouseMoveHandler = throttleRAF(handleImageMouseMove);
const mouseUpHandler = handleImageMouseUp;
document.addEventListener('mousemove', mouseMoveHandler);
document.addEventListener('mouseup', mouseUpHandler);
// Clean up these handlers when done
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';
};
// Navigation functions
const showPreviousImage = () => {
if (state.currentFileIndex > 0) {
showImage(state.currentFileIndex - 1);
}
};
const showNextImage = () => {
if (state.currentFileIndex < state.files.length - 1) {
showImage(state.currentFileIndex + 1);
}
};
// Zoom functions
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)`;
// Reset pan position when zooming
if (state.zoomLevel <= 1) {
state.translateX = 0;
state.translateY = 0;
img.style.transform = `scale(${state.zoomLevel}) translate3d(0, 0, 0)`;
}
// Update cursor based on zoom level
if (state.zoomLevel > 1) {
img.style.cursor = 'grab';
} else {
img.style.cursor = 'default';
}
}
};
// Fullscreen functions
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;
}
};
// Keyboard navigation
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;
}
};
// Helper functions
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];
};
// Initialize the application
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>