zoomora 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/zoomora.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * Zoomora Lightbox Plugin v1.3.0
2
+ * Zoomora Lightbox Plugin v1.4.0
3
3
  * A modern, responsive lightbox plugin with zoom, fullscreen, and gallery features
4
4
  *
5
5
  * Copyright (c) 2026 Faruk Ahmed (FrontTheme)
@@ -9,153 +9,138 @@
9
9
  // Import styles
10
10
  import './styles/zoomora.scss';
11
11
 
12
- (function (global, factory) {
13
- 'use strict';
12
+ /**
13
+ * Zoomora Lightbox Plugin
14
+ * @class
15
+ */
16
+ class Zoomora {
17
+ /**
18
+ * Default configuration options
19
+ * @type {Object}
20
+ */
21
+ static get defaults() {
22
+ return {
23
+ selector: '[data-zoomora]',
24
+ showCounter: true,
25
+ showThumbnails: true,
26
+ showFullscreen: true,
27
+ showZoom: true,
28
+ transition: 'fade', // 'fade' or 'slide'
29
+ useHref: false, // Use href instead of data-src
30
+ maxZoomScale: 3, // Maximum zoom scale
31
+ zoomStep: 0.1, // Zoom step for scroll wheel
32
+ animationDuration: 300, // Animation duration in ms
33
+ closeOnBackgroundClick: true, // Close lightbox when clicking on background
34
+ autoplayVideos: false, // Start YouTube and local videos when their slide opens
35
+ onOpen: null, // Callback when lightbox opens
36
+ onClose: null, // Callback when lightbox closes
37
+ onNext: null, // Callback when navigating to next item
38
+ onPrev: null, // Callback when navigating to previous item
14
39
 
15
- if (typeof module === 'object' && typeof module.exports === 'object') {
16
- // CommonJS/Node.js
17
- module.exports = factory();
18
- } else if (typeof define === 'function' && define.amd) {
19
- // AMD
20
- define(factory);
21
- } else {
22
- // Browser globals
23
- global.Zoomora = factory();
40
+ // Auto-hide controls
41
+ showAutoHideToggle: true, // Show auto-hide toggle button
42
+ autoHideDelay: 3000, // Delay in ms before hiding controls
43
+ autoHideEnabled: false, // Default state of auto-hide
44
+ };
24
45
  }
25
- }(typeof window !== 'undefined' ? window : this, function () {
26
- 'use strict';
27
46
 
28
47
  /**
29
- * Zoomora Lightbox Plugin
30
- * @class
48
+ * Constructor
49
+ * @param {Object} options - Configuration options
31
50
  */
32
- class Zoomora {
33
- /**
34
- * Default configuration options
35
- * @type {Object}
36
- */
37
- static get defaults() {
38
- return {
39
- selector: '[data-zoomora]',
40
- showCounter: true,
41
- showThumbnails: true,
42
- showFullscreen: true,
43
- showZoom: true,
44
- transition: 'fade', // 'fade' or 'slide'
45
- useHref: false, // Use href instead of data-src
46
- maxZoomScale: 3, // Maximum zoom scale
47
- zoomStep: 0.1, // Zoom step for scroll wheel
48
- animationDuration: 300, // Animation duration in ms
49
- closeOnBackgroundClick: true, // Close lightbox when clicking on background
50
- onOpen: null, // Callback when lightbox opens
51
- onClose: null, // Callback when lightbox closes
52
- onNext: null, // Callback when navigating to next item
53
- onPrev: null, // Callback when navigating to previous item
54
-
55
- // Auto-hide controls
56
- showAutoHideToggle: true, // Show auto-hide toggle button
57
- autoHideDelay: 3000, // Delay in ms before hiding controls
58
- autoHideEnabled: false, // Default state of auto-hide
59
- };
60
- }
61
-
62
- /**
63
- * Constructor
64
- * @param {Object} options - Configuration options
65
- */
66
- constructor(options = {}) {
67
- this.options = {...Zoomora.defaults, ...options};
68
- this.currentIndex = 0;
69
- this.items = [];
70
- this.isZoomed = false;
71
- this.isFullscreen = false;
72
- this.isDragging = false;
73
- this.thumbnailsVisible = true;
74
- this.startX = 0;
75
- this.startY = 0;
76
- this.currentX = 0;
77
- this.currentY = 0;
78
- this.currentScale = 1;
79
- this.isInitialized = false;
80
- this.eventListeners = new Map();
51
+ constructor(options = {}) {
52
+ this.options = {...Zoomora.defaults, ...options};
53
+ this.currentIndex = 0;
54
+ this.items = [];
55
+ this.isZoomed = false;
56
+ this.isFullscreen = false;
57
+ this.isDragging = false;
58
+ this.thumbnailsVisible = true;
59
+ this.startX = 0;
60
+ this.startY = 0;
61
+ this.currentX = 0;
62
+ this.currentY = 0;
63
+ this.currentScale = 1;
64
+ this.isInitialized = false;
65
+ this.eventListeners = new Map();
66
+
67
+ // Auto-hide controls
68
+ this.autoHideEnabled = false;
69
+ this.autoHideTimeout = null;
70
+ this.autoHideDelay = 3000;
71
+ this.isControlsVisible = true;
72
+
73
+ this.lastDragTime = 0;
74
+ this.boundMethods = {
75
+ handleDocumentClick: this.handleDocumentClick.bind(this),
76
+ handleKeydown: this.handleKeydown.bind(this),
77
+ handleFullscreenChange: this.handleFullscreenChange.bind(this),
78
+ handleZoomoraClick: this.handleZoomoraClick.bind(this),
79
+ handleContentClick: this.handleContentClick.bind(this),
80
+ handleMouseDown: this.handleMouseDown.bind(this),
81
+ handleMouseMove: this.handleMouseMove.bind(this),
82
+ handleMouseUp: this.handleMouseUp.bind(this),
83
+ handleTouchStart: this.handleTouchStart.bind(this),
84
+ handleTouchMove: this.handleTouchMove.bind(this),
85
+ handleTouchEnd: this.handleTouchEnd.bind(this),
86
+ handleWheel: this.handleWheel.bind(this),
87
+ };
88
+
89
+ this.init();
90
+ }
81
91
 
82
- // Auto-hide controls
83
- this.autoHideEnabled = false;
84
- this.autoHideTimeout = null;
85
- this.autoHideDelay = 3000;
86
- this.isControlsVisible = true;
87
-
88
- this.lastDragTime = 0;
89
- this.boundMethods = {
90
- handleDocumentClick: this.handleDocumentClick.bind(this),
91
- handleKeydown: this.handleKeydown.bind(this),
92
- handleFullscreenChange: this.handleFullscreenChange.bind(this),
93
- handleZoomoraClick: this.handleZoomoraClick.bind(this),
94
- handleContentClick: this.handleContentClick.bind(this),
95
- handleMouseDown: this.handleMouseDown.bind(this),
96
- handleMouseMove: this.handleMouseMove.bind(this),
97
- handleMouseUp: this.handleMouseUp.bind(this),
98
- handleTouchStart: this.handleTouchStart.bind(this),
99
- handleTouchMove: this.handleTouchMove.bind(this),
100
- handleTouchEnd: this.handleTouchEnd.bind(this),
101
- handleWheel: this.handleWheel.bind(this),
102
- };
103
-
104
- this.init();
105
- }
106
-
107
- /**
108
- * Initialize the plugin
109
- */
110
- init() {
111
- if (this.isInitialized) {
112
- console.warn('Zoomora: Plugin already initialized');
113
- return;
114
- }
92
+ /**
93
+ * Initialize the plugin
94
+ */
95
+ init() {
96
+ if (this.isInitialized) {
97
+ console.warn('Zoomora: Plugin already initialized');
98
+ return;
99
+ }
115
100
 
116
- try {
117
- this.createLightbox();
118
- this.bindEvents();
101
+ try {
102
+ this.createLightbox();
103
+ this.bindEvents();
119
104
 
120
- this.isInitialized = true;
121
- } catch (error) {
122
- console.error('Zoomora: Initialization failed', error);
123
- }
105
+ this.isInitialized = true;
106
+ } catch (error) {
107
+ console.error('Zoomora: Initialization failed', error);
124
108
  }
109
+ }
125
110
 
126
- /**
127
- * Static method to bind to anchor tags with href
128
- * @param {string} selector - CSS selector
129
- * @param {Object} options - Configuration options
130
- * @returns {Zoomora} - New Zoomora instance
131
- */
132
- static bind(selector, options = {}) {
133
- const enhancedOptions = {
134
- ...options,
135
- selector: selector,
136
- useHref: true
137
- };
138
- return new Zoomora(enhancedOptions);
139
- }
140
-
141
- /**
142
- * Create lightbox HTML structure
143
- */
144
- createLightbox() {
145
- // Check if lightbox already exists
146
- if (document.getElementById('zoomoraLightbox')) {
147
- console.warn('Zoomora: Lightbox already exists in DOM');
148
- return;
149
- }
111
+ /**
112
+ * Static method to bind to anchor tags with href
113
+ * @param {string} selector - CSS selector
114
+ * @param {Object} options - Configuration options
115
+ * @returns {Zoomora} - New Zoomora instance
116
+ */
117
+ static bind(selector, options = {}) {
118
+ const enhancedOptions = {
119
+ ...options,
120
+ selector: selector,
121
+ useHref: true
122
+ };
123
+ return new Zoomora(enhancedOptions);
124
+ }
125
+
126
+ /**
127
+ * Create lightbox HTML structure
128
+ */
129
+ createLightbox() {
130
+ // Check if lightbox already exists
131
+ if (document.getElementById('zoomoraLightbox')) {
132
+ console.warn('Zoomora: Lightbox already exists in DOM');
133
+ return;
134
+ }
150
135
 
151
- // Build auto-hide button conditionally
152
- const autoHideButtonHTML = this.options.showAutoHideToggle ? `
136
+ // Build auto-hide button conditionally
137
+ const autoHideButtonHTML = this.options.showAutoHideToggle ? `
153
138
  <button class="zoomora-btn" id="zoomoraAutoHideBtn" title="Toggle Auto-Hide Controls">
154
139
  <i class="zoomorai-controls-off"></i>
155
140
  </button>
156
141
  ` : '';
157
142
 
158
- const zoomoraHTML = `
143
+ const zoomoraHTML = `
159
144
  <div class="zoomora" id="zoomoraLightbox">
160
145
  <div class="zoomora-container">
161
146
  <div class="zoomora-header" id="zoomoraHeader">
@@ -204,1560 +189,1604 @@ import './styles/zoomora.scss';
204
189
  </div>
205
190
  `;
206
191
 
207
- document.body.insertAdjacentHTML('beforeend', zoomoraHTML);
208
-
209
- // Cache DOM elements
210
- this.zoomora = document.getElementById('zoomoraLightbox');
211
- this.header = document.getElementById('zoomoraHeader');
212
- this.content = document.getElementById('zoomoraContent');
213
- this.slidesContainer = document.getElementById('zoomoraSlidesContainer');
214
- this.counter = document.getElementById('zoomoraCounter');
215
- this.thumbs = document.getElementById('zoomoraThumbs');
216
- this.loading = document.getElementById('zoomoraLoading');
217
- this.caption = document.getElementById('zoomoraCaption');
218
- this.prevBtn = document.getElementById('zoomoraPrevBtn');
219
- this.nextBtn = document.getElementById('zoomoraNextBtn');
220
-
221
- // Verify all elements were created
222
- if (!this.zoomora || !this.content || !this.counter || !this.thumbs || !this.loading || !this.caption) {
223
- throw new Error('Failed to create required DOM elements');
224
- }
192
+ document.body.insertAdjacentHTML('beforeend', zoomoraHTML);
193
+
194
+ // Cache DOM elements
195
+ this.zoomora = document.getElementById('zoomoraLightbox');
196
+ this.header = document.getElementById('zoomoraHeader');
197
+ this.content = document.getElementById('zoomoraContent');
198
+ this.slidesContainer = document.getElementById('zoomoraSlidesContainer');
199
+ this.counter = document.getElementById('zoomoraCounter');
200
+ this.thumbs = document.getElementById('zoomoraThumbs');
201
+ this.loading = document.getElementById('zoomoraLoading');
202
+ this.caption = document.getElementById('zoomoraCaption');
203
+ this.prevBtn = document.getElementById('zoomoraPrevBtn');
204
+ this.nextBtn = document.getElementById('zoomoraNextBtn');
205
+
206
+ // Verify all elements were created
207
+ if (!this.zoomora || !this.content || !this.counter || !this.thumbs || !this.loading || !this.caption) {
208
+ throw new Error('Failed to create required DOM elements');
225
209
  }
210
+ }
226
211
 
227
- /**
228
- * Bind event listeners
229
- */
230
- bindEvents() {
231
- // Document click handler for opening lightbox
232
- this.addEventListener(document, 'click', this.boundMethods.handleDocumentClick);
233
-
234
- // Control button handlers
235
- this.addEventListener(document.getElementById('zoomoraCloseBtn'), 'click', () => this.close());
236
- this.addEventListener(document.getElementById('zoomoraPrevBtn'), 'click', () => this.prev());
237
- this.addEventListener(document.getElementById('zoomoraNextBtn'), 'click', () => this.next());
238
- if (this.options.showZoom) {
239
- this.addEventListener(document.getElementById('zoomoraZoomBtn'), 'click', () => this.toggleZoom());
240
- }
241
- if (this.options.showFullscreen) {
242
- this.addEventListener(document.getElementById('zoomoraFullscreenBtn'), 'click', () => this.toggleFullscreen());
243
- this.addEventListener(document.getElementById('zoomoraCollapseBtn'), 'click', () => this.exitFullscreen());
244
- }
245
- if (this.options.showThumbnails) {
246
- this.addEventListener(document.getElementById('zoomoraThumbnailBtn'), 'click', () => this.toggleThumbnails());
247
- }
212
+ /**
213
+ * Bind event listeners
214
+ */
215
+ bindEvents() {
216
+ // Document click handler for opening lightbox
217
+ this.addEventListener(document, 'click', this.boundMethods.handleDocumentClick);
218
+
219
+ // Control button handlers
220
+ this.addEventListener(document.getElementById('zoomoraCloseBtn'), 'click', () => this.close());
221
+ this.addEventListener(document.getElementById('zoomoraPrevBtn'), 'click', () => this.prev());
222
+ this.addEventListener(document.getElementById('zoomoraNextBtn'), 'click', () => this.next());
223
+ if (this.options.showZoom) {
224
+ this.addEventListener(document.getElementById('zoomoraZoomBtn'), 'click', () => this.toggleZoom());
225
+ }
226
+ if (this.options.showFullscreen) {
227
+ this.addEventListener(document.getElementById('zoomoraFullscreenBtn'), 'click', () => this.toggleFullscreen());
228
+ this.addEventListener(document.getElementById('zoomoraCollapseBtn'), 'click', () => this.exitFullscreen());
229
+ }
230
+ if (this.options.showThumbnails) {
231
+ this.addEventListener(document.getElementById('zoomoraThumbnailBtn'), 'click', () => this.toggleThumbnails());
232
+ }
248
233
 
249
- // Fullscreen change events
250
- this.addEventListener(document, 'fullscreenchange', this.boundMethods.handleFullscreenChange);
251
- this.addEventListener(document, 'webkitfullscreenchange', this.boundMethods.handleFullscreenChange);
252
- this.addEventListener(document, 'mozfullscreenchange', this.boundMethods.handleFullscreenChange);
253
- this.addEventListener(document, 'MSFullscreenChange', this.boundMethods.handleFullscreenChange);
234
+ // Fullscreen change events
235
+ this.addEventListener(document, 'fullscreenchange', this.boundMethods.handleFullscreenChange);
236
+ this.addEventListener(document, 'webkitfullscreenchange', this.boundMethods.handleFullscreenChange);
237
+ this.addEventListener(document, 'mozfullscreenchange', this.boundMethods.handleFullscreenChange);
238
+ this.addEventListener(document, 'MSFullscreenChange', this.boundMethods.handleFullscreenChange);
254
239
 
255
- // Keyboard navigation
256
- this.addEventListener(document, 'keydown', this.boundMethods.handleKeydown);
240
+ // Keyboard navigation
241
+ this.addEventListener(document, 'keydown', this.boundMethods.handleKeydown);
257
242
 
258
- // Lightbox click to close
259
- this.addEventListener(this.zoomora, 'click', this.boundMethods.handleZoomoraClick);
243
+ // Lightbox click to close
244
+ this.addEventListener(this.zoomora, 'click', this.boundMethods.handleZoomoraClick);
260
245
 
261
- // Content click for zoom toggle and background click
262
- this.addEventListener(this.content, 'click', this.boundMethods.handleContentClick);
246
+ // Content click for zoom toggle and background click
247
+ this.addEventListener(this.content, 'click', this.boundMethods.handleContentClick);
263
248
 
264
- // Mouse drag events
265
- this.addEventListener(this.content, 'mousedown', this.boundMethods.handleMouseDown);
266
- this.addEventListener(this.content, 'mousemove', this.boundMethods.handleMouseMove);
267
- this.addEventListener(this.content, 'mouseup', this.boundMethods.handleMouseUp);
268
- this.addEventListener(this.content, 'mouseleave', this.boundMethods.handleMouseUp);
249
+ // Mouse drag events
250
+ this.addEventListener(this.content, 'mousedown', this.boundMethods.handleMouseDown);
251
+ this.addEventListener(this.content, 'mousemove', this.boundMethods.handleMouseMove);
252
+ this.addEventListener(this.content, 'mouseup', this.boundMethods.handleMouseUp);
253
+ this.addEventListener(this.content, 'mouseleave', this.boundMethods.handleMouseUp);
269
254
 
270
- // Touch events
271
- this.addEventListener(this.content, 'touchstart', this.boundMethods.handleTouchStart, {passive: false});
272
- this.addEventListener(this.content, 'touchmove', this.boundMethods.handleTouchMove, {passive: false});
273
- this.addEventListener(this.content, 'touchend', this.boundMethods.handleTouchEnd);
255
+ // Touch events
256
+ this.addEventListener(this.content, 'touchstart', this.boundMethods.handleTouchStart, {passive: false});
257
+ this.addEventListener(this.content, 'touchmove', this.boundMethods.handleTouchMove, {passive: false});
258
+ this.addEventListener(this.content, 'touchend', this.boundMethods.handleTouchEnd);
274
259
 
275
- // Scroll-wheel zoom
276
- if (this.options.showZoom) {
277
- this.addEventListener(this.content, 'wheel', this.boundMethods.handleWheel, {passive: false});
278
- }
260
+ // Scroll-wheel zoom
261
+ if (this.options.showZoom) {
262
+ this.addEventListener(this.content, 'wheel', this.boundMethods.handleWheel, {passive: false});
263
+ }
279
264
 
280
- // Add auto-hide toggle button handler (only if enabled)
281
- if (this.options.showAutoHideToggle) {
282
- this.addEventListener(document.getElementById('zoomoraAutoHideBtn'), 'click', () => this.toggleAutoHide());
265
+ // Add auto-hide toggle button handler (only if enabled)
266
+ if (this.options.showAutoHideToggle) {
267
+ this.addEventListener(document.getElementById('zoomoraAutoHideBtn'), 'click', () => this.toggleAutoHide());
283
268
 
284
- // Add mouse/touch activity listeners for auto-hide
285
- this.addEventListener(this.zoomora, 'mousemove', () => this.handleUserActivity());
286
- this.addEventListener(this.zoomora, 'touchstart', () => this.handleUserActivity());
287
- this.addEventListener(this.zoomora, 'keydown', () => this.handleUserActivity());
288
- }
269
+ // Add mouse/touch activity listeners for auto-hide
270
+ this.addEventListener(this.zoomora, 'mousemove', () => this.handleUserActivity());
271
+ this.addEventListener(this.zoomora, 'touchstart', () => this.handleUserActivity());
272
+ this.addEventListener(this.zoomora, 'keydown', () => this.handleUserActivity());
289
273
  }
274
+ }
290
275
 
291
- /**
292
- * Add event listener with cleanup tracking
293
- * @param {EventTarget} element - DOM element or document
294
- * @param {string} event - Event type
295
- * @param {Function} handler - Event handler
296
- * @param {Object} options - Event options
297
- */
298
- addEventListener(element, event, handler, options = {}) {
299
- if (!element) return;
276
+ /**
277
+ * Add event listener with cleanup tracking
278
+ * @param {EventTarget} element - DOM element or document
279
+ * @param {string} event - Event type
280
+ * @param {Function} handler - Event handler
281
+ * @param {Object} options - Event options
282
+ */
283
+ addEventListener(element, event, handler, options = {}) {
284
+ if (!element) return;
300
285
 
301
- element.addEventListener(event, handler, options);
286
+ element.addEventListener(event, handler, options);
302
287
 
303
- // Track for cleanup
304
- const key = `${element.constructor.name}_${event}`;
305
- if (!this.eventListeners.has(key)) {
306
- this.eventListeners.set(key, []);
307
- }
308
- this.eventListeners.get(key).push({element, event, handler, options});
309
- }
310
-
311
- /**
312
- * Handle document click events
313
- * @param {Event} e - Click event
314
- */
315
- handleDocumentClick(e) {
316
- const target = e.target.closest(this.options.selector);
317
- if (target) {
318
- e.preventDefault();
319
- this.open(target);
320
- }
288
+ // Track for cleanup
289
+ const key = `${element.constructor.name}_${event}`;
290
+ if (!this.eventListeners.has(key)) {
291
+ this.eventListeners.set(key, []);
321
292
  }
293
+ this.eventListeners.get(key).push({element, event, handler, options});
294
+ }
322
295
 
323
- /**
324
- * Handle keyboard events
325
- * @param {KeyboardEvent} e - Keyboard event
326
- */
327
- handleKeydown(e) {
328
- if (!this.zoomora.classList.contains('active')) return;
329
-
330
- switch (e.key) {
331
- case 'Escape':
332
- if (this.isFullscreen) {
333
- this.exitFullscreen();
334
- } else {
335
- this.close();
336
- }
337
- break;
338
- case 'ArrowLeft':
339
- this.prev();
340
- break;
341
- case 'ArrowRight':
342
- this.next();
343
- break;
344
- case 'z':
345
- case 'Z':
346
- this.toggleZoom();
347
- break;
348
- case 'f':
349
- case 'F':
350
- this.toggleFullscreen();
351
- break;
352
- case 't':
353
- case 'T':
354
- this.toggleThumbnails();
355
- break;
356
- }
296
+ /**
297
+ * Handle document click events
298
+ * @param {Event} e - Click event
299
+ */
300
+ handleDocumentClick(e) {
301
+ const target = e.target.closest(this.options.selector);
302
+ if (target) {
303
+ e.preventDefault();
304
+ this.open(target);
357
305
  }
306
+ }
358
307
 
359
- /**
360
- * Handle fullscreen change events
361
- */
362
- handleFullscreenChange() {
363
- const isInFullscreen = !!(
364
- document.fullscreenElement ||
365
- document.webkitFullscreenElement ||
366
- document.mozFullScreenElement ||
367
- document.msFullscreenElement
368
- );
308
+ /**
309
+ * Handle keyboard events
310
+ * @param {KeyboardEvent} e - Keyboard event
311
+ */
312
+ handleKeydown(e) {
313
+ if (!this.zoomora.classList.contains('active')) {
314
+ this.handleTriggerKeydown(e);
315
+ return;
316
+ }
369
317
 
370
- // Only update if state changed from fullscreen to not fullscreen
371
- if (!isInFullscreen && this.isFullscreen) {
372
- this.isFullscreen = false;
373
- this.zoomora.classList.remove('fullscreen');
374
- this.updateButtons();
375
- }
376
- // Update if entering fullscreen
377
- else if (isInFullscreen && !this.isFullscreen) {
378
- this.isFullscreen = true;
379
- this.zoomora.classList.add('fullscreen');
380
- this.updateButtons();
381
- }
318
+ switch (e.key) {
319
+ case 'Escape':
320
+ if (this.isFullscreen) {
321
+ this.exitFullscreen();
322
+ } else {
323
+ this.close();
324
+ }
325
+ break;
326
+ case 'ArrowLeft':
327
+ this.prev();
328
+ break;
329
+ case 'ArrowRight':
330
+ this.next();
331
+ break;
332
+ case 'z':
333
+ case 'Z':
334
+ this.toggleZoom();
335
+ break;
336
+ case 'f':
337
+ case 'F':
338
+ this.toggleFullscreen();
339
+ break;
340
+ case 't':
341
+ case 'T':
342
+ this.toggleThumbnails();
343
+ break;
382
344
  }
345
+ }
383
346
 
384
- /**
385
- * Handle zoomora container clicks
386
- * @param {Event} e - Click event
387
- */
388
- handleZoomoraClick(e) {
389
- if (e.target === this.zoomora && this.options.closeOnBackgroundClick) {
390
- this.close();
391
- }
347
+ /**
348
+ * Open the lightbox with Enter or Space on a focused trigger
349
+ * @param {KeyboardEvent} e - Keyboard event
350
+ */
351
+ handleTriggerKeydown(e) {
352
+ if (e.key !== 'Enter' && e.key !== ' ') return;
353
+
354
+ const trigger = e.target.closest(this.options.selector);
355
+ if (!trigger) return;
356
+
357
+ e.preventDefault();
358
+ this.open(trigger);
359
+ }
360
+
361
+ /**
362
+ * Handle fullscreen change events
363
+ */
364
+ handleFullscreenChange() {
365
+ const isInFullscreen = !!(
366
+ document.fullscreenElement ||
367
+ document.webkitFullscreenElement ||
368
+ document.mozFullScreenElement ||
369
+ document.msFullscreenElement
370
+ );
371
+
372
+ // Only update if state changed from fullscreen to not fullscreen
373
+ if (!isInFullscreen && this.isFullscreen) {
374
+ this.isFullscreen = false;
375
+ this.zoomora.classList.remove('fullscreen');
376
+ this.updateButtons();
377
+ }
378
+ // Update if entering fullscreen
379
+ else if (isInFullscreen && !this.isFullscreen) {
380
+ this.isFullscreen = true;
381
+ this.zoomora.classList.add('fullscreen');
382
+ this.updateButtons();
392
383
  }
384
+ }
393
385
 
394
- /**
395
- * Handle content clicks
396
- * @param {Event} e - Click event
397
- */
398
- handleContentClick(e) {
399
- // Ignore click if we just finished dragging (within 200ms)
400
- if (Date.now() - this.lastDragTime < 200) {
401
- return;
402
- }
386
+ /**
387
+ * Handle zoomora container clicks
388
+ * @param {Event} e - Click event
389
+ */
390
+ handleZoomoraClick(e) {
391
+ if (e.target === this.zoomora && this.options.closeOnBackgroundClick) {
392
+ this.close();
393
+ }
394
+ }
403
395
 
404
- // Don't close on header clicks
405
- if (this.header && this.header.contains(e.target)) {
406
- return;
407
- }
396
+ /**
397
+ * Handle content clicks
398
+ * @param {Event} e - Click event
399
+ */
400
+ handleContentClick(e) {
401
+ // Ignore click if we just finished dragging (within 200ms)
402
+ if (Date.now() - this.lastDragTime < 200) {
403
+ return;
404
+ }
408
405
 
409
- // Don't close on button clicks
410
- if (e.target.closest('button')) {
411
- return;
412
- }
406
+ // Don't close on header clicks
407
+ if (this.header && this.header.contains(e.target)) {
408
+ return;
409
+ }
413
410
 
414
- // Check if click is directly on actual media element (img, video, iframe)
415
- const mediaElement = e.target.closest('.zoomora-media, .zoomora-video, .zoomora-iframe');
411
+ // Don't close on button clicks
412
+ if (e.target.closest('button')) {
413
+ return;
414
+ }
416
415
 
417
- if (mediaElement) {
418
- // Only toggle zoom for images
419
- if (mediaElement.classList.contains('zoomora-media') && !mediaElement.classList.contains('no-zoom')) {
420
- this.toggleZoom();
421
- }
422
- return;
423
- }
416
+ // Check if click is directly on actual media element (img, video, iframe)
417
+ const mediaElement = e.target.closest('.zoomora-media, .zoomora-video, .zoomora-iframe');
424
418
 
425
- // Check if clicking inside video container but not on video itself
426
- const videoContainer = e.target.closest('.zoomora-video-container');
427
- if (videoContainer) {
428
- // If clicking on the play button, don't close
429
- if (e.target.closest('.zoomora-play-button')) {
430
- return;
431
- }
432
- // If clicking on background area of video container, close
433
- if (this.options.closeOnBackgroundClick) {
434
- this.close();
435
- }
436
- return;
419
+ if (mediaElement) {
420
+ // Only toggle zoom for images
421
+ if (mediaElement.classList.contains('zoomora-media') && !mediaElement.classList.contains('no-zoom')) {
422
+ this.toggleZoom();
437
423
  }
424
+ return;
425
+ }
438
426
 
439
- // Check if clicking on background elements (including when zoomed)
440
- // This checks the actual target element, not just closest()
441
- const clickedElement = e.target;
442
- const isBackgroundElement =
443
- clickedElement.classList.contains('zoomora-content') ||
444
- clickedElement.classList.contains('zoomora-slide-container') ||
445
- clickedElement.classList.contains('zoomora-slide') ||
446
- clickedElement.classList.contains('zoomora-slides-container') ||
447
- clickedElement.id === 'zoomoraContent' ||
448
- clickedElement.id === 'zoomoraSlidesContainer';
449
-
450
- if (isBackgroundElement && this.options.closeOnBackgroundClick) {
451
- this.close();
427
+ // Check if clicking inside video container but not on video itself
428
+ const videoContainer = e.target.closest('.zoomora-video-container');
429
+ if (videoContainer) {
430
+ // If clicking on the play button, don't close
431
+ if (e.target.closest('.zoomora-play-button')) {
452
432
  return;
453
433
  }
454
-
455
- // If we reach here and no media was clicked, close
434
+ // If clicking on background area of video container, close
456
435
  if (this.options.closeOnBackgroundClick) {
457
436
  this.close();
458
437
  }
438
+ return;
459
439
  }
460
440
 
461
- /**
462
- * Handle mouse down events
463
- * @param {MouseEvent} e - Mouse event
464
- */
465
- handleMouseDown(e) {
466
- this.startDrag(e);
441
+ // Check if clicking on background elements (including when zoomed)
442
+ // This checks the actual target element, not just closest()
443
+ const clickedElement = e.target;
444
+ const isBackgroundElement =
445
+ clickedElement.classList.contains('zoomora-content') ||
446
+ clickedElement.classList.contains('zoomora-slide-container') ||
447
+ clickedElement.classList.contains('zoomora-slide') ||
448
+ clickedElement.classList.contains('zoomora-slides-container') ||
449
+ clickedElement.id === 'zoomoraContent' ||
450
+ clickedElement.id === 'zoomoraSlidesContainer';
451
+
452
+ if (isBackgroundElement && this.options.closeOnBackgroundClick) {
453
+ this.close();
454
+ return;
467
455
  }
468
456
 
469
- /**
470
- * Handle mouse move events
471
- * @param {MouseEvent} e - Mouse event
472
- */
473
- handleMouseMove(e) {
474
- this.drag(e);
457
+ // If we reach here and no media was clicked, close
458
+ if (this.options.closeOnBackgroundClick) {
459
+ this.close();
475
460
  }
461
+ }
476
462
 
477
- /**
478
- * Handle mouse up events
479
- */
480
- handleMouseUp() {
481
- this.endDrag();
482
- }
463
+ /**
464
+ * Handle mouse down events
465
+ * @param {MouseEvent} e - Mouse event
466
+ */
467
+ handleMouseDown(e) {
468
+ this.startDrag(e);
469
+ }
483
470
 
484
- /**
485
- * Handle touch start events
486
- * @param {TouchEvent} e - Touch event
487
- */
488
- handleTouchStart(e) {
489
- this.startDrag(e);
490
- }
471
+ /**
472
+ * Handle mouse move events
473
+ * @param {MouseEvent} e - Mouse event
474
+ */
475
+ handleMouseMove(e) {
476
+ this.drag(e);
477
+ }
491
478
 
492
- /**
493
- * Handle touch move events
494
- * @param {TouchEvent} e - Touch event
495
- */
496
- handleTouchMove(e) {
497
- this.drag(e);
498
- }
479
+ /**
480
+ * Handle mouse up events
481
+ */
482
+ handleMouseUp() {
483
+ this.endDrag();
484
+ }
499
485
 
500
- /**
501
- * Handle touch end events
502
- */
503
- handleTouchEnd() {
504
- this.endDrag();
505
- }
486
+ /**
487
+ * Handle touch start events
488
+ * @param {TouchEvent} e - Touch event
489
+ */
490
+ handleTouchStart(e) {
491
+ this.startDrag(e);
492
+ }
493
+
494
+ /**
495
+ * Handle touch move events
496
+ * @param {TouchEvent} e - Touch event
497
+ */
498
+ handleTouchMove(e) {
499
+ this.drag(e);
500
+ }
506
501
 
507
- /**
508
- * Handle scroll-wheel zoom
509
- * @param {WheelEvent} e - Wheel event
510
- */
511
- handleWheel(e) {
512
- const media = this.content.querySelector('.zoomora-media');
513
- if (!media || media.tagName !== 'IMG') return;
502
+ /**
503
+ * Handle touch end events
504
+ */
505
+ handleTouchEnd() {
506
+ this.endDrag();
507
+ }
514
508
 
515
- const zoomConfig = this.calculateZoomLevels(media);
516
- if (!zoomConfig.canZoom) return;
509
+ /**
510
+ * Handle scroll-wheel zoom
511
+ * @param {WheelEvent} e - Wheel event
512
+ */
513
+ handleWheel(e) {
514
+ const media = this.content.querySelector('.zoomora-media');
515
+ if (!media || media.tagName !== 'IMG') return;
517
516
 
518
- e.preventDefault();
517
+ const zoomConfig = this.calculateZoomLevels(media);
518
+ if (!zoomConfig.canZoom) return;
519
519
 
520
- const maxScale = zoomConfig.levels[zoomConfig.levels.length - 1];
521
- const step = this.options.zoomStep || 0.1;
522
- const direction = e.deltaY < 0 ? 1 : -1; // scroll/pinch up = zoom in
520
+ e.preventDefault();
523
521
 
524
- let newScale = (this.currentScale || 1) + (direction * step * maxScale);
525
- newScale = Math.max(1, Math.min(maxScale, newScale));
522
+ const maxScale = zoomConfig.levels[zoomConfig.levels.length - 1];
523
+ const step = this.options.zoomStep || 0.1;
524
+ const direction = e.deltaY < 0 ? 1 : -1; // scroll/pinch up = zoom in
526
525
 
527
- if (newScale === this.currentScale) return;
526
+ let newScale = (this.currentScale || 1) + (direction * step * maxScale);
527
+ newScale = Math.max(1, Math.min(maxScale, newScale));
528
528
 
529
- this.currentScale = newScale;
530
- this.isZoomed = newScale > 1;
531
- this.currentX = 0;
532
- this.currentY = 0;
529
+ if (newScale === this.currentScale) return;
533
530
 
534
- if (this.isZoomed) {
535
- media.style.transform = `scale(${newScale})`;
536
- media.style.cursor = 'grab';
537
- media.classList.add('zoomed');
538
- } else {
539
- media.style.transform = '';
540
- media.style.cursor = 'pointer';
541
- media.classList.remove('zoomed');
542
- }
531
+ this.currentScale = newScale;
532
+ this.isZoomed = newScale > 1;
533
+ this.currentX = 0;
534
+ this.currentY = 0;
543
535
 
544
- // Keep the click-to-cycle zoom index roughly in sync with the
545
- // continuous wheel scale, so a click after scrolling cycles sensibly.
546
- let nearestIndex = 0;
547
- zoomConfig.levels.forEach((level, index) => {
548
- if (Math.abs(level - newScale) < Math.abs(zoomConfig.levels[nearestIndex] - newScale)) {
549
- nearestIndex = index;
550
- }
551
- });
552
- media.dataset.zoomLevel = nearestIndex.toString();
536
+ if (this.isZoomed) {
537
+ media.style.transform = `scale(${newScale})`;
538
+ media.style.cursor = 'grab';
539
+ media.classList.add('zoomed');
540
+ } else {
541
+ media.style.transform = '';
542
+ media.style.cursor = 'pointer';
543
+ media.classList.remove('zoomed');
553
544
  }
554
545
 
555
- /**
556
- * Get image source from element
557
- * @param {Element} item - DOM element
558
- * @returns {string} - Image source URL
559
- */
560
- getImageSource(item) {
561
- const src = item.dataset.src || item.src || item.getAttribute('src');
562
- const type = item.dataset.type || 'image';
563
-
564
- // Handle YouTube URLs for video types
565
- if (type === 'video' && src) {
566
- return this.convertToEmbedUrl(src);
546
+ // Keep the click-to-cycle zoom index roughly in sync with the
547
+ // continuous wheel scale, so a click after scrolling cycles sensibly.
548
+ let nearestIndex = 0;
549
+ zoomConfig.levels.forEach((level, index) => {
550
+ if (Math.abs(level - newScale) < Math.abs(zoomConfig.levels[nearestIndex] - newScale)) {
551
+ nearestIndex = index;
567
552
  }
553
+ });
554
+ media.dataset.zoomLevel = nearestIndex.toString();
555
+ }
556
+
557
+ /**
558
+ * Get image source from element
559
+ * @param {Element} item - DOM element
560
+ * @returns {string} - Image source URL
561
+ */
562
+ getImageSource(item) {
563
+ const src = item.dataset.src || item.getAttribute('href') || item.src || item.getAttribute('src');
564
+ const type = item.dataset.type || 'image';
568
565
 
569
- return src;
566
+ // Handle YouTube URLs for video types
567
+ if (type === 'video' && src) {
568
+ return this.convertToEmbedUrl(src);
570
569
  }
571
570
 
572
- /**
573
- * Convert YouTube URLs to embed format
574
- * @param url
575
- * @returns {*|string}
576
- */
577
- convertToEmbedUrl(url) {
578
- // If already an embed URL, return as is
579
- if (url.includes('youtube.com/embed/')) {
580
- return url;
581
- }
571
+ return src;
572
+ }
582
573
 
583
- // Handle different YouTube URL formats
584
- let videoId = null;
585
- let startTime = null;
574
+ /**
575
+ * Convert YouTube URLs to embed format
576
+ * @param url
577
+ * @returns {*|string}
578
+ */
579
+ convertToEmbedUrl(url) {
580
+ // If already an embed URL, return as is
581
+ if (url.includes('youtube.com/embed/')) {
582
+ return url;
583
+ }
586
584
 
587
- // Extract video ID and start time from different YouTube URL formats
588
- if (url.includes('youtube.com/watch')) {
589
- const urlParams = new URLSearchParams(url.split('?')[1]);
590
- videoId = urlParams.get('v');
585
+ // Handle different YouTube URL formats
586
+ let videoId = null;
587
+ let startTime = null;
588
+
589
+ // Extract video ID and start time from different YouTube URL formats
590
+ if (url.includes('youtube.com/watch')) {
591
+ const urlParams = new URLSearchParams(url.split('?')[1]);
592
+ videoId = urlParams.get('v');
593
+ const t = urlParams.get('t');
594
+ if (t) {
595
+ // Convert time format (e.g., "111s" to seconds)
596
+ startTime = t.replace('s', '');
597
+ }
598
+ } else if (url.includes('youtu.be/')) {
599
+ const parts = url.split('/');
600
+ const videoAndParams = parts[parts.length - 1];
601
+ if (videoAndParams.includes('?')) {
602
+ videoId = videoAndParams.split('?')[0];
603
+ const urlParams = new URLSearchParams(videoAndParams.split('?')[1]);
591
604
  const t = urlParams.get('t');
592
- if (t) {
593
- // Convert time format (e.g., "111s" to seconds)
594
- startTime = t.replace('s', '');
595
- }
596
- } else if (url.includes('youtu.be/')) {
597
- const parts = url.split('/');
598
- const videoAndParams = parts[parts.length - 1];
599
- if (videoAndParams.includes('?')) {
600
- videoId = videoAndParams.split('?')[0];
601
- const urlParams = new URLSearchParams(videoAndParams.split('?')[1]);
602
- const t = urlParams.get('t');
603
- if (t) startTime = t.replace('s', '');
604
- } else {
605
- videoId = videoAndParams;
606
- }
605
+ if (t) startTime = t.replace('s', '');
606
+ } else {
607
+ videoId = videoAndParams;
607
608
  }
609
+ }
608
610
 
609
- if (videoId) {
610
- let embedUrl = `https://www.youtube.com/embed/${videoId}`;
611
- const params = new URLSearchParams();
612
-
613
- if (startTime) {
614
- params.append('start', startTime);
615
- }
611
+ if (videoId) {
612
+ let embedUrl = `https://www.youtube.com/embed/${videoId}`;
613
+ const params = new URLSearchParams();
616
614
 
617
- // Add other useful parameters
618
- params.append('rel', '0'); // Don't show related videos
619
- params.append('modestbranding', '1'); // Modest branding
615
+ if (startTime) {
616
+ params.append('start', startTime);
617
+ }
620
618
 
621
- // Option: Add autoplay for YouTube videos (uncomment if desired)
622
- // params.append('autoplay', '1');
619
+ // Add other useful parameters
620
+ params.append('rel', '0'); // Don't show related videos
621
+ params.append('modestbranding', '1'); // Modest branding
623
622
 
624
- if (params.toString()) {
625
- embedUrl += '?' + params.toString();
626
- }
623
+ if (this.options.autoplayVideos) {
624
+ params.append('autoplay', '1');
625
+ }
627
626
 
628
- return embedUrl;
627
+ if (params.toString()) {
628
+ embedUrl += '?' + params.toString();
629
629
  }
630
630
 
631
- // If not a YouTube URL or couldn't parse, return original
632
- return url;
631
+ return embedUrl;
633
632
  }
634
633
 
635
- /**
636
- * Get thumbnail source from element
637
- * @param {Element} item - DOM element
638
- * @returns {string} - Thumbnail source URL
639
- */
640
- getThumbnailSource(item) {
641
- const type = item.dataset.type || 'image';
634
+ // If not a YouTube URL or couldn't parse, return original
635
+ return url;
636
+ }
642
637
 
643
- // For video items, always use the img.src as thumbnail (poster image)
644
- if (type === 'video') {
645
- return item.src || item.getAttribute('src');
646
- }
638
+ /**
639
+ * Get thumbnail source from element
640
+ * Order: data-thumbnail, the trigger's own image or the image inside it, then data-src or href.
641
+ * @param {Element|Object} item - DOM element or virtual item
642
+ * @returns {string} - Thumbnail source URL
643
+ */
644
+ getThumbnailSource(item) {
645
+ if (item.dataset.thumbnail) {
646
+ return item.dataset.thumbnail;
647
+ }
647
648
 
648
- // For regular images, use data-src or fallback to src
649
- return item.dataset.src || item.src || item.getAttribute('src');
649
+ const innerImage = item.querySelector?.('img');
650
+ const imageSrc = item.getAttribute('src') || innerImage?.getAttribute('src');
651
+ const type = item.dataset.type || 'image';
652
+
653
+ if (type === 'video') {
654
+ return imageSrc;
650
655
  }
651
656
 
652
- /**
653
- * Get group identifier from element
654
- * @param {Element} element - DOM element
655
- * @returns {string} - Group identifier
656
- */
657
- getGroup(element) {
658
- if (this.options.useHref) {
659
- return element.dataset.lightbox || element.dataset.gallery || 'default';
660
- }
661
- return element.dataset.zoomora || 'default';
657
+ return imageSrc || item.dataset.src || item.getAttribute('href');
658
+ }
659
+
660
+ /**
661
+ * Get group identifier from element
662
+ * @param {Element} element - DOM element
663
+ * @returns {string} - Group identifier
664
+ */
665
+ getGroup(element) {
666
+ if (this.options.useHref) {
667
+ return element.dataset.lightbox || element.dataset.gallery || 'default';
662
668
  }
669
+ return element.dataset.zoomora || 'default';
670
+ }
663
671
 
664
- /**
665
- * Open lightbox with specified element
666
- * @param {Element} element - DOM element to open
667
- */
668
- open(element) {
669
- if (!element) return;
672
+ /**
673
+ * Open lightbox with specified element
674
+ * @param {Element} element - DOM element to open
675
+ */
676
+ open(element) {
677
+ if (!element) return;
670
678
 
671
- const group = this.getGroup(element);
672
- let groupSelector;
679
+ const group = this.getGroup(element);
680
+ let groupSelector;
673
681
 
674
- if (this.options.useHref) {
675
- if (element.dataset.lightbox) {
676
- groupSelector = `[data-lightbox="${group}"]`;
677
- } else if (element.dataset.gallery) {
678
- groupSelector = `[data-gallery="${group}"]`;
679
- } else {
680
- groupSelector = this.options.selector;
681
- }
682
+ if (this.options.useHref) {
683
+ if (element.dataset.lightbox) {
684
+ groupSelector = `[data-lightbox="${group}"]`;
685
+ } else if (element.dataset.gallery) {
686
+ groupSelector = `[data-gallery="${group}"]`;
682
687
  } else {
683
- groupSelector = `[data-zoomora="${group}"]`;
688
+ groupSelector = this.options.selector;
684
689
  }
690
+ } else {
691
+ groupSelector = `[data-zoomora="${group}"]`;
692
+ }
685
693
 
686
- this.items = Array.from(document.querySelectorAll(groupSelector));
687
- this.currentIndex = Math.max(0, this.items.indexOf(element));
694
+ this.items = Array.from(document.querySelectorAll(groupSelector));
695
+ this.currentIndex = Math.max(0, this.items.indexOf(element));
688
696
 
689
- this._activateLightbox();
697
+ this._activateLightbox();
690
698
 
691
- // Trigger callback
692
- if (typeof this.options.onOpen === 'function') {
693
- this.options.onOpen(element, this.currentIndex);
694
- }
699
+ // Trigger callback
700
+ if (typeof this.options.onOpen === 'function') {
701
+ this.options.onOpen(element, this.currentIndex);
695
702
  }
703
+ }
696
704
 
697
- /**
698
- * Open lightbox programmatically with image(s) not present in the DOM.
699
- * Accepts a single URL, an array of URLs, a config object, or an array
700
- * of config objects — so all of these work:
701
- *
702
- * lightbox.openImage('photo.jpg');
703
- * lightbox.openImage(['a.jpg', 'b.jpg'], { startIndex: 1 });
704
- * lightbox.openImage({ src: 'photo.jpg', caption: 'Hello' });
705
- * lightbox.openImage([
706
- * { src: 'a.jpg', caption: 'First' },
707
- * { src: 'https://youtu.be/xyz', type: 'video', thumb: 'poster.jpg' },
708
- * ]);
709
- *
710
- * @param {string|string[]|Object|Object[]} images - URL(s) or config object(s)
711
- * @param {Object} [opts] - Options
712
- * @param {number} [opts.startIndex=0] - Index to open at when passing an array
713
- */
714
- openImage(images, opts = {}) {
715
- const list = Array.isArray(images) ? images : [images];
716
-
717
- this.items = list.map(entry =>
718
- this.createVirtualItem(typeof entry === 'string' ? {src: entry} : entry)
719
- );
720
-
721
- this.currentIndex = Math.max(
722
- 0,
723
- Math.min(opts.startIndex || 0, this.items.length - 1)
724
- );
725
-
726
- this._activateLightbox();
727
-
728
- // Trigger callback
729
- if (typeof this.options.onOpen === 'function') {
730
- this.options.onOpen(this.items[this.currentIndex], this.currentIndex);
731
- }
732
- }
705
+ /**
706
+ * Open lightbox programmatically with image(s) not present in the DOM.
707
+ * Accepts a single URL, an array of URLs, a config object, or an array
708
+ * of config objects — so all of these work:
709
+ *
710
+ * lightbox.openImage('photo.jpg');
711
+ * lightbox.openImage(['a.jpg', 'b.jpg'], { startIndex: 1 });
712
+ * lightbox.openImage({ src: 'photo.jpg', caption: 'Hello' });
713
+ * lightbox.openImage([
714
+ * { src: 'a.jpg', caption: 'First' },
715
+ * { src: 'https://youtu.be/xyz', type: 'video', thumb: 'poster.jpg' },
716
+ * ]);
717
+ *
718
+ * @param {string|string[]|Object|Object[]} images - URL(s) or config object(s)
719
+ * @param {Object} [opts] - Options
720
+ * @param {number} [opts.startIndex=0] - Index to open at when passing an array
721
+ */
722
+ openImage(images, opts = {}) {
723
+ const list = Array.isArray(images) ? images : [images];
733
724
 
734
- /**
735
- * Create a virtual item that mimics a DOM element's interface so
736
- * programmatic images work through the existing render pipeline without
737
- * any changes to getImageSource, getThumbnailSource, updateCaption, etc.
738
- * @param {Object} config
739
- * @param {string} config.src - Full-size image/video/iframe URL
740
- * @param {string} [config.thumb] - Thumbnail URL (falls back to src)
741
- * @param {string} [config.type='image'] - 'image', 'video', or 'iframe'
742
- * @param {string} [config.caption=''] - Caption text
743
- * @param {string} [config.alt=''] - Alt text for the thumbnail img
744
- * @returns {Object} Virtual item
745
- */
746
- createVirtualItem({src, thumb, type = 'image', caption = '', alt = ''}) {
747
- const thumbSrc = thumb || src;
748
- return {
749
- dataset: {src, type, caption},
750
- src: thumbSrc,
751
- alt,
752
- title: caption,
753
- getAttribute: (name) => name === 'src' ? thumbSrc : null,
754
- };
755
- }
756
-
757
- /**
758
- * Shared activation sequence used by both open() and openImage().
759
- * Resets state, shows the lightbox, and triggers all UI updates.
760
- */
761
- _activateLightbox() {
762
- // Reset states
763
- this.isZoomed = false;
764
- this.isFullscreen = false;
765
- this.thumbnailsVisible = true;
766
- this.currentX = 0;
767
- this.currentY = 0;
768
- this.currentScale = 1;
725
+ this.items = list.map(entry =>
726
+ this.createVirtualItem(typeof entry === 'string' ? {src: entry} : entry)
727
+ );
769
728
 
770
- this.zoomora.classList.add('active');
771
- document.body.style.overflow = 'hidden';
729
+ this.currentIndex = Math.max(
730
+ 0,
731
+ Math.min(opts.startIndex || 0, this.items.length - 1)
732
+ );
772
733
 
773
- this.loadContent();
774
- this.updateThumbnails();
775
- this.updateCounter();
776
- this.updateNavigation();
777
- this.updateCaption();
778
- this.updateButtons();
734
+ this._activateLightbox();
779
735
 
780
- // Initialize auto-hide
781
- if (this.autoHideEnabled) {
782
- this.startAutoHideTimer();
783
- } else {
784
- this.showControls();
785
- }
736
+ // Trigger callback
737
+ if (typeof this.options.onOpen === 'function') {
738
+ this.options.onOpen(this.items[this.currentIndex], this.currentIndex);
786
739
  }
740
+ }
787
741
 
788
- /**
789
- * Close lightbox
790
- */
791
- close() {
792
- if (this.isFullscreen) {
793
- this.exitFullscreen();
794
- }
795
-
796
- this.zoomora.classList.remove('active');
797
- document.body.style.overflow = '';
798
- this.isZoomed = false;
799
- this.isFullscreen = false;
800
- this.currentX = 0;
801
- this.currentY = 0;
802
- this.currentScale = 1;
742
+ /**
743
+ * Create a virtual item that mimics a DOM element's interface so
744
+ * programmatic images work through the existing render pipeline without
745
+ * any changes to getImageSource, getThumbnailSource, updateCaption, etc.
746
+ * @param {Object} config
747
+ * @param {string} config.src - Full-size image/video/iframe URL
748
+ * @param {string} [config.thumb] - Thumbnail URL (falls back to src)
749
+ * @param {string} [config.type='image'] - 'image', 'video', or 'iframe'
750
+ * @param {string} [config.caption=''] - Caption text
751
+ * @param {string} [config.alt=''] - Alt text for the thumbnail img
752
+ * @returns {Object} Virtual item
753
+ */
754
+ createVirtualItem({src, thumb, type = 'image', caption = '', alt = ''}) {
755
+ const thumbSrc = thumb || src;
756
+ return {
757
+ dataset: {src, type, caption},
758
+ src: thumbSrc,
759
+ alt,
760
+ title: caption,
761
+ getAttribute: (name) => name === 'src' ? thumbSrc : null,
762
+ };
763
+ }
803
764
 
804
- const media = this.content.querySelector('.zoomora-media');
805
- if (media) {
806
- media.classList.remove('zoomed');
807
- media.style.transform = '';
808
- media.style.cursor = '';
809
- }
765
+ /**
766
+ * Shared activation sequence used by both open() and openImage().
767
+ * Resets state, shows the lightbox, and triggers all UI updates.
768
+ */
769
+ _activateLightbox() {
770
+ // Reset states
771
+ this.isZoomed = false;
772
+ this.isFullscreen = false;
773
+ this.thumbnailsVisible = true;
774
+ this.currentX = 0;
775
+ this.currentY = 0;
776
+ this.currentScale = 1;
777
+
778
+ this.zoomora.classList.add('active');
779
+ document.body.style.overflow = 'hidden';
780
+
781
+ this.loadContent();
782
+ this.updateThumbnails();
783
+ this.updateCounter();
784
+ this.updateNavigation();
785
+ this.updateCaption();
786
+ this.updateButtons();
787
+
788
+ // Initialize auto-hide
789
+ if (this.autoHideEnabled) {
790
+ this.startAutoHideTimer();
791
+ } else {
792
+ this.showControls();
793
+ }
794
+ }
810
795
 
811
- this.zoomora.classList.remove('fullscreen');
812
- this.updateButtons();
796
+ /**
797
+ * Close lightbox
798
+ */
799
+ close() {
800
+ if (this.isFullscreen) {
801
+ this.exitFullscreen();
802
+ }
813
803
 
814
- // Clean up auto-hide
815
- this.stopAutoHideTimer();
816
- this.showControls();
804
+ this.zoomora.classList.remove('active');
805
+ document.body.style.overflow = '';
817
806
 
818
- // Trigger callback
819
- if (typeof this.options.onClose === 'function') {
820
- this.options.onClose(this.currentIndex);
821
- }
807
+ // Stop playing media; the next open builds fresh media elements
808
+ const activeVideo = this.content.querySelector('video');
809
+ if (activeVideo) {
810
+ activeVideo.pause();
822
811
  }
823
812
 
824
- /**
825
- * Navigate to previous item
826
- */
827
- prev() {
828
- if (this.currentIndex > 0) {
829
- this.currentIndex--;
830
- this.loadContent();
831
- this.updateCounter();
832
- this.updateThumbnails();
833
- this.updateNavigation();
834
- this.updateCaption();
813
+ const activeIframe = this.content.querySelector('iframe');
814
+ if (activeIframe) {
815
+ activeIframe.src = 'about:blank';
816
+ }
835
817
 
836
- // Trigger callback
837
- if (typeof this.options.onPrev === 'function') {
838
- this.options.onPrev(this.currentIndex);
839
- }
840
- }
818
+ this.isZoomed = false;
819
+ this.isFullscreen = false;
820
+ this.currentX = 0;
821
+ this.currentY = 0;
822
+ this.currentScale = 1;
823
+
824
+ const media = this.content.querySelector('.zoomora-media');
825
+ if (media) {
826
+ media.classList.remove('zoomed');
827
+ media.style.transform = '';
828
+ media.style.cursor = '';
841
829
  }
842
830
 
843
- /**
844
- * Navigate to next item
845
- */
846
- next() {
847
- if (this.currentIndex < this.items.length - 1) {
848
- this.currentIndex++;
849
- this.loadContent();
850
- this.updateCounter();
851
- this.updateThumbnails();
852
- this.updateNavigation();
853
- this.updateCaption();
831
+ this.zoomora.classList.remove('fullscreen');
832
+ this.updateButtons();
854
833
 
855
- // Trigger callback
856
- if (typeof this.options.onNext === 'function') {
857
- this.options.onNext(this.currentIndex);
858
- }
859
- }
834
+ // Clean up auto-hide
835
+ this.stopAutoHideTimer();
836
+ this.showControls();
837
+
838
+ // Trigger callback
839
+ if (typeof this.options.onClose === 'function') {
840
+ this.options.onClose(this.currentIndex);
860
841
  }
842
+ }
861
843
 
862
- /**
863
- * Load content for current item
864
- */
865
- loadContent() {
866
- if (!this.items[this.currentIndex]) return;
844
+ /**
845
+ * Navigate to previous item
846
+ */
847
+ prev() {
848
+ if (this.currentIndex > 0) {
849
+ this.currentIndex--;
850
+ this.loadContent();
851
+ this.updateCounter();
852
+ this.updateThumbnails();
853
+ this.updateNavigation();
854
+ this.updateCaption();
867
855
 
868
- const item = this.items[this.currentIndex];
869
- const src = this.getImageSource(item);
870
- const type = item.dataset.type || 'image';
856
+ // Trigger callback
857
+ if (typeof this.options.onPrev === 'function') {
858
+ this.options.onPrev(this.currentIndex);
859
+ }
860
+ }
861
+ }
871
862
 
872
- if (!src) {
873
- console.error('Zoomora: No source found for item');
874
- return;
863
+ /**
864
+ * Navigate to next item
865
+ */
866
+ next() {
867
+ if (this.currentIndex < this.items.length - 1) {
868
+ this.currentIndex++;
869
+ this.loadContent();
870
+ this.updateCounter();
871
+ this.updateThumbnails();
872
+ this.updateNavigation();
873
+ this.updateCaption();
874
+
875
+ // Trigger callback
876
+ if (typeof this.options.onNext === 'function') {
877
+ this.options.onNext(this.currentIndex);
875
878
  }
879
+ }
880
+ }
876
881
 
877
- // Reset zoom state
878
- this.isZoomed = false;
879
- this.currentX = 0;
880
- this.currentY = 0;
881
- this.currentScale = 1;
882
+ /**
883
+ * Load content for current item
884
+ */
885
+ loadContent() {
886
+ if (!this.items[this.currentIndex]) return;
882
887
 
883
- // Show loading spinner
884
- this.loading.style.display = 'block';
888
+ const item = this.items[this.currentIndex];
889
+ const src = this.getImageSource(item);
890
+ const type = item.dataset.type || 'image';
885
891
 
886
- if (this.slidesContainer) {
887
- this.slidesContainer.innerHTML = '';
888
- }
892
+ if (!src) {
893
+ console.error('Zoomora: No source found for item');
894
+ return;
895
+ }
896
+
897
+ // Reset zoom state
898
+ this.isZoomed = false;
899
+ this.currentX = 0;
900
+ this.currentY = 0;
901
+ this.currentScale = 1;
902
+
903
+ // Show loading spinner
904
+ this.loading.style.display = 'block';
905
+
906
+ if (this.slidesContainer) {
907
+ this.slidesContainer.innerHTML = '';
908
+ }
909
+
910
+ setTimeout(() => {
911
+ this.renderContent(src, type, item);
912
+ }, 100);
913
+ }
889
914
 
890
- setTimeout(() => {
891
- this.renderContent(src, type, item);
892
- }, 100);
893
- }
894
-
895
- /**
896
- * Render content based on type
897
- * @param {string} src - Source URL
898
- * @param {string} type - Content type
899
- * @param {Element} item - Original item element
900
- */
901
- renderContent(src, type, item) {
902
- const slideContainer = document.createElement('div');
903
- slideContainer.className = 'zoomora-slide-container';
904
-
905
- // Create slide wrapper
906
- const slide = document.createElement('div');
907
- slide.className = `zoomora-slide ${this.options.transition === 'slide' ? 'slide-effect' : 'fade-effect'}`;
908
- slide.style.opacity = '0';
909
- slide.style.transform = this.options.transition === 'slide' ? 'translateX(100%)' : 'scale(0.8)';
910
-
911
- let mediaElement;
912
-
913
- try {
914
- switch (type) {
915
- case 'video':
916
- if (src.endsWith('.mp4') || src.endsWith('.webm') || src.endsWith('.ogg')) {
917
- const videoContainer = document.createElement('div');
918
- videoContainer.className = 'zoomora-video-container';
919
-
920
- mediaElement = document.createElement('video');
921
- mediaElement.src = src;
922
- mediaElement.className = 'zoomora-video';
923
- mediaElement.setAttribute('preload', 'metadata');
924
-
925
- const posterSrc = item.src || item.getAttribute('src');
926
- if (posterSrc) {
927
- mediaElement.setAttribute('poster', posterSrc);
928
- }
929
-
930
- const playButton = document.createElement('div');
931
- playButton.className = 'zoomora-play-button';
932
- playButton.innerHTML = `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>`;
933
-
934
- playButton.addEventListener('click', () => {
935
- mediaElement.play();
936
- playButton.style.display = 'none';
937
- mediaElement.setAttribute('controls', '');
938
- });
939
-
940
- mediaElement.addEventListener('ended', () => {
941
- playButton.style.display = 'flex';
942
- });
943
-
944
- videoContainer.appendChild(mediaElement);
945
- videoContainer.appendChild(playButton);
946
- slideContainer.appendChild(videoContainer);
947
- } else {
948
- mediaElement = document.createElement('iframe');
949
- mediaElement.src = src;
950
- mediaElement.className = 'zoomora-video';
951
- mediaElement.setAttribute('allowfullscreen', '');
952
- mediaElement.setAttribute('frameborder', '0');
953
- slideContainer.appendChild(mediaElement);
915
+ /**
916
+ * Render content based on type
917
+ * @param {string} src - Source URL
918
+ * @param {string} type - Content type
919
+ * @param {Element} item - Original item element
920
+ */
921
+ renderContent(src, type, item) {
922
+ const slideContainer = document.createElement('div');
923
+ slideContainer.className = 'zoomora-slide-container';
924
+
925
+ // Create slide wrapper
926
+ const slide = document.createElement('div');
927
+ slide.className = `zoomora-slide ${this.options.transition === 'slide' ? 'slide-effect' : 'fade-effect'}`;
928
+ slide.style.opacity = '0';
929
+ slide.style.transform = this.options.transition === 'slide' ? 'translateX(100%)' : 'scale(0.8)';
930
+
931
+ let mediaElement;
932
+
933
+ try {
934
+ switch (type) {
935
+ case 'video':
936
+ if (src.endsWith('.mp4') || src.endsWith('.webm') || src.endsWith('.ogg')) {
937
+ const videoContainer = document.createElement('div');
938
+ videoContainer.className = 'zoomora-video-container';
939
+
940
+ mediaElement = document.createElement('video');
941
+ mediaElement.src = src;
942
+ mediaElement.className = 'zoomora-video';
943
+ mediaElement.setAttribute('preload', 'metadata');
944
+
945
+ const posterSrc = item.src || item.getAttribute('src');
946
+ if (posterSrc) {
947
+ mediaElement.setAttribute('poster', posterSrc);
954
948
  }
955
- break;
956
949
 
957
- case 'iframe':
950
+ const playButton = document.createElement('div');
951
+ playButton.className = 'zoomora-play-button';
952
+ playButton.innerHTML = `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>`;
953
+
954
+ playButton.addEventListener('click', () => {
955
+ mediaElement.play();
956
+ playButton.style.display = 'none';
957
+ mediaElement.setAttribute('controls', '');
958
+ });
959
+
960
+ mediaElement.addEventListener('ended', () => {
961
+ playButton.style.display = 'flex';
962
+ });
963
+
964
+ videoContainer.appendChild(mediaElement);
965
+ videoContainer.appendChild(playButton);
966
+ if (this.options.autoplayVideos) {
967
+ mediaElement.play()
968
+ .then(() => {
969
+ playButton.style.display = 'none';
970
+ mediaElement.setAttribute('controls', '');
971
+ })
972
+ .catch(() => {
973
+ // Autoplay blocked by the browser: the play button stays visible
974
+ });
975
+ }
976
+ slideContainer.appendChild(videoContainer);
977
+ } else {
958
978
  mediaElement = document.createElement('iframe');
959
979
  mediaElement.src = src;
960
- mediaElement.className = 'zoomora-iframe';
980
+ mediaElement.className = 'zoomora-video';
981
+ mediaElement.setAttribute('allowfullscreen', '');
982
+ mediaElement.setAttribute('allow', 'autoplay; fullscreen; picture-in-picture');
961
983
  mediaElement.setAttribute('frameborder', '0');
962
984
  slideContainer.appendChild(mediaElement);
963
- break;
985
+ }
986
+ break;
964
987
 
965
- default:
966
- mediaElement = document.createElement('img');
967
- mediaElement.src = src;
968
- mediaElement.className = 'zoomora-media';
969
- mediaElement.alt = item.alt || item.title || '';
988
+ case 'iframe':
989
+ mediaElement = document.createElement('iframe');
990
+ mediaElement.src = src;
991
+ mediaElement.className = 'zoomora-iframe';
992
+ mediaElement.setAttribute('frameborder', '0');
993
+ slideContainer.appendChild(mediaElement);
994
+ break;
970
995
 
971
- mediaElement.onload = () => {
972
- this.loading.style.display = 'none';
973
- this.checkZoomability(mediaElement);
974
- this.animateSlideIn(slide);
975
- };
996
+ default:
997
+ mediaElement = document.createElement('img');
998
+ mediaElement.src = src;
999
+ mediaElement.className = 'zoomora-media';
1000
+ mediaElement.alt = item.alt || item.title || '';
976
1001
 
977
- mediaElement.onerror = () => {
978
- this.loading.style.display = 'none';
979
- console.error('Zoomora: Failed to load image:', src);
980
- this.animateSlideIn(slide);
981
- };
1002
+ mediaElement.onload = () => {
1003
+ this.loading.style.display = 'none';
1004
+ this.checkZoomability(mediaElement);
1005
+ this.animateSlideIn(slide);
1006
+ };
982
1007
 
983
- slideContainer.appendChild(mediaElement);
984
- }
1008
+ mediaElement.onerror = () => {
1009
+ this.loading.style.display = 'none';
1010
+ console.error('Zoomora: Failed to load image:', src);
1011
+ this.animateSlideIn(slide);
1012
+ };
985
1013
 
986
- slide.appendChild(slideContainer);
1014
+ slideContainer.appendChild(mediaElement);
1015
+ }
987
1016
 
988
- // Clear existing slides and add new one
989
- this.slidesContainer.innerHTML = '';
990
- this.slidesContainer.appendChild(slide);
1017
+ slide.appendChild(slideContainer);
991
1018
 
992
- if (type !== 'image') {
993
- this.loading.style.display = 'none';
994
- this.animateSlideIn(slide);
995
- }
1019
+ // Clear existing slides and add new one
1020
+ this.slidesContainer.innerHTML = '';
1021
+ this.slidesContainer.appendChild(slide);
996
1022
 
997
- } catch (error) {
998
- console.error('Zoomora: Error rendering content:', error);
1023
+ if (type !== 'image') {
999
1024
  this.loading.style.display = 'none';
1025
+ this.animateSlideIn(slide);
1000
1026
  }
1001
- }
1002
-
1003
- // Animation helper
1004
- animateSlideIn(slide) {
1005
- // Force reflow
1006
- slide.offsetHeight;
1007
-
1008
- // Add active class and animate
1009
- slide.classList.add('active');
1010
1027
 
1011
- if (this.options.transition === 'slide') {
1012
- slide.style.transition = `transform ${this.options.animationDuration}ms ease, opacity ${this.options.animationDuration}ms ease`;
1013
- slide.style.transform = 'translateX(0)';
1014
- slide.style.opacity = '1';
1015
- } else {
1016
- slide.style.transition = `transform ${this.options.animationDuration}ms ease, opacity ${this.options.animationDuration}ms ease`;
1017
- slide.style.transform = 'scale(1)';
1018
- slide.style.opacity = '1';
1019
- }
1028
+ } catch (error) {
1029
+ console.error('Zoomora: Error rendering content:', error);
1030
+ this.loading.style.display = 'none';
1020
1031
  }
1032
+ }
1021
1033
 
1022
- // Toggle auto-hide controls
1023
- toggleAutoHide() {
1024
- this.autoHideEnabled = !this.autoHideEnabled;
1025
- const autoHideBtn = document.getElementById('zoomoraAutoHideBtn');
1026
-
1027
- if (this.autoHideEnabled) {
1028
- autoHideBtn.classList.add('active');
1029
- autoHideBtn.title = 'Disable Auto-Hide Controls';
1030
- this.startAutoHideTimer();
1031
- } else {
1032
- autoHideBtn.classList.remove('active');
1033
- autoHideBtn.title = 'Enable Auto-Hide Controls';
1034
- this.stopAutoHideTimer();
1035
- this.showControls();
1036
- }
1034
+ // Animation helper
1035
+ animateSlideIn(slide) {
1036
+ // Force reflow
1037
+ slide.offsetHeight;
1038
+
1039
+ // Add active class and animate
1040
+ slide.classList.add('active');
1041
+
1042
+ if (this.options.transition === 'slide') {
1043
+ slide.style.transition = `transform ${this.options.animationDuration}ms ease, opacity ${this.options.animationDuration}ms ease`;
1044
+ slide.style.transform = 'translateX(0)';
1045
+ slide.style.opacity = '1';
1046
+ } else {
1047
+ slide.style.transition = `transform ${this.options.animationDuration}ms ease, opacity ${this.options.animationDuration}ms ease`;
1048
+ slide.style.transform = 'scale(1)';
1049
+ slide.style.opacity = '1';
1037
1050
  }
1051
+ }
1038
1052
 
1039
- // Handle user activity
1040
- handleUserActivity() {
1041
- if (!this.autoHideEnabled) return;
1053
+ // Toggle auto-hide controls
1054
+ toggleAutoHide() {
1055
+ this.autoHideEnabled = !this.autoHideEnabled;
1056
+ const autoHideBtn = document.getElementById('zoomoraAutoHideBtn');
1042
1057
 
1043
- this.showControls();
1058
+ if (this.autoHideEnabled) {
1059
+ autoHideBtn.classList.add('active');
1060
+ autoHideBtn.title = 'Disable Auto-Hide Controls';
1044
1061
  this.startAutoHideTimer();
1062
+ } else {
1063
+ autoHideBtn.classList.remove('active');
1064
+ autoHideBtn.title = 'Enable Auto-Hide Controls';
1065
+ this.stopAutoHideTimer();
1066
+ this.showControls();
1045
1067
  }
1068
+ }
1046
1069
 
1047
- // Start auto-hide timer
1048
- startAutoHideTimer() {
1049
- this.stopAutoHideTimer();
1070
+ // Handle user activity
1071
+ handleUserActivity() {
1072
+ if (!this.autoHideEnabled) return;
1050
1073
 
1051
- this.autoHideTimeout = setTimeout(() => {
1052
- this.hideControls();
1053
- }, this.options.autoHideDelay);
1054
- }
1074
+ this.showControls();
1075
+ this.startAutoHideTimer();
1076
+ }
1055
1077
 
1056
- // Stop auto-hide timer
1057
- stopAutoHideTimer() {
1058
- if (this.autoHideTimeout) {
1059
- clearTimeout(this.autoHideTimeout);
1060
- this.autoHideTimeout = null;
1061
- }
1062
- }
1078
+ // Start auto-hide timer
1079
+ startAutoHideTimer() {
1080
+ this.stopAutoHideTimer();
1063
1081
 
1064
- // Show controls
1065
- showControls() {
1066
- if (this.isControlsVisible) return;
1082
+ this.autoHideTimeout = setTimeout(() => {
1083
+ this.hideControls();
1084
+ }, this.options.autoHideDelay);
1085
+ }
1067
1086
 
1068
- this.isControlsVisible = true;
1069
- this.zoomora.classList.remove('controls-hidden');
1087
+ // Stop auto-hide timer
1088
+ stopAutoHideTimer() {
1089
+ if (this.autoHideTimeout) {
1090
+ clearTimeout(this.autoHideTimeout);
1091
+ this.autoHideTimeout = null;
1092
+ }
1093
+ }
1070
1094
 
1071
- // Animate controls back in
1072
- if (this.header) {
1073
- this.header.style.transform = 'translateY(0)';
1074
- this.header.style.opacity = '1';
1075
- }
1095
+ // Show controls
1096
+ showControls() {
1097
+ if (this.isControlsVisible) return;
1076
1098
 
1077
- if (this.caption) {
1078
- this.caption.style.transform = 'translateX(-50%) translateY(0)';
1079
- this.caption.style.opacity = '1';
1080
- }
1099
+ this.isControlsVisible = true;
1100
+ this.zoomora.classList.remove('controls-hidden');
1081
1101
 
1082
- if (this.thumbs) {
1083
- this.thumbs.style.transform = 'translateX(-50%) translateY(0)';
1084
- this.thumbs.style.opacity = '1';
1085
- }
1102
+ // Animate controls back in
1103
+ if (this.header) {
1104
+ this.header.style.transform = 'translateY(0)';
1105
+ this.header.style.opacity = '1';
1106
+ }
1086
1107
 
1087
- if (this.prevBtn) {
1088
- this.prevBtn.style.transform = 'translateX(0)';
1089
- this.prevBtn.style.opacity = '1';
1090
- }
1108
+ if (this.caption) {
1109
+ this.caption.style.transform = 'translateX(-50%) translateY(0)';
1110
+ this.caption.style.opacity = '1';
1111
+ }
1091
1112
 
1092
- if (this.nextBtn) {
1093
- this.nextBtn.style.transform = 'translateX(0)';
1094
- this.nextBtn.style.opacity = '1';
1095
- }
1113
+ if (this.thumbs) {
1114
+ this.thumbs.style.transform = 'translateX(-50%) translateY(0)';
1115
+ this.thumbs.style.opacity = '1';
1096
1116
  }
1097
1117
 
1098
- // Hide controls
1099
- hideControls() {
1100
- if (!this.isControlsVisible) return;
1118
+ if (this.prevBtn) {
1119
+ this.prevBtn.style.transform = 'translateX(0)';
1120
+ this.prevBtn.style.opacity = '1';
1121
+ }
1101
1122
 
1102
- this.isControlsVisible = false;
1103
- this.zoomora.classList.add('controls-hidden');
1123
+ if (this.nextBtn) {
1124
+ this.nextBtn.style.transform = 'translateX(0)';
1125
+ this.nextBtn.style.opacity = '1';
1126
+ }
1127
+ }
1104
1128
 
1105
- // Animate controls out
1106
- if (this.header) {
1107
- this.header.style.transform = 'translateY(-100%)';
1108
- this.header.style.opacity = '0';
1109
- }
1129
+ // Hide controls
1130
+ hideControls() {
1131
+ if (!this.isControlsVisible) return;
1110
1132
 
1111
- if (this.caption) {
1112
- this.caption.style.transform = 'translateX(-50%) translateY(100%)';
1113
- this.caption.style.opacity = '0';
1114
- }
1133
+ this.isControlsVisible = false;
1134
+ this.zoomora.classList.add('controls-hidden');
1115
1135
 
1116
- if (this.thumbs) {
1117
- this.thumbs.style.transform = 'translateX(-50%) translateY(100%)';
1118
- this.thumbs.style.opacity = '0';
1119
- }
1136
+ // Animate controls out
1137
+ if (this.header) {
1138
+ this.header.style.transform = 'translateY(-100%)';
1139
+ this.header.style.opacity = '0';
1140
+ }
1120
1141
 
1121
- if (this.prevBtn) {
1122
- this.prevBtn.style.transform = 'translateX(-100%)';
1123
- this.prevBtn.style.opacity = '0';
1124
- }
1142
+ if (this.caption) {
1143
+ this.caption.style.transform = 'translateX(-50%) translateY(100%)';
1144
+ this.caption.style.opacity = '0';
1145
+ }
1125
1146
 
1126
- if (this.nextBtn) {
1127
- this.nextBtn.style.transform = 'translateX(100%)';
1128
- this.nextBtn.style.opacity = '0';
1129
- }
1147
+ if (this.thumbs) {
1148
+ this.thumbs.style.transform = 'translateX(-50%) translateY(100%)';
1149
+ this.thumbs.style.opacity = '0';
1130
1150
  }
1131
1151
 
1132
- /**
1133
- * Check if image can be zoomed
1134
- * @param {HTMLImageElement} img - Image element
1135
- */
1136
- checkZoomability(img) {
1137
- if (!img || !img.naturalWidth || !img.naturalHeight) return;
1138
-
1139
- const imgRect = img.getBoundingClientRect();
1140
- const renderedWidth = imgRect.width;
1141
- const renderedHeight = imgRect.height;
1142
- const naturalWidth = img.naturalWidth;
1143
- const naturalHeight = img.naturalHeight;
1144
-
1145
- const isScaledDown = naturalWidth > renderedWidth || naturalHeight > renderedHeight;
1146
- const zoomRatioX = naturalWidth / renderedWidth;
1147
- const zoomRatioY = naturalHeight / renderedHeight;
1148
- const maxZoomRatio = Math.max(zoomRatioX, zoomRatioY);
1149
-
1150
- // Store zoom info
1151
- img.dataset.zoomRatio = maxZoomRatio.toString();
1152
- img.dataset.naturalWidth = naturalWidth.toString();
1153
- img.dataset.naturalHeight = naturalHeight.toString();
1154
- img.dataset.renderedWidth = renderedWidth.toString();
1155
- img.dataset.renderedHeight = renderedHeight.toString();
1156
-
1157
- // Enable zoom if significantly scaled down
1158
- if (isScaledDown && maxZoomRatio > 1.1) {
1159
- img.classList.remove('no-zoom');
1160
- } else {
1161
- img.classList.add('no-zoom');
1162
- }
1152
+ if (this.prevBtn) {
1153
+ this.prevBtn.style.transform = 'translateX(-100%)';
1154
+ this.prevBtn.style.opacity = '0';
1163
1155
  }
1164
1156
 
1165
- /**
1166
- * Calculate zoom levels for an image
1167
- * @param {HTMLImageElement} img - Image element
1168
- * @returns {Object} Zoom configuration
1169
- */
1170
- calculateZoomLevels(img) {
1171
- if (!img || !img.naturalWidth || !img.naturalHeight) {
1172
- return {canZoom: false, levels: [], currentLevel: 0};
1173
- }
1157
+ if (this.nextBtn) {
1158
+ this.nextBtn.style.transform = 'translateX(100%)';
1159
+ this.nextBtn.style.opacity = '0';
1160
+ }
1161
+ }
1174
1162
 
1175
- // Get CONTAINER size (not current displayed image size)
1176
- const container = this.content.getBoundingClientRect();
1177
- const containerWidth = container.width;
1178
- const containerHeight = container.height;
1163
+ /**
1164
+ * Check if image can be zoomed
1165
+ * @param {HTMLImageElement} img - Image element
1166
+ */
1167
+ checkZoomability(img) {
1168
+ if (!img || !img.naturalWidth || !img.naturalHeight) return;
1169
+
1170
+ const imgRect = img.getBoundingClientRect();
1171
+ const renderedWidth = imgRect.width;
1172
+ const renderedHeight = imgRect.height;
1173
+ const naturalWidth = img.naturalWidth;
1174
+ const naturalHeight = img.naturalHeight;
1175
+
1176
+ const isScaledDown = naturalWidth > renderedWidth || naturalHeight > renderedHeight;
1177
+ const zoomRatioX = naturalWidth / renderedWidth;
1178
+ const zoomRatioY = naturalHeight / renderedHeight;
1179
+ const maxZoomRatio = Math.max(zoomRatioX, zoomRatioY);
1180
+
1181
+ // Store zoom info
1182
+ img.dataset.zoomRatio = maxZoomRatio.toString();
1183
+ img.dataset.naturalWidth = naturalWidth.toString();
1184
+ img.dataset.naturalHeight = naturalHeight.toString();
1185
+ img.dataset.renderedWidth = renderedWidth.toString();
1186
+ img.dataset.renderedHeight = renderedHeight.toString();
1187
+
1188
+ // Enable zoom if significantly scaled down
1189
+ if (isScaledDown && maxZoomRatio > 1.1) {
1190
+ img.classList.remove('no-zoom');
1191
+ } else {
1192
+ img.classList.add('no-zoom');
1193
+ }
1194
+ }
1179
1195
 
1180
- const naturalWidth = img.naturalWidth;
1181
- const naturalHeight = img.naturalHeight;
1196
+ /**
1197
+ * Calculate zoom levels for an image
1198
+ * @param {HTMLImageElement} img - Image element
1199
+ * @returns {Object} Zoom configuration
1200
+ */
1201
+ calculateZoomLevels(img) {
1202
+ if (!img || !img.naturalWidth || !img.naturalHeight) {
1203
+ return {canZoom: false, levels: [], currentLevel: 0};
1204
+ }
1182
1205
 
1183
- // Calculate what the ORIGINAL fit-to-screen scale would be
1184
- const scaleToFitWidth = containerWidth / naturalWidth;
1185
- const scaleToFitHeight = containerHeight / naturalHeight;
1186
- const fitScale = Math.min(scaleToFitWidth, scaleToFitHeight);
1206
+ // Get CONTAINER size (not current displayed image size)
1207
+ const container = this.content.getBoundingClientRect();
1208
+ const containerWidth = container.width;
1209
+ const containerHeight = container.height;
1187
1210
 
1188
- // If image naturally fits or is smaller than container, can't zoom
1189
- if (fitScale >= 0.95) {
1190
- return {canZoom: false, levels: [], currentLevel: 0};
1191
- }
1211
+ const naturalWidth = img.naturalWidth;
1212
+ const naturalHeight = img.naturalHeight;
1192
1213
 
1193
- // Respect the configured ceiling (falls back to 3x only if misconfigured)
1194
- const configuredMax = Number(this.options.maxZoomScale);
1195
- const maxZoom = Number.isFinite(configuredMax) && configuredMax > 0
1196
- ? Math.max(1, configuredMax)
1197
- : 3;
1214
+ // Calculate what the ORIGINAL fit-to-screen scale would be
1215
+ const scaleToFitWidth = containerWidth / naturalWidth;
1216
+ const scaleToFitHeight = containerHeight / naturalHeight;
1217
+ const fitScale = Math.min(scaleToFitWidth, scaleToFitHeight);
1198
1218
 
1199
- // Calculate zoom levels based on FIT scale (not current scale)
1200
- const levels = [1]; // Start with fit-to-screen
1219
+ // If image naturally fits or is smaller than container, can't zoom
1220
+ if (fitScale >= 0.95) {
1221
+ return {canZoom: false, levels: [], currentLevel: 0};
1222
+ }
1201
1223
 
1202
- // Add progressive levels up to the configured max: 1.5x, 2x, 3x, 4x, 5x...
1203
- [1.5, 2, 3, 4, 5].forEach((candidate) => {
1204
- if (candidate <= maxZoom && fitScale * candidate <= 1) {
1205
- levels.push(candidate);
1206
- }
1207
- });
1224
+ // Respect the configured ceiling (falls back to 3x only if misconfigured)
1225
+ const configuredMax = Number(this.options.maxZoomScale);
1226
+ const maxZoom = Number.isFinite(configuredMax) && configuredMax > 0
1227
+ ? Math.max(1, configuredMax)
1228
+ : 3;
1208
1229
 
1209
- // Add actual size (100%) as final level, capped at the configured max
1210
- const actualSizeScale = Math.min(1 / fitScale, maxZoom);
1211
- if (actualSizeScale > levels[levels.length - 1]) {
1212
- levels.push(actualSizeScale);
1213
- }
1230
+ // Calculate zoom levels based on FIT scale (not current scale)
1231
+ const levels = [1]; // Start with fit-to-screen
1214
1232
 
1215
- return {
1216
- canZoom: levels.length > 1,
1217
- levels: levels,
1218
- currentLevel: 0,
1219
- baseScale: fitScale,
1220
- naturalWidth: naturalWidth,
1221
- naturalHeight: naturalHeight
1222
- };
1223
- }
1224
-
1225
- /**
1226
- * Update counter display
1227
- */
1228
- updateCounter() {
1229
- if (this.options.showCounter && this.counter) {
1230
- this.counter.textContent = `${this.currentIndex + 1} / ${this.items.length}`;
1233
+ // Add progressive levels up to the configured max: 1.5x, 2x, 3x, 4x, 5x...
1234
+ [1.5, 2, 3, 4, 5].forEach((candidate) => {
1235
+ if (candidate <= maxZoom && fitScale * candidate <= 1) {
1236
+ levels.push(candidate);
1231
1237
  }
1232
- }
1238
+ });
1233
1239
 
1234
- /**
1235
- * Update caption display
1236
- */
1237
- updateCaption() {
1238
- if (!this.caption || !this.items[this.currentIndex]) return;
1240
+ // Add actual size (100%) as final level, capped at the configured max
1241
+ const actualSizeScale = Math.min(1 / fitScale, maxZoom);
1242
+ if (actualSizeScale > levels[levels.length - 1]) {
1243
+ levels.push(actualSizeScale);
1244
+ }
1239
1245
 
1240
- const item = this.items[this.currentIndex];
1241
- const caption = item.dataset.caption || item.title || '';
1246
+ return {
1247
+ canZoom: levels.length > 1,
1248
+ levels: levels,
1249
+ currentLevel: 0,
1250
+ baseScale: fitScale,
1251
+ naturalWidth: naturalWidth,
1252
+ naturalHeight: naturalHeight
1253
+ };
1254
+ }
1242
1255
 
1243
- if (caption) {
1244
- this.caption.textContent = caption;
1245
- this.caption.style.transform = 'translateX(-50%) translateY(0)';
1246
- this.caption.style.opacity = '1';
1247
- this.caption.style.pointerEvents = 'auto';
1248
- this.caption.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
1249
- } else {
1250
- this.caption.style.transform = 'translateX(-50%) translateY(100%)';
1251
- this.caption.style.opacity = '0';
1252
- this.caption.style.pointerEvents = 'none';
1253
- this.caption.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
1254
- }
1256
+ /**
1257
+ * Update counter display
1258
+ */
1259
+ updateCounter() {
1260
+ if (this.options.showCounter && this.counter) {
1261
+ this.counter.textContent = `${this.currentIndex + 1} / ${this.items.length}`;
1255
1262
  }
1263
+ }
1256
1264
 
1257
- /**
1258
- * Update thumbnails display
1259
- */
1260
- updateThumbnails() {
1261
- if (!this.options.showThumbnails || !this.thumbs) return;
1265
+ /**
1266
+ * Update caption display
1267
+ */
1268
+ updateCaption() {
1269
+ if (!this.caption || !this.items[this.currentIndex]) return;
1270
+
1271
+ const item = this.items[this.currentIndex];
1272
+ const caption = item.dataset.caption || item.title || '';
1273
+
1274
+ if (caption) {
1275
+ this.caption.textContent = caption;
1276
+ this.caption.style.transform = 'translateX(-50%) translateY(0)';
1277
+ this.caption.style.opacity = '1';
1278
+ this.caption.style.pointerEvents = 'auto';
1279
+ this.caption.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
1280
+ } else {
1281
+ this.caption.style.transform = 'translateX(-50%) translateY(100%)';
1282
+ this.caption.style.opacity = '0';
1283
+ this.caption.style.pointerEvents = 'none';
1284
+ this.caption.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
1285
+ }
1286
+ }
1262
1287
 
1263
- if (this.items.length === 1) {
1264
- this.caption.classList.add('single-item-thumbnail');
1265
- this.thumbs.style.display = 'none';
1266
- return;
1267
- }
1288
+ /**
1289
+ * Update thumbnails display
1290
+ */
1291
+ updateThumbnails() {
1292
+ if (!this.options.showThumbnails || !this.thumbs) return;
1268
1293
 
1269
- this.caption.classList.remove('single-item-thumbnail');
1294
+ if (this.items.length === 1) {
1295
+ this.caption.classList.add('single-item-thumbnail');
1296
+ this.thumbs.style.display = 'none';
1297
+ return;
1298
+ }
1270
1299
 
1271
- this.thumbs.style.display = 'flex';
1272
- this.thumbs.innerHTML = '';
1300
+ this.caption.classList.remove('single-item-thumbnail');
1273
1301
 
1274
- this.items.forEach((item, index) => {
1275
- const thumb = document.createElement('div');
1276
- thumb.className = 'zoomora-thumb';
1277
- if (index === this.currentIndex) thumb.classList.add('active');
1302
+ this.thumbs.style.display = 'flex';
1303
+ this.thumbs.innerHTML = '';
1278
1304
 
1279
- const img = document.createElement('img');
1280
- const thumbSrc = this.getThumbnailSource(item);
1305
+ this.items.forEach((item, index) => {
1306
+ const thumb = document.createElement('div');
1307
+ thumb.className = 'zoomora-thumb';
1308
+ if (index === this.currentIndex) thumb.classList.add('active');
1281
1309
 
1282
- if (thumbSrc) {
1283
- img.src = thumbSrc;
1284
- img.alt = item.alt || item.title || '';
1285
- img.onerror = () => {
1286
- thumb.style.display = 'none';
1287
- };
1288
- }
1310
+ const img = document.createElement('img');
1311
+ const thumbSrc = this.getThumbnailSource(item);
1289
1312
 
1290
- thumb.appendChild(img);
1313
+ if (thumbSrc) {
1314
+ img.src = thumbSrc;
1315
+ img.alt = item.alt || item.title || '';
1316
+ img.onerror = () => {
1317
+ thumb.style.display = 'none';
1318
+ };
1319
+ }
1291
1320
 
1292
- thumb.addEventListener('click', () => {
1293
- this.currentIndex = index;
1294
- this.loadContent();
1295
- this.updateCounter();
1296
- this.updateThumbnails();
1297
- this.updateNavigation();
1298
- this.updateCaption();
1299
- });
1321
+ thumb.appendChild(img);
1300
1322
 
1301
- this.thumbs.appendChild(thumb);
1323
+ thumb.addEventListener('click', () => {
1324
+ this.currentIndex = index;
1325
+ this.loadContent();
1326
+ this.updateCounter();
1327
+ this.updateThumbnails();
1328
+ this.updateNavigation();
1329
+ this.updateCaption();
1302
1330
  });
1303
- }
1304
1331
 
1305
- /**
1306
- * Update navigation buttons
1307
- */
1308
- updateNavigation() {
1309
- const prevBtn = document.getElementById('zoomoraPrevBtn');
1310
- const nextBtn = document.getElementById('zoomoraNextBtn');
1332
+ this.thumbs.appendChild(thumb);
1333
+ });
1334
+ }
1311
1335
 
1312
- if (prevBtn) {
1313
- prevBtn.style.display = this.currentIndex > 0 ? 'flex' : 'none';
1314
- }
1315
- if (nextBtn) {
1316
- nextBtn.style.display = this.currentIndex < this.items.length - 1 ? 'flex' : 'none';
1317
- }
1336
+ /**
1337
+ * Update navigation buttons
1338
+ */
1339
+ updateNavigation() {
1340
+ const prevBtn = document.getElementById('zoomoraPrevBtn');
1341
+ const nextBtn = document.getElementById('zoomoraNextBtn');
1342
+
1343
+ if (prevBtn) {
1344
+ prevBtn.style.display = this.currentIndex > 0 ? 'flex' : 'none';
1345
+ }
1346
+ if (nextBtn) {
1347
+ nextBtn.style.display = this.currentIndex < this.items.length - 1 ? 'flex' : 'none';
1318
1348
  }
1349
+ }
1319
1350
 
1320
- /**
1321
- * Update control buttons
1322
- */
1323
- updateButtons() {
1324
- const fullscreenBtn = document.getElementById('zoomoraFullscreenBtn');
1325
- const collapseBtn = document.getElementById('zoomoraCollapseBtn');
1351
+ /**
1352
+ * Update control buttons
1353
+ */
1354
+ updateButtons() {
1355
+ const fullscreenBtn = document.getElementById('zoomoraFullscreenBtn');
1356
+ const collapseBtn = document.getElementById('zoomoraCollapseBtn');
1326
1357
 
1327
- if (fullscreenBtn && collapseBtn) {
1328
- if (this.isFullscreen) {
1329
- fullscreenBtn.style.display = 'none';
1330
- collapseBtn.style.display = 'flex';
1331
- } else {
1332
- fullscreenBtn.style.display = 'flex';
1333
- collapseBtn.style.display = 'none';
1334
- }
1358
+ if (fullscreenBtn && collapseBtn) {
1359
+ if (this.isFullscreen) {
1360
+ fullscreenBtn.style.display = 'none';
1361
+ collapseBtn.style.display = 'flex';
1362
+ } else {
1363
+ fullscreenBtn.style.display = 'flex';
1364
+ collapseBtn.style.display = 'none';
1335
1365
  }
1336
1366
  }
1367
+ }
1337
1368
 
1338
- /**
1339
- * Toggle zoom state
1340
- */
1341
- toggleZoom() {
1342
- const media = this.content.querySelector('.zoomora-media');
1343
- if (!media || media.tagName !== 'IMG') return;
1369
+ /**
1370
+ * Toggle zoom state
1371
+ */
1372
+ toggleZoom() {
1373
+ const media = this.content.querySelector('.zoomora-media');
1374
+ if (!media || media.tagName !== 'IMG') return;
1344
1375
 
1345
- // Calculate zoom levels
1346
- const zoomConfig = this.calculateZoomLevels(media);
1376
+ // Calculate zoom levels
1377
+ const zoomConfig = this.calculateZoomLevels(media);
1347
1378
 
1348
- if (!zoomConfig.canZoom) {
1349
- return;
1350
- }
1379
+ if (!zoomConfig.canZoom) {
1380
+ return;
1381
+ }
1351
1382
 
1352
- // Get or initialize current zoom level
1353
- if (!media.dataset.zoomLevel) {
1354
- media.dataset.zoomLevel = '0';
1355
- }
1383
+ // Get or initialize current zoom level
1384
+ if (!media.dataset.zoomLevel) {
1385
+ media.dataset.zoomLevel = '0';
1386
+ }
1356
1387
 
1357
- let currentLevel = parseInt(media.dataset.zoomLevel) || 0;
1388
+ let currentLevel = parseInt(media.dataset.zoomLevel) || 0;
1358
1389
 
1359
- // Cycle to next level
1360
- currentLevel++;
1361
- if (currentLevel >= zoomConfig.levels.length) {
1362
- // Reset to fit-to-screen
1363
- currentLevel = 0;
1364
- }
1390
+ // Cycle to next level
1391
+ currentLevel++;
1392
+ if (currentLevel >= zoomConfig.levels.length) {
1393
+ // Reset to fit-to-screen
1394
+ currentLevel = 0;
1395
+ }
1365
1396
 
1366
- const targetScale = zoomConfig.levels[currentLevel];
1367
- media.dataset.zoomLevel = currentLevel.toString();
1368
-
1369
- if (currentLevel === 0) {
1370
- // Back to fit-to-screen
1371
- this.isZoomed = false;
1372
- this.isDragging = false;
1373
- media.classList.remove('dragging');
1374
- media.style.transform = '';
1375
- media.style.cursor = 'pointer';
1376
- media.classList.remove('zoomed');
1377
- this.currentX = 0;
1378
- this.currentY = 0;
1379
- this.currentScale = 1;
1380
- } else {
1381
- // Zoom in - reset position to center
1382
- this.isZoomed = true;
1383
- this.currentScale = targetScale;
1384
- this.currentX = 0; // Always reset to center on zoom change
1385
- this.currentY = 0;
1397
+ const targetScale = zoomConfig.levels[currentLevel];
1398
+ media.dataset.zoomLevel = currentLevel.toString();
1386
1399
 
1387
- media.style.transform = `translate(0px, 0px) scale(${targetScale})`;
1388
- media.style.cursor = 'grab';
1389
- media.classList.add('zoomed');
1390
- }
1391
- }
1400
+ if (currentLevel === 0) {
1401
+ // Back to fit-to-screen
1402
+ this.isZoomed = false;
1403
+ this.isDragging = false;
1404
+ media.classList.remove('dragging');
1405
+ media.style.transform = '';
1406
+ media.style.cursor = 'pointer';
1407
+ media.classList.remove('zoomed');
1408
+ this.currentX = 0;
1409
+ this.currentY = 0;
1410
+ this.currentScale = 1;
1411
+ } else {
1412
+ // Zoom in - reset position to center
1413
+ this.isZoomed = true;
1414
+ this.currentScale = targetScale;
1415
+ this.currentX = 0; // Always reset to center on zoom change
1416
+ this.currentY = 0;
1392
1417
 
1393
- /**
1394
- * Toggle fullscreen mode
1395
- */
1396
- toggleFullscreen() {
1397
- if (!this.isFullscreen) {
1398
- this.enterFullscreen();
1399
- } else {
1400
- this.exitFullscreen();
1401
- }
1418
+ media.style.transform = `translate(0px, 0px) scale(${targetScale})`;
1419
+ media.style.cursor = 'grab';
1420
+ media.classList.add('zoomed');
1402
1421
  }
1422
+ }
1403
1423
 
1404
- /**
1405
- * Enter fullscreen mode
1406
- */
1407
- enterFullscreen() {
1408
- const element = this.zoomora;
1409
-
1410
- try {
1411
- // Try standard API first
1412
- if (element.requestFullscreen) {
1413
- element.requestFullscreen()
1414
- .then(() => {
1415
- this.isFullscreen = true;
1416
- this.zoomora.classList.add('fullscreen');
1417
- this.updateButtons();
1418
- })
1419
- .catch(err => {
1420
- console.warn('Zoomora: Fullscreen request failed:', err.message);
1421
- this.fallbackFullscreen();
1422
- });
1423
- }
1424
- // Try webkit (Safari, older Chrome)
1425
- else if (element.webkitRequestFullscreen) {
1426
- element.webkitRequestFullscreen();
1427
- // Webkit doesn't return a promise, so set state immediately
1428
- this.isFullscreen = true;
1429
- this.zoomora.classList.add('fullscreen');
1430
- this.updateButtons();
1431
- }
1432
- // Try moz (Firefox)
1433
- else if (element.mozRequestFullScreen) {
1434
- element.mozRequestFullScreen();
1435
- this.isFullscreen = true;
1436
- this.zoomora.classList.add('fullscreen');
1437
- this.updateButtons();
1438
- }
1439
- // Try ms (IE11)
1440
- else if (element.msRequestFullscreen) {
1441
- element.msRequestFullscreen();
1442
- this.isFullscreen = true;
1443
- this.zoomora.classList.add('fullscreen');
1444
- this.updateButtons();
1445
- }
1446
- // Fallback to CSS fullscreen
1447
- else {
1448
- console.warn('Zoomora: Fullscreen API not supported, using CSS fallback');
1449
- this.fallbackFullscreen();
1450
- }
1451
- } catch (error) {
1452
- console.error('Zoomora: Error entering fullscreen:', error);
1453
- this.fallbackFullscreen();
1454
- }
1424
+ /**
1425
+ * Toggle fullscreen mode
1426
+ */
1427
+ toggleFullscreen() {
1428
+ if (!this.isFullscreen) {
1429
+ this.enterFullscreen();
1430
+ } else {
1431
+ this.exitFullscreen();
1455
1432
  }
1433
+ }
1456
1434
 
1457
- /**
1458
- * Exit fullscreen mode
1459
- */
1460
- exitFullscreen() {
1461
- // First check if we're actually in fullscreen
1462
- const isInFullscreen = !!(
1463
- document.fullscreenElement ||
1464
- document.webkitFullscreenElement ||
1465
- document.mozFullScreenElement ||
1466
- document.msFullscreenElement
1467
- );
1468
-
1469
- if (!isInFullscreen) {
1470
- // Not in real fullscreen, just exit CSS fullscreen
1471
- this.fallbackExitFullscreen();
1472
- return;
1435
+ /**
1436
+ * Enter fullscreen mode
1437
+ */
1438
+ enterFullscreen() {
1439
+ const element = this.zoomora;
1440
+
1441
+ try {
1442
+ // Try standard API first
1443
+ if (element.requestFullscreen) {
1444
+ element.requestFullscreen()
1445
+ .then(() => {
1446
+ this.isFullscreen = true;
1447
+ this.zoomora.classList.add('fullscreen');
1448
+ this.updateButtons();
1449
+ })
1450
+ .catch(err => {
1451
+ console.warn('Zoomora: Fullscreen request failed:', err.message);
1452
+ this.fallbackFullscreen();
1453
+ });
1454
+ }
1455
+ // Try webkit (Safari, older Chrome)
1456
+ else if (element.webkitRequestFullscreen) {
1457
+ element.webkitRequestFullscreen();
1458
+ // Webkit doesn't return a promise, so set state immediately
1459
+ this.isFullscreen = true;
1460
+ this.zoomora.classList.add('fullscreen');
1461
+ this.updateButtons();
1473
1462
  }
1474
-
1475
- try {
1476
- // Try standard API first
1477
- if (document.exitFullscreen) {
1478
- document.exitFullscreen()
1479
- .then(() => {
1480
- this.isFullscreen = false;
1481
- this.zoomora.classList.remove('fullscreen');
1482
- this.updateButtons();
1483
- })
1484
- .catch(err => {
1485
- console.warn('Zoomora: Exit fullscreen failed:', err.message);
1486
- this.fallbackExitFullscreen();
1487
- });
1488
- }
1489
- // Try webkit
1490
- else if (document.webkitExitFullscreen) {
1491
- document.webkitExitFullscreen();
1492
- this.isFullscreen = false;
1493
- this.zoomora.classList.remove('fullscreen');
1494
- this.updateButtons();
1495
- }
1496
- // Try moz
1497
- else if (document.mozCancelFullScreen) {
1498
- document.mozCancelFullScreen();
1499
- this.isFullscreen = false;
1500
- this.zoomora.classList.remove('fullscreen');
1501
- this.updateButtons();
1502
- }
1503
- // Try ms
1504
- else if (document.msExitFullscreen) {
1505
- document.msExitFullscreen();
1506
- this.isFullscreen = false;
1507
- this.zoomora.classList.remove('fullscreen');
1508
- this.updateButtons();
1509
- }
1510
- // Fallback
1511
- else {
1512
- this.fallbackExitFullscreen();
1513
- }
1514
- } catch (error) {
1515
- console.error('Zoomora: Error exiting fullscreen:', error);
1516
- this.fallbackExitFullscreen();
1463
+ // Try moz (Firefox)
1464
+ else if (element.mozRequestFullScreen) {
1465
+ element.mozRequestFullScreen();
1466
+ this.isFullscreen = true;
1467
+ this.zoomora.classList.add('fullscreen');
1468
+ this.updateButtons();
1517
1469
  }
1470
+ // Try ms (IE11)
1471
+ else if (element.msRequestFullscreen) {
1472
+ element.msRequestFullscreen();
1473
+ this.isFullscreen = true;
1474
+ this.zoomora.classList.add('fullscreen');
1475
+ this.updateButtons();
1476
+ }
1477
+ // Fallback to CSS fullscreen
1478
+ else {
1479
+ console.warn('Zoomora: Fullscreen API not supported, using CSS fallback');
1480
+ this.fallbackFullscreen();
1481
+ }
1482
+ } catch (error) {
1483
+ console.error('Zoomora: Error entering fullscreen:', error);
1484
+ this.fallbackFullscreen();
1518
1485
  }
1486
+ }
1519
1487
 
1520
- /**
1521
- * Fallback fullscreen using CSS
1522
- */
1523
- fallbackFullscreen() {
1524
- this.isFullscreen = true;
1525
- this.zoomora.classList.add('fullscreen');
1526
- this.updateButtons();
1488
+ /**
1489
+ * Exit fullscreen mode
1490
+ */
1491
+ exitFullscreen() {
1492
+ // First check if we're actually in fullscreen
1493
+ const isInFullscreen = !!(
1494
+ document.fullscreenElement ||
1495
+ document.webkitFullscreenElement ||
1496
+ document.mozFullScreenElement ||
1497
+ document.msFullscreenElement
1498
+ );
1499
+
1500
+ if (!isInFullscreen) {
1501
+ // Not in real fullscreen, just exit CSS fullscreen
1502
+ this.fallbackExitFullscreen();
1503
+ return;
1527
1504
  }
1528
1505
 
1529
- /**
1530
- * Exit CSS fullscreen
1531
- */
1532
- fallbackExitFullscreen() {
1533
- this.isFullscreen = false;
1534
- this.zoomora.classList.remove('fullscreen');
1535
- this.updateButtons();
1506
+ try {
1507
+ // Try standard API first
1508
+ if (document.exitFullscreen) {
1509
+ document.exitFullscreen()
1510
+ .then(() => {
1511
+ this.isFullscreen = false;
1512
+ this.zoomora.classList.remove('fullscreen');
1513
+ this.updateButtons();
1514
+ })
1515
+ .catch(err => {
1516
+ console.warn('Zoomora: Exit fullscreen failed:', err.message);
1517
+ this.fallbackExitFullscreen();
1518
+ });
1519
+ }
1520
+ // Try webkit
1521
+ else if (document.webkitExitFullscreen) {
1522
+ document.webkitExitFullscreen();
1523
+ this.isFullscreen = false;
1524
+ this.zoomora.classList.remove('fullscreen');
1525
+ this.updateButtons();
1526
+ }
1527
+ // Try moz
1528
+ else if (document.mozCancelFullScreen) {
1529
+ document.mozCancelFullScreen();
1530
+ this.isFullscreen = false;
1531
+ this.zoomora.classList.remove('fullscreen');
1532
+ this.updateButtons();
1533
+ }
1534
+ // Try ms
1535
+ else if (document.msExitFullscreen) {
1536
+ document.msExitFullscreen();
1537
+ this.isFullscreen = false;
1538
+ this.zoomora.classList.remove('fullscreen');
1539
+ this.updateButtons();
1540
+ }
1541
+ // Fallback
1542
+ else {
1543
+ this.fallbackExitFullscreen();
1544
+ }
1545
+ } catch (error) {
1546
+ console.error('Zoomora: Error exiting fullscreen:', error);
1547
+ this.fallbackExitFullscreen();
1536
1548
  }
1549
+ }
1537
1550
 
1538
- /**
1539
- * Toggle thumbnails visibility
1540
- */
1541
- toggleThumbnails() {
1542
- if (this.items.length === 1) return;
1543
-
1544
- this.thumbnailsVisible = !this.thumbnailsVisible;
1545
- if (this.thumbs) {
1546
- this.thumbs.classList.toggle('thumbs-hidden', !this.thumbnailsVisible);
1547
-
1548
- if (!this.caption || !this.items[this.currentIndex]) return;
1551
+ /**
1552
+ * Fallback fullscreen using CSS
1553
+ */
1554
+ fallbackFullscreen() {
1555
+ this.isFullscreen = true;
1556
+ this.zoomora.classList.add('fullscreen');
1557
+ this.updateButtons();
1558
+ }
1549
1559
 
1550
- const item = this.items[this.currentIndex];
1551
- const caption = item.dataset.caption || item.title || '';
1560
+ /**
1561
+ * Exit CSS fullscreen
1562
+ */
1563
+ fallbackExitFullscreen() {
1564
+ this.isFullscreen = false;
1565
+ this.zoomora.classList.remove('fullscreen');
1566
+ this.updateButtons();
1567
+ }
1552
1568
 
1553
- if (caption && this.thumbs.classList.contains('thumbs-hidden')) {
1554
- this.caption.style.bottom = '20px';
1555
- this.caption.style.transition = 'bottom 0.3s ease, transform 0.3s ease';
1556
- } else {
1557
- this.caption.style.bottom = '120px';
1558
- }
1559
- }
1560
- }
1569
+ /**
1570
+ * Toggle thumbnails visibility
1571
+ */
1572
+ toggleThumbnails() {
1573
+ if (this.items.length === 1) return;
1561
1574
 
1562
- /**
1563
- * Start drag operation
1564
- * @param {Event} e - Mouse or touch event
1565
- */
1566
- startDrag(e) {
1567
- const media = this.content.querySelector('.zoomora-media');
1568
- if (!media || !this.isZoomed) return;
1575
+ this.thumbnailsVisible = !this.thumbnailsVisible;
1576
+ if (this.thumbs) {
1577
+ this.thumbs.classList.toggle('thumbs-hidden', !this.thumbnailsVisible);
1569
1578
 
1570
- this.isDragging = true;
1571
- media.classList.add('dragging');
1572
- media.style.cursor = 'grabbing';
1579
+ if (!this.caption || !this.items[this.currentIndex]) return;
1573
1580
 
1574
- this.startX = e.clientX || (e.touches && e.touches[0].clientX) || 0;
1575
- this.startY = e.clientY || (e.touches && e.touches[0].clientY) || 0;
1581
+ const item = this.items[this.currentIndex];
1582
+ const caption = item.dataset.caption || item.title || '';
1576
1583
 
1577
- // Read current position from transform
1578
- const transform = window.getComputedStyle(media).transform;
1579
- if (transform && transform !== 'none') {
1580
- const matrix = new DOMMatrix(transform);
1581
- this.currentX = matrix.m41;
1582
- this.currentY = matrix.m42;
1584
+ if (caption && this.thumbs.classList.contains('thumbs-hidden')) {
1585
+ this.caption.style.bottom = '20px';
1586
+ this.caption.style.transition = 'bottom 0.3s ease, transform 0.3s ease';
1583
1587
  } else {
1584
- this.currentX = 0;
1585
- this.currentY = 0;
1588
+ this.caption.style.bottom = '120px';
1586
1589
  }
1587
-
1588
- e.preventDefault();
1589
1590
  }
1591
+ }
1590
1592
 
1591
- /**
1592
- * Handle drag movement
1593
- * @param {Event} e - Mouse or touch event
1594
- */
1595
- drag(e) {
1596
- if (!this.isDragging) return;
1597
- e.preventDefault();
1593
+ /**
1594
+ * Start drag operation
1595
+ * @param {Event} e - Mouse or touch event
1596
+ */
1597
+ startDrag(e) {
1598
+ const media = this.content.querySelector('.zoomora-media');
1599
+ if (!media || !this.isZoomed) return;
1600
+
1601
+ this.isDragging = true;
1602
+ media.classList.add('dragging');
1603
+ media.style.cursor = 'grabbing';
1604
+
1605
+ this.startX = e.clientX || (e.touches && e.touches[0].clientX) || 0;
1606
+ this.startY = e.clientY || (e.touches && e.touches[0].clientY) || 0;
1607
+
1608
+ // Read current position from transform
1609
+ const transform = window.getComputedStyle(media).transform;
1610
+ if (transform && transform !== 'none') {
1611
+ const matrix = new DOMMatrix(transform);
1612
+ this.currentX = matrix.m41;
1613
+ this.currentY = matrix.m42;
1614
+ } else {
1615
+ this.currentX = 0;
1616
+ this.currentY = 0;
1617
+ }
1598
1618
 
1599
- const media = this.content.querySelector('.zoomora-media');
1600
- if (!media) return;
1619
+ e.preventDefault();
1620
+ }
1601
1621
 
1602
- const clientX = e.clientX || (e.touches && e.touches[0].clientX) || 0;
1603
- const clientY = e.clientY || (e.touches && e.touches[0].clientY) || 0;
1622
+ /**
1623
+ * Handle drag movement
1624
+ * @param {Event} e - Mouse or touch event
1625
+ */
1626
+ drag(e) {
1627
+ if (!this.isDragging) return;
1628
+ e.preventDefault();
1604
1629
 
1605
- const deltaX = clientX - this.startX;
1606
- const deltaY = clientY - this.startY;
1630
+ const media = this.content.querySelector('.zoomora-media');
1631
+ if (!media) return;
1607
1632
 
1608
- let newX = this.currentX + deltaX;
1609
- let newY = this.currentY + deltaY;
1633
+ const clientX = e.clientX || (e.touches && e.touches[0].clientX) || 0;
1634
+ const clientY = e.clientY || (e.touches && e.touches[0].clientY) || 0;
1610
1635
 
1611
- // Calculate boundaries for BOTH width AND height
1612
- const container = this.content.getBoundingClientRect();
1613
- const mediaRect = media.getBoundingClientRect();
1636
+ const deltaX = clientX - this.startX;
1637
+ const deltaY = clientY - this.startY;
1614
1638
 
1615
- // How much the scaled image overflows the container
1616
- const overflowX = Math.max(0, (mediaRect.width - container.width) / 2);
1617
- const overflowY = Math.max(0, (mediaRect.height - container.height) / 2);
1639
+ let newX = this.currentX + deltaX;
1640
+ let newY = this.currentY + deltaY;
1618
1641
 
1619
- // Constrain drag to overflow boundaries
1620
- if (overflowX > 0) {
1621
- newX = Math.max(-overflowX, Math.min(overflowX, newX));
1622
- } else {
1623
- newX = 0; // Center horizontally if no overflow
1624
- }
1642
+ // Calculate boundaries for BOTH width AND height
1643
+ const container = this.content.getBoundingClientRect();
1644
+ const mediaRect = media.getBoundingClientRect();
1625
1645
 
1626
- if (overflowY > 0) {
1627
- newY = Math.max(-overflowY, Math.min(overflowY, newY));
1628
- } else {
1629
- newY = 0; // Center vertically if no overflow
1630
- }
1646
+ // How much the scaled image overflows the container
1647
+ const overflowX = Math.max(0, (mediaRect.width - container.width) / 2);
1648
+ const overflowY = Math.max(0, (mediaRect.height - container.height) / 2);
1631
1649
 
1632
- // Apply transform
1633
- media.style.transform = `translate(${newX}px, ${newY}px) scale(${this.currentScale})`;
1650
+ // Constrain drag to overflow boundaries
1651
+ if (overflowX > 0) {
1652
+ newX = Math.max(-overflowX, Math.min(overflowX, newX));
1653
+ } else {
1654
+ newX = 0; // Center horizontally if no overflow
1655
+ }
1634
1656
 
1635
- // Update for next frame
1636
- this.startX = clientX;
1637
- this.startY = clientY;
1638
- this.currentX = newX;
1639
- this.currentY = newY;
1657
+ if (overflowY > 0) {
1658
+ newY = Math.max(-overflowY, Math.min(overflowY, newY));
1659
+ } else {
1660
+ newY = 0; // Center vertically if no overflow
1640
1661
  }
1641
1662
 
1642
- /**
1643
- * End drag operation
1644
- */
1645
- endDrag() {
1646
- if (!this.isDragging) return;
1663
+ // Apply transform
1664
+ media.style.transform = `translate(${newX}px, ${newY}px) scale(${this.currentScale})`;
1647
1665
 
1648
- this.isDragging = false;
1649
- this.lastDragTime = Date.now();
1666
+ // Update for next frame
1667
+ this.startX = clientX;
1668
+ this.startY = clientY;
1669
+ this.currentX = newX;
1670
+ this.currentY = newY;
1671
+ }
1650
1672
 
1651
- const media = this.content.querySelector('.zoomora-media');
1652
- if (media) {
1653
- media.classList.remove('dragging');
1654
- if (this.isZoomed) {
1655
- media.style.cursor = 'grab';
1656
- }
1657
- }
1658
- }
1673
+ /**
1674
+ * End drag operation
1675
+ */
1676
+ endDrag() {
1677
+ if (!this.isDragging) return;
1659
1678
 
1660
- /**
1661
- * Go to specific item by index
1662
- * @param {number} index - Item index
1663
- */
1664
- goTo(index) {
1665
- if (index < 0 || index >= this.items.length) return;
1679
+ this.isDragging = false;
1680
+ this.lastDragTime = Date.now();
1666
1681
 
1667
- this.currentIndex = index;
1668
- this.loadContent();
1669
- this.updateCounter();
1670
- this.updateThumbnails();
1671
- this.updateNavigation();
1672
- this.updateCaption();
1682
+ const media = this.content.querySelector('.zoomora-media');
1683
+ if (media) {
1684
+ media.classList.remove('dragging');
1685
+ if (this.isZoomed) {
1686
+ media.style.cursor = 'grab';
1687
+ }
1673
1688
  }
1689
+ }
1674
1690
 
1675
- /**
1676
- * Get current item index
1677
- * @returns {number} - Current index
1678
- */
1679
- getCurrentIndex() {
1680
- return this.currentIndex;
1681
- }
1691
+ /**
1692
+ * Go to specific item by index
1693
+ * @param {number} index - Item index
1694
+ */
1695
+ goTo(index) {
1696
+ if (index < 0 || index >= this.items.length) return;
1697
+
1698
+ this.currentIndex = index;
1699
+ this.loadContent();
1700
+ this.updateCounter();
1701
+ this.updateThumbnails();
1702
+ this.updateNavigation();
1703
+ this.updateCaption();
1704
+ }
1682
1705
 
1683
- /**
1684
- * Get total number of items
1685
- * @returns {number} - Total items
1686
- */
1687
- getTotalItems() {
1688
- return this.items.length;
1689
- }
1706
+ /**
1707
+ * Get current item index
1708
+ * @returns {number} - Current index
1709
+ */
1710
+ getCurrentIndex() {
1711
+ return this.currentIndex;
1712
+ }
1690
1713
 
1691
- /**
1692
- * Check if lightbox is open
1693
- * @returns {boolean} - True if open
1694
- */
1695
- isOpen() {
1696
- return this.zoomora && this.zoomora.classList.contains('active');
1697
- }
1714
+ /**
1715
+ * Get total number of items
1716
+ * @returns {number} - Total items
1717
+ */
1718
+ getTotalItems() {
1719
+ return this.items.length;
1720
+ }
1698
1721
 
1699
- /**
1700
- * Update options
1701
- * @param {Object} newOptions - New options to merge
1702
- */
1703
- updateOptions(newOptions) {
1704
- this.options = {...this.options, ...newOptions};
1705
- }
1722
+ /**
1723
+ * Check if lightbox is open
1724
+ * @returns {boolean} - True if open
1725
+ */
1726
+ isOpen() {
1727
+ return this.zoomora && this.zoomora.classList.contains('active');
1728
+ }
1706
1729
 
1707
- /**
1708
- * Refresh the lightbox (re-scan for items)
1709
- */
1710
- refresh() {
1711
- if (this.isOpen()) {
1712
- const currentItem = this.items[this.currentIndex];
1713
- if (currentItem) {
1714
- this.open(currentItem);
1715
- }
1730
+ /**
1731
+ * Update options
1732
+ * @param {Object} newOptions - New options to merge
1733
+ */
1734
+ updateOptions(newOptions) {
1735
+ this.options = {...this.options, ...newOptions};
1736
+ }
1737
+
1738
+ /**
1739
+ * Refresh the lightbox (re-scan for items)
1740
+ */
1741
+ refresh() {
1742
+ if (this.isOpen()) {
1743
+ const currentItem = this.items[this.currentIndex];
1744
+ if (currentItem) {
1745
+ this.open(currentItem);
1716
1746
  }
1717
1747
  }
1748
+ }
1718
1749
 
1719
- /**
1720
- * Destroy the lightbox instance
1721
- */
1722
- destroy() {
1723
- if (!this.isInitialized) return;
1750
+ /**
1751
+ * Destroy the lightbox instance
1752
+ */
1753
+ destroy() {
1754
+ if (!this.isInitialized) return;
1724
1755
 
1725
- // Close if open
1726
- if (this.isOpen()) {
1727
- this.close();
1728
- }
1756
+ // Close if open
1757
+ if (this.isOpen()) {
1758
+ this.close();
1759
+ }
1729
1760
 
1730
- // Clean up auto-hide
1731
- this.stopAutoHideTimer();
1761
+ // Clean up auto-hide
1762
+ this.stopAutoHideTimer();
1732
1763
 
1733
- // Remove all event listeners
1734
- this.eventListeners.forEach((listeners, _key) => {
1735
- listeners.forEach(({element, event, handler, options}) => {
1736
- if (element && element.removeEventListener) {
1737
- element.removeEventListener(event, handler, options);
1738
- }
1739
- });
1764
+ // Remove all event listeners
1765
+ this.eventListeners.forEach((listeners, _key) => {
1766
+ listeners.forEach(({element, event, handler, options}) => {
1767
+ if (element && element.removeEventListener) {
1768
+ element.removeEventListener(event, handler, options);
1769
+ }
1740
1770
  });
1741
- this.eventListeners.clear();
1771
+ });
1772
+ this.eventListeners.clear();
1742
1773
 
1743
- // Remove DOM elements
1744
- if (this.zoomora && this.zoomora.parentNode) {
1745
- this.zoomora.parentNode.removeChild(this.zoomora);
1746
- }
1747
-
1748
- // Reset properties
1749
- this.isInitialized = false;
1750
- this.items = [];
1751
- this.currentIndex = 0;
1752
- this.zoomora = null;
1753
- this.content = null;
1754
- this.counter = null;
1755
- this.thumbs = null;
1756
- this.loading = null;
1757
- this.caption = null;
1774
+ // Remove DOM elements
1775
+ if (this.zoomora && this.zoomora.parentNode) {
1776
+ this.zoomora.parentNode.removeChild(this.zoomora);
1758
1777
  }
1778
+
1779
+ // Reset properties
1780
+ this.isInitialized = false;
1781
+ this.items = [];
1782
+ this.currentIndex = 0;
1783
+ this.zoomora = null;
1784
+ this.content = null;
1785
+ this.counter = null;
1786
+ this.thumbs = null;
1787
+ this.loading = null;
1788
+ this.caption = null;
1759
1789
  }
1790
+ }
1760
1791
 
1761
- // Export the class
1762
- return Zoomora;
1763
- }));
1792
+ export default Zoomora;