1/** 2 * Modular Retro GUI OS Image Viewer Component 3 * Features: 4 * - Full-viewport window maximize button support (takes up whole screen with GUI titlebar/caption) 5 * - Optimal inner picture fit (100% stage utilization while preserving aspect ratio) 6 * - Click-to-zoom and smooth 2D drag panning (mouse & touch) 7 * - Mouse wheel zooming (1.0x - 7.0x) with auto pan reset 8 * - Responsive navigation injection for photo carousels & standalone page triggers ([ â PREV ], [ NEXT ⺠], [ GALLERY ]) 9 * - ESC key and backdrop click dismissal 10 * - Strict duotone/monochrome filter exclusion 11 */ 12(function(window) { 13 'use strict'; 14 15 class ImageViewer { 16 static instance = null; 17 18 constructor() { 19 this.modal = document.getElementById('gui-image-modal'); 20 this.modalImg = document.getElementById('image-viewer-img'); 21 this.modalFilename = document.getElementById('image-viewer-filename'); 22 this.modalPath = document.getElementById('image-viewer-path'); 23 this.modalCloseBtn = document.getElementById('image-viewer-close-btn'); 24 this.modalMinBtn = document.getElementById('image-viewer-min-btn'); 25 this.modalMaxBtn = document.getElementById('image-viewer-max-btn'); 26 this.modalWin = this.modal ? this.modal.querySelector('.image-viewer-window') : null; 27 28 this.currentCarousel = null; 29 this.galleryTriggers = []; 30 this.triggerIndex = -1; 31 32 // Window & Zoom/Pan state 33 this.isWindowMaximized = false; 34 this.isZoomed = false; 35 this.zoomLevel = 1.0; 36 this.panX = 0; 37 this.panY = 0; 38 this.isDragging = false; 39 this.dragStartX = 0; 40 this.dragStartY = 0; 41 this.startPanX = 0; 42 this.startPanY = 0; 43 this.hasDragged = false; 44 45 this.initEvents(); 46 } 47 48 static getInstance() { 49 if (!ImageViewer.instance) { 50 ImageViewer.instance = new ImageViewer(); 51 } 52 return ImageViewer.instance; 53 } 54 55 initEvents() { 56 if (!this.modal || !this.modalImg) return; 57 58 const self = this; 59 60 if (this.modalCloseBtn) { 61 this.modalCloseBtn.addEventListener('click', (e) => { 62 e.preventDefault(); 63 e.stopPropagation(); 64 self.close(); 65 }); 66 } 67 68 if (this.modalMinBtn) { 69 this.modalMinBtn.addEventListener('click', (e) => { 70 e.preventDefault(); 71 e.stopPropagation(); 72 self.zoomOut(); 73 }); 74 } 75 76 if (this.modalMaxBtn) { 77 this.modalMaxBtn.addEventListener('click', (e) => { 78 e.preventDefault(); 79 e.stopPropagation(); 80 self.toggleWindowMaximize(); 81 }); 82 } 83 84 // Mouse drag panning on image 85 this.modalImg.addEventListener('mousedown', (e) => { 86 if (!self.isZoomed || e.button !== 0) return; 87 self.isDragging = true; 88 self.hasDragged = false; 89 self.dragStartX = e.clientX; 90 self.dragStartY = e.clientY; 91 self.startPanX = self.panX; 92 self.startPanY = self.panY; 93 self.applyTransform(false); 94 e.preventDefault(); 95 }); 96 97 window.addEventListener('mousemove', (e) => { 98 if (!self.isDragging || !self.isZoomed) return; 99 const dx = e.clientX - self.dragStartX; 100 const dy = e.clientY - self.dragStartY; 101 if (Math.abs(dx) > 3 || Math.abs(dy) > 3) { 102 self.hasDragged = true; 103 } 104 self.panX = self.startPanX + dx; 105 self.panY = self.startPanY + dy; 106 self.applyTransform(false); 107 }); 108 109 window.addEventListener('mouseup', () => { 110 if (self.isDragging) { 111 self.isDragging = false; 112 self.applyTransform(false); 113 } 114 }); 115 116 // Touch drag panning 117 this.modalImg.addEventListener('touchstart', (e) => { 118 if (!self.isZoomed || e.touches.length !== 1) return; 119 self.isDragging = true; 120 self.hasDragged = false; 121 self.dragStartX = e.touches[0].clientX; 122 self.dragStartY = e.touches[0].clientY; 123 self.startPanX = self.panX; 124 self.startPanY = self.panY; 125 }, { passive: true }); 126 127 window.addEventListener('touchmove', (e) => { 128 if (!self.isDragging || !self.isZoomed || e.touches.length !== 1) return; 129 const dx = e.touches[0].clientX - self.dragStartX; 130 const dy = e.touches[0].clientY - self.dragStartY; 131 if (Math.abs(dx) > 3 || Math.abs(dy) > 3) { 132 self.hasDragged = true; 133 } 134 self.panX = self.startPanX + dx; 135 self.panY = self.startPanY + dy; 136 self.applyTransform(false); 137 }, { passive: true }); 138 139 window.addEventListener('touchend', () => { 140 if (self.isDragging) { 141 self.isDragging = false;
142 self.applyTransform(false); 143 } 144 }); 145 146 // Click to toggle zoom (only if user wasn't actively dragging) 147 this.modalImg.addEventListener('click', (e) => { 148 e.stopPropagation(); 149 if (self.hasDragged) { 150 self.hasDragged = false; 151 return; 152 } 153 self.toggleZoom(); 154 }); 155 156 // Mouse wheel zoom 157 const bodyElem = this.modal.querySelector('.image-viewer-body') || this.modalImg; 158 bodyElem.addEventListener('wheel', (e) => { 159 if (!self.modal.classList.contains('is-active')) return; 160 e.preventDefault(); 161 const zoomDelta = e.deltaY < 0 ? 0.35 : -0.35; 162 let nextZoom = (self.isZoomed ? self.zoomLevel : 1.0) + zoomDelta; 163 nextZoom = Math.max(1.0, Math.min(7.0, nextZoom)); 164 if (nextZoom > 1.05) { 165 self.isZoomed = true; 166 self.zoomLevel = nextZoom; 167 self.applyTransform(true); 168 } else { 169 self.zoomOut(); 170 } 171 }, { passive: false }); 172 173 if (this.modalWin) { 174 this.modalWin.addEventListener('click', (e) => { 175 e.stopPropagation(); 176 }); 177 } 178 179 this.modal.addEventListener('click', (e) => { 180 if (e.target === self.modal) { 181 self.close(); 182 } 183 }); 184 185 document.addEventListener('keydown', (e) => { 186 if (!self.modal.classList.contains('is-active')) return; 187 188 if (e.key === 'Escape') { 189 self.close(); 190 } else if (self.currentCarousel) { 191 if (e.key === 'ArrowLeft') { 192 self.currentCarousel.updateGallery(self.currentCarousel.currentIndex - 1); 193 self.updateFromCarousel(self.currentCarousel); 194 } else if (e.key === 'ArrowRight') { 195 self.currentCarousel.updateGallery(self.currentCarousel.currentIndex + 1); 196 self.updateFromCarousel(self.currentCarousel); 197 } 198 } else if (self.galleryTriggers.length > 1) { 199 if (e.key === 'ArrowLeft') { 200 self.navigateTrigger(self.triggerIndex - 1); 201 } else if (e.key === 'ArrowRight') { 202 self.navigateTrigger(self.triggerIndex + 1); 203 } 204 } 205 }); 206 207 // Global delegation for .gui-image-trigger 208 document.addEventListener('click', (e) => { 209 const trigger = e.target.closest('.gui-image-trigger'); 210 if (!trigger) return; 211 212 e.preventDefault(); 213 e.stopPropagation(); 214 215 const src = trigger.getAttribute('data-modal-image') || trigger.getAttribute('href') || trigger.src; 216 const title = trigger.getAttribute('data-modal-title') || trigger.getAttribute('title') || trigger.alt || 'Artwork Preview'; 217 218 self.openFromTrigger(trigger, src, title); 219 }); 220 } 221 222 toggleWindowMaximize() { 223 if (!this.modal || !this.modalWin) return; 224 this.isWindowMaximized = !this.isWindowMaximized; 225 this.modal.classList.toggle('is-maximized', this.isWindowMaximized); 226 this.modalWin.classList.toggle('is-maximized', this.isWindowMaximized); 227 if (this.modalMaxBtn) { 228 this.modalMaxBtn.title = this.isWindowMaximized ? 'Restore Window Viewport' : 'Maximize Window to Full Viewport'; 229 } 230 } 231 232 applyTransform(animate = false) { 233 if (!this.modalImg) return; 234 if (animate) { 235 this.modalImg.style.transition = 'transform 0.2s cubic-bezier(0.2, 0, 0.2, 1)'; 236 } else { 237 this.modalImg.style.transition = 'none'; 238 } 239 240 if (this.isZoomed) { 241 this.modalImg.style.transform = `translate(${this.panX}px, ${this.panY}px) scale(${this.zoomLevel})`; 242 this.modalImg.classList.add('is-zoomed'); 243 this.modalImg.style.cursor = this.isDragging ? 'grabbing' : 'grab'; 244 } else { 245 this.modalImg.style.transform = ''; 246 this.modalImg.classList.remove('is-zoomed', 'is-dragging'); 247 this.modalImg.style.cursor = 'zoom-in'; 248 } 249 } 250 251 zoomIn(scale = 2.5) { 252 this.isZoomed = true; 253 this.zoomLevel = scale; 254 this.panX = 0; 255 this.panY = 0; 256 this.applyTransform(true); 257 } 258 259 zoomOut() { 260 this.isZoomed = false;
261 this.zoomLevel = 1.0; 262 this.panX = 0; 263 this.panY = 0; 264 this.isDragging = false; 265 this.applyTransform(true); 266 } 267 268 toggleZoom() { 269 if (this.isZoomed) { 270 this.zoomOut(); 271 } else { 272 this.zoomIn(2.5); 273 } 274 } 275 276 resetImageFilter() { 277 if (!this.modalImg) return; 278 this.modalImg.classList.remove('duotone-filtered'); 279 this.modalImg.style.filter = 'none'; 280 this.modalImg.style.webkitFilter = 'none'; 281 this.modalImg.setAttribute('data-palette-filter', 'false'); 282 this.modalImg.setAttribute('data-monochrome', 'false'); 283 this.modalImg.setAttribute('filter', '0'); 284 this.modalImg.setAttribute('data-filter', '0'); 285 } 286 287 open(src, alt, figNum) { 288 if (!this.modal || !this.modalImg) return; 289 290 this.currentCarousel = null; 291 this.galleryTriggers = []; 292 this.triggerIndex = -1; 293 294 this.resetImageFilter(); 295 this.zoomOut(); 296 this.modalImg.src = src; 297 const figLabel = figNum ? "Figure " + figNum : (alt || "Image Preview"); 298 if (this.modalFilename) this.modalFilename.textContent = figLabel; 299 300 if (this.modalPath) { 301 let displayPath = src || ''; 302 if (displayPath.startsWith('data:')) { 303 displayPath = '[ EMBEDDED INLINE IMAGE ]'; 304 } else if (displayPath.length > 55) { 305 displayPath = '...' + displayPath.slice(-52); 306 } 307 this.modalPath.textContent = 'SOURCE: ' + displayPath; 308 } 309 310 this.removeCarouselControls(); 311 this.modal.classList.add('is-active'); 312 document.body.style.overflow = 'hidden'; 313 } 314 315 openFromTrigger(trigger, src, title) { 316 if (!this.modal || !this.modalImg) return; 317 318 this.currentCarousel = null; 319 this.galleryTriggers = Array.from(document.querySelectorAll('.gui-image-trigger')).filter(el => { 320 return el.offsetParent !== null || el.getAttribute('data-modal-image') || el.getAttribute('href'); 321 }); 322 this.triggerIndex = this.galleryTriggers.indexOf(trigger); 323 324 this.zoomOut(); 325 this.displayTriggerImage(src, title); 326 327 this.modal.classList.add('is-active'); 328 document.body.style.overflow = 'hidden'; 329 } 330 331 displayTriggerImage(src, title) { 332 this.resetImageFilter(); 333 this.zoomOut(); 334 this.modalImg.src = src; 335 if (this.modalFilename) { 336 const countInfo = this.galleryTriggers.length > 1 ? `[ ${this.triggerIndex + 1} / ${this.galleryTriggers.length} ] ${title}` : title; 337 this.modalFilename.textContent = countInfo; 338 } 339 340 if (this.modalPath) { 341 let displayPath = src || ''; 342 if (displayPath.length > 55) displayPath = '...' + displayPath.slice(-52); 343 this.modalPath.textContent = 'SOURCE: ' + displayPath; 344 } 345 346 this.injectTriggerControls(); 347 } 348 349 navigateTrigger(newIndex) { 350 if (this.galleryTriggers.length === 0) return; 351 if (newIndex < 0) newIndex = this.galleryTriggers.length - 1; 352 if (newIndex >= this.galleryTriggers.length) newIndex = 0; 353 this.triggerIndex = newIndex; 354 355 const trigger = this.galleryTriggers[this.triggerIndex]; 356 const src = trigger.getAttribute('data-modal-image') || trigger.getAttribute('href') || trigger.src; 357 const title = trigger.getAttribute('data-modal-title') || trigger.getAttribute('title') || trigger.alt || 'Artwork Preview'; 358 this.displayTriggerImage(src, title); 359 } 360 361 injectTriggerControls() { 362 let controlBar = this.modal.querySelector('#image-viewer-carousel-controls'); 363 if (!controlBar) { 364 controlBar = document.createElement('div'); 365 controlBar.id = 'image-viewer-carousel-controls'; 366 controlBar.className = 'image-viewer-carousel-controls'; 367 controlBar.style.cssText = 'display: flex; gap: 0.5rem; align-items: center; justify-content: center; margin-top: 0.5rem;'; 368 369 const caption = this.modal.querySelector('.image-viewer-caption'); 370 if (caption) { 371 caption.appendChild(controlBar); 372 } 373 } 374 375 controlBar.innerHTML = ''; 376 377 if (this.galleryTriggers.length > 1) { 378 const prevBtn = document.createElement('button'); 379 prevBtn.type = 'button'; 380 prevBtn.className = 'export-btn'; 381 prevBtn.textContent = '[ â PREV ]'; 382 prevBtn.style.cssText = 'font-size: 0.75rem; padding: 0.25rem 0.5rem; cursor: pointer;'; 383 prevBtn.addEventListener('click', (e) => { 384 e.preventDefault(); 385 e.stopPropagation(); 386 this.navigateTrigger(this.triggerIndex - 1); 387 }); 388 389 const nextBtn = document.createElement('button'); 390 nextBtn.type = 'button'; 391 nextBtn.className = 'export-btn'; 392 nextBtn.textContent = '[ NEXT ⺠]'; 393 nextBtn.style.cssText = 'font-size: 0.75rem; padding: 0.25rem 0.5rem; cursor: pointer;'; 394 nextBtn.addEventListener('click', (e) => { 395 e.preventDefault(); 396 e.stopPropagation(); 397 this.navigateTrigger(this.triggerIndex + 1); 398 }); 399 400 controlBar.appendChild(prevBtn); 401 controlBar.appendChild(nextBtn); 402 } 403 } 404 405 openFromCarousel(carousel, index) { 406 if (!carousel || !this.modal || !this.modalImg) return; 407 408 this.currentCarousel = carousel; 409 this.galleryTriggers = []; 410 this.triggerIndex = -1; 411 this.zoomOut(); 412 carousel.updateGallery(index); 413 this.updateFromCarousel(carousel); 414 415 this.modal.classList.add('is-active'); 416 document.body.style.overflow = 'hidden'; 417 } 418 419 updateFromCarousel(carousel) { 420 if (!carousel) return; 421 422 const activeThumb = carousel.thumbs[carousel.currentIndex]; 423 if (!activeThumb) return; 424 425 const targetSrc = activeThumb.getAttribute('data-full-src') || activeThumb.src || activeThumb.getAttribute('data-url'); 426 this.resetImageFilter(); 427 this.zoomOut(); 428 this.modalImg.src = targetSrc; 429 430 if (this.modalFilename) { 431 this.modalFilename.textContent = `[ ${carousel.currentIndex + 1} / ${carousel.thumbs.length} ]`; 432 } 433 434 if (this.modalPath) { 435 let displayPath = targetSrc || ''; 436 if (displayPath.length >
436 55) displayPath = '...' + displayPath.slice(-52); 437 this.modalPath.textContent = 'MEDIA: ' + displayPath; 438 } 439 440 this.injectCarouselControls(carousel); 441 } 442 443 injectCarouselControls(carousel) { 444 let controlBar = this.modal.querySelector('#image-viewer-carousel-controls'); 445 if (!controlBar) { 446 controlBar = document.createElement('div'); 447 controlBar.id = 'image-viewer-carousel-controls'; 448 controlBar.className = 'image-viewer-carousel-controls'; 449 controlBar.style.cssText = 'display: flex; gap: 0.5rem; align-items: center; justify-content: center; margin-top: 0.5rem;'; 450 451 const caption = this.modal.querySelector('.image-viewer-caption'); 452 if (caption) { 453 caption.appendChild(controlBar); 454 } 455 } 456 457 controlBar.innerHTML = ''; 458 459 if (carousel.thumbs.length > 1) { 460 const prevBtn = document.createElement('button'); 461 prevBtn.type = 'button'; 462 prevBtn.className = 'export-btn'; 463 prevBtn.textContent = '[ â PREV ]'; 464 prevBtn.style.cssText = 'font-size: 0.75rem; padding: 0.25rem 0.5rem; cursor: pointer;'; 465 prevBtn.addEventListener('click', (e) => { 466 e.preventDefault(); 467 e.stopPropagation(); 468 carousel.updateGallery(carousel.currentIndex - 1); 469 this.updateFromCarousel(carousel); 470 }); 471 472 const galleryBtn = document.createElement('button'); 473 galleryBtn.type = 'button'; 474 galleryBtn.className = 'export-btn'; 475 galleryBtn.textContent = '[ GALLERY ]'; 476 galleryBtn.style.cssText = 'font-size: 0.75rem; padding: 0.25rem 0.5rem; cursor: pointer;'; 477 galleryBtn.addEventListener('click', (e) => { 478 e.preventDefault(); 479 e.stopPropagation(); 480 carousel.openThumbsModal(); 481 }); 482 483 const nextBtn = document.createElement('button'); 484 nextBtn.type = 'button'; 485 nextBtn.className = 'export-btn'; 486 nextBtn.textContent = '[ NEXT ⺠]'; 487 nextBtn.style.cssText = 'font-size: 0.75rem; padding: 0.25rem 0.5rem; cursor: pointer;'; 488 nextBtn.addEventListener('click', (e) => { 489 e.preventDefault(); 490 e.stopPropagation(); 491 carousel.updateGallery(carousel.currentIndex + 1); 492 this.updateFromCarousel(carousel); 493 }); 494 495 controlBar.appendChild(prevBtn); 496 controlBar.appendChild(galleryBtn); 497 controlBar.appendChild(nextBtn); 498 } 499 } 500 501 removeCarouselControls() { 502 const controlBar = this.modal?.querySelector('#image-viewer-carousel-controls'); 503 if (controlBar) controlBar.remove(); 504 } 505 506 close() { 507 if (!this.modal) return; 508 this.modal.classList.remove('is-active', 'is-maximized'); 509 if (this.modalWin) this.modalWin.classList.remove('is-maximized'); 510 this.isWindowMaximized = false; 511 document.body.style.overflow = ''; 512 if (this.modalImg) { 513 this.modalImg.src = ''; 514 } 515 this.zoomOut(); 516 this.currentCarousel = null; 517 this.galleryTriggers = []; 518 this.triggerIndex = -1; 519 this.removeCarouselControls(); 520 } 521 } 522 523 window.ImageViewer = ImageViewer; 524 525 // Initialize on load 526 if (document.readyState === 'loading') { 527 document.addEventListener('DOMContentLoaded', () => ImageViewer.getInstance()); 528 } else { 529 ImageViewer.getInstance(); 530 } 531})(window);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.