PageSourceSearch

https://sol.vin/js/image_viewer.js

js sol.vin collected 2026-10-09 21:58:26 UTC 18,355 bytes, 531 lines download raw bytes

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.