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