neiki-gallery 1.0.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.
@@ -0,0 +1,955 @@
1
+ /*!
2
+ * Neiki Gallery v1.0.0
3
+ * A vanilla JavaScript image gallery / lightbox library.
4
+ * No dependencies. No frameworks.
5
+ *
6
+ * Usage:
7
+ * Auto-init: <div data-neiki-gallery>...</div>
8
+ * Manual: new NeikiGallery('#my-gallery', { ... });
9
+ *
10
+ * License: MIT
11
+ */
12
+ (function (root, factory) {
13
+ if (typeof define === 'function' && define.amd) {
14
+ define([], factory);
15
+ } else if (typeof module === 'object' && module.exports) {
16
+ module.exports = factory();
17
+ } else {
18
+ root.NeikiGallery = factory();
19
+ }
20
+ })(typeof self !== 'undefined' ? self : this, function () {
21
+ 'use strict';
22
+
23
+ /* ========================================================================
24
+ Helpers
25
+ ======================================================================== */
26
+
27
+ var uid = 0;
28
+
29
+ /**
30
+ * Generate a unique ID for each gallery instance.
31
+ */
32
+ function nextId() {
33
+ return ++uid;
34
+ }
35
+
36
+ /**
37
+ * Merge defaults with user options (shallow).
38
+ */
39
+ function mergeOptions(defaults, opts) {
40
+ var result = {};
41
+ for (var key in defaults) {
42
+ if (defaults.hasOwnProperty(key)) {
43
+ result[key] = defaults[key];
44
+ }
45
+ }
46
+ if (opts) {
47
+ for (var key in opts) {
48
+ if (opts.hasOwnProperty(key)) {
49
+ result[key] = opts[key];
50
+ }
51
+ }
52
+ }
53
+ return result;
54
+ }
55
+
56
+ /**
57
+ * Query helper.
58
+ */
59
+ function $(selector, ctx) {
60
+ return (ctx || document).querySelector(selector);
61
+ }
62
+
63
+ function $$(selector, ctx) {
64
+ return Array.prototype.slice.call((ctx || document).querySelectorAll(selector));
65
+ }
66
+
67
+ /**
68
+ * Create an element with optional class, attributes, and innerHTML.
69
+ */
70
+ function createElement(tag, className, attrs, html) {
71
+ var el = document.createElement(tag);
72
+ if (className) el.className = className;
73
+ if (attrs) {
74
+ for (var k in attrs) {
75
+ if (attrs.hasOwnProperty(k)) el.setAttribute(k, attrs[k]);
76
+ }
77
+ }
78
+ if (html) el.innerHTML = html;
79
+ return el;
80
+ }
81
+
82
+ /* ========================================================================
83
+ SVG Icons
84
+ ======================================================================== */
85
+
86
+ var ICONS = {
87
+ close: '<svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
88
+ prev: '<svg viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg>',
89
+ next: '<svg viewBox="0 0 24 24"><polyline points="9 6 15 12 9 18"/></svg>',
90
+ fullscreen: '<svg viewBox="0 0 24 24"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>',
91
+ exitFullscreen: '<svg viewBox="0 0 24 24"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>'
92
+ };
93
+
94
+ /* ========================================================================
95
+ Default Options
96
+ ======================================================================== */
97
+
98
+ var DEFAULTS = {
99
+ layout: 'masonry', // 'masonry' | 'grid'
100
+ loop: false, // infinite loop navigation
101
+ thumbnails: true, // show thumbnail strip in lightbox
102
+ zoom: true, // allow zoom on click in lightbox
103
+ fullscreen: true, // show fullscreen button
104
+ transition: 'fade', // 'fade' | 'slide'
105
+ theme: 'dark', // 'dark' | 'light'
106
+ hashNavigation: true, // deep linking via URL hash
107
+ counter: true, // show image counter
108
+ captions: true, // show captions
109
+ preload: 1, // how many adjacent images to preload
110
+ lazyLoad: true // lazy load grid thumbnails
111
+ };
112
+
113
+ /* ========================================================================
114
+ NeikiGallery Constructor
115
+ ======================================================================== */
116
+
117
+ function NeikiGallery(selectorOrElement, options) {
118
+ // Allow omitting `new`
119
+ if (!(this instanceof NeikiGallery)) {
120
+ return new NeikiGallery(selectorOrElement, options);
121
+ }
122
+
123
+ this._id = nextId();
124
+ this._events = {};
125
+ this._isOpen = false;
126
+ this._currentIndex = 0;
127
+ this._isZoomed = false;
128
+ this._isFullscreen = false;
129
+ this._destroyed = false;
130
+ this._boundHandlers = {};
131
+
132
+ // Resolve the container element
133
+ if (typeof selectorOrElement === 'string') {
134
+ this._container = $(selectorOrElement);
135
+ } else {
136
+ this._container = selectorOrElement;
137
+ }
138
+
139
+ if (!this._container) {
140
+ console.warn('NeikiGallery: Container not found for selector "' + selectorOrElement + '"');
141
+ return;
142
+ }
143
+
144
+ // Read data-* attributes from the container and merge with JS options
145
+ var dataOpts = this._readDataAttributes();
146
+ this._options = mergeOptions(DEFAULTS, mergeOptions(dataOpts, options));
147
+
148
+ // Parse items from DOM
149
+ this._items = this._parseItems();
150
+
151
+ if (this._items.length === 0) {
152
+ console.warn('NeikiGallery: No items found in container.');
153
+ return;
154
+ }
155
+
156
+ // Initialize
157
+ this._setupGrid();
158
+ this._setupLazyLoad();
159
+ this._buildLightbox();
160
+ this._bindEvents();
161
+ this._checkHash();
162
+ }
163
+
164
+ /* ========================================================================
165
+ Prototype — Private Methods
166
+ ======================================================================== */
167
+
168
+ NeikiGallery.prototype._readDataAttributes = function () {
169
+ var c = this._container;
170
+ var opts = {};
171
+ if (c.hasAttribute('data-layout')) opts.layout = c.getAttribute('data-layout');
172
+ if (c.hasAttribute('data-theme')) opts.theme = c.getAttribute('data-theme');
173
+ if (c.hasAttribute('data-loop')) opts.loop = c.getAttribute('data-loop') !== 'false';
174
+ if (c.hasAttribute('data-thumbnails')) opts.thumbnails = c.getAttribute('data-thumbnails') !== 'false';
175
+ if (c.hasAttribute('data-zoom')) opts.zoom = c.getAttribute('data-zoom') !== 'false';
176
+ if (c.hasAttribute('data-fullscreen')) opts.fullscreen = c.getAttribute('data-fullscreen') !== 'false';
177
+ if (c.hasAttribute('data-transition')) opts.transition = c.getAttribute('data-transition');
178
+ if (c.hasAttribute('data-hash-navigation')) opts.hashNavigation = c.getAttribute('data-hash-navigation') !== 'false';
179
+ return opts;
180
+ };
181
+
182
+ /**
183
+ * Parse <a><img></a> children into an items array.
184
+ */
185
+ NeikiGallery.prototype._parseItems = function () {
186
+ var anchors = $$(':scope > a', this._container);
187
+ var items = [];
188
+ for (var i = 0; i < anchors.length; i++) {
189
+ var a = anchors[i];
190
+ var img = $('img', a);
191
+ items.push({
192
+ src: a.getAttribute('href') || (img ? img.getAttribute('src') : ''),
193
+ thumb: img ? (img.getAttribute('data-src') || img.getAttribute('src')) : '',
194
+ caption: a.getAttribute('data-caption') || (img ? img.getAttribute('alt') : '') || '',
195
+ element: a,
196
+ img: img
197
+ });
198
+ // Prevent default link behaviour
199
+ a.addEventListener('click', function (e) { e.preventDefault(); });
200
+ }
201
+ return items;
202
+ };
203
+
204
+ /**
205
+ * Apply the grid layout class.
206
+ */
207
+ NeikiGallery.prototype._setupGrid = function () {
208
+ this._container.classList.add('neiki-gallery');
209
+ if (this._options.layout === 'grid') {
210
+ this._container.classList.add('neiki-gallery--grid');
211
+ this._container.classList.remove('neiki-gallery--masonry');
212
+ } else {
213
+ this._container.classList.add('neiki-gallery--masonry');
214
+ this._container.classList.remove('neiki-gallery--grid');
215
+ }
216
+ // Theme
217
+ if (this._options.theme) {
218
+ this._container.setAttribute('data-theme', this._options.theme);
219
+ }
220
+ };
221
+
222
+ /**
223
+ * Lazy load grid thumbnails via IntersectionObserver.
224
+ */
225
+ NeikiGallery.prototype._setupLazyLoad = function () {
226
+ var self = this;
227
+ if (!this._options.lazyLoad || !('IntersectionObserver' in window)) {
228
+ // Fallback: load all immediately
229
+ this._items.forEach(function (item) {
230
+ if (item.img) item.img.classList.add('neiki-loaded');
231
+ });
232
+ return;
233
+ }
234
+
235
+ this._observer = new IntersectionObserver(function (entries) {
236
+ entries.forEach(function (entry) {
237
+ if (entry.isIntersecting) {
238
+ var img = entry.target;
239
+ var lazySrc = img.getAttribute('data-src');
240
+ if (lazySrc) {
241
+ img.src = lazySrc;
242
+ img.removeAttribute('data-src');
243
+ }
244
+ img.addEventListener('load', function () {
245
+ img.classList.add('neiki-loaded');
246
+ });
247
+ // Already cached
248
+ if (img.complete && img.naturalWidth) {
249
+ img.classList.add('neiki-loaded');
250
+ }
251
+ self._observer.unobserve(img);
252
+ }
253
+ });
254
+ }, { rootMargin: '200px' });
255
+
256
+ this._items.forEach(function (item) {
257
+ if (item.img) {
258
+ // If img already has a real src and is loaded, mark it
259
+ if (item.img.complete && item.img.naturalWidth) {
260
+ item.img.classList.add('neiki-loaded');
261
+ } else {
262
+ self._observer.observe(item.img);
263
+ // Also listen for load in case it's not lazy
264
+ item.img.addEventListener('load', function () {
265
+ item.img.classList.add('neiki-loaded');
266
+ });
267
+ }
268
+ }
269
+ });
270
+ };
271
+
272
+ /* ========================================================================
273
+ Lightbox — Build DOM
274
+ ======================================================================== */
275
+
276
+ NeikiGallery.prototype._buildLightbox = function () {
277
+ var opts = this._options;
278
+
279
+ // Overlay
280
+ this._lightbox = createElement('div', 'neiki-lightbox neiki-lightbox--' + opts.transition, {
281
+ role: 'dialog',
282
+ 'aria-modal': 'true',
283
+ 'aria-label': 'Image lightbox',
284
+ tabindex: '-1'
285
+ });
286
+
287
+ // Apply theme
288
+ if (opts.theme) {
289
+ this._lightbox.setAttribute('data-theme', opts.theme);
290
+ }
291
+
292
+ // Top bar (counter)
293
+ if (opts.counter) {
294
+ this._topbar = createElement('div', 'neiki-lightbox__topbar');
295
+ this._counter = createElement('span', 'neiki-lightbox__counter', { 'aria-live': 'polite' });
296
+ this._topbar.appendChild(this._counter);
297
+ this._lightbox.appendChild(this._topbar);
298
+ }
299
+
300
+ // Close button
301
+ this._closeBtn = createElement('button', 'neiki-lightbox__btn neiki-lightbox__close', {
302
+ 'aria-label': 'Close lightbox',
303
+ type: 'button'
304
+ }, ICONS.close);
305
+ this._lightbox.appendChild(this._closeBtn);
306
+
307
+ // Fullscreen button
308
+ if (opts.fullscreen) {
309
+ this._fsBtn = createElement('button', 'neiki-lightbox__btn neiki-lightbox__fullscreen', {
310
+ 'aria-label': 'Toggle fullscreen',
311
+ type: 'button'
312
+ }, ICONS.fullscreen);
313
+ this._lightbox.appendChild(this._fsBtn);
314
+ }
315
+
316
+ // Prev / Next
317
+ this._prevBtn = createElement('button', 'neiki-lightbox__btn neiki-lightbox__prev', {
318
+ 'aria-label': 'Previous image',
319
+ type: 'button'
320
+ }, ICONS.prev);
321
+ this._nextBtn = createElement('button', 'neiki-lightbox__btn neiki-lightbox__next', {
322
+ 'aria-label': 'Next image',
323
+ type: 'button'
324
+ }, ICONS.next);
325
+
326
+ // Stage (image area)
327
+ this._stage = createElement('div', 'neiki-lightbox__stage');
328
+
329
+ // Slide wrapper (for slide transition)
330
+ this._slideWrapper = createElement('div', 'neiki-lightbox__slide-wrapper');
331
+
332
+ // Spinner
333
+ this._spinner = createElement('div', 'neiki-lightbox__spinner neiki-hidden');
334
+
335
+ // Image
336
+ this._image = createElement('img', 'neiki-lightbox__image', {
337
+ alt: '',
338
+ draggable: 'false'
339
+ });
340
+
341
+ this._slideWrapper.appendChild(this._spinner);
342
+ this._slideWrapper.appendChild(this._image);
343
+ this._stage.appendChild(this._prevBtn);
344
+ this._stage.appendChild(this._slideWrapper);
345
+ this._stage.appendChild(this._nextBtn);
346
+ this._lightbox.appendChild(this._stage);
347
+
348
+ // Caption
349
+ if (opts.captions) {
350
+ this._caption = createElement('div', 'neiki-lightbox__caption', { 'aria-live': 'polite' });
351
+ this._lightbox.appendChild(this._caption);
352
+ }
353
+
354
+ // Thumbnail strip
355
+ if (opts.thumbnails) {
356
+ this._thumbsContainer = createElement('div', 'neiki-lightbox__thumbs', {
357
+ role: 'listbox',
358
+ 'aria-label': 'Image thumbnails'
359
+ });
360
+ this._thumbButtons = [];
361
+ for (var i = 0; i < this._items.length; i++) {
362
+ var btn = createElement('button', 'neiki-lightbox__thumb', {
363
+ type: 'button',
364
+ role: 'option',
365
+ 'aria-label': 'View image ' + (i + 1)
366
+ });
367
+ var thumbImg = createElement('img', '', {
368
+ src: this._items[i].thumb,
369
+ alt: this._items[i].caption || 'Thumbnail ' + (i + 1),
370
+ draggable: 'false'
371
+ });
372
+ btn.appendChild(thumbImg);
373
+ this._thumbButtons.push(btn);
374
+ this._thumbsContainer.appendChild(btn);
375
+ }
376
+ this._lightbox.appendChild(this._thumbsContainer);
377
+ }
378
+
379
+ document.body.appendChild(this._lightbox);
380
+ };
381
+
382
+ /* ========================================================================
383
+ Lightbox — Bind Events
384
+ ======================================================================== */
385
+
386
+ NeikiGallery.prototype._bindEvents = function () {
387
+ var self = this;
388
+
389
+ // Grid item clicks
390
+ this._items.forEach(function (item, index) {
391
+ item.element.addEventListener('click', function (e) {
392
+ e.preventDefault();
393
+ self.open(index);
394
+ });
395
+ });
396
+
397
+ // Close button
398
+ this._closeBtn.addEventListener('click', function () { self.close(); });
399
+
400
+ // Nav buttons
401
+ this._prevBtn.addEventListener('click', function (e) { e.stopPropagation(); self.prev(); });
402
+ this._nextBtn.addEventListener('click', function (e) { e.stopPropagation(); self.next(); });
403
+
404
+ // Fullscreen
405
+ if (this._fsBtn) {
406
+ this._fsBtn.addEventListener('click', function (e) {
407
+ e.stopPropagation();
408
+ self._toggleFullscreen();
409
+ });
410
+ }
411
+
412
+ // Zoom on image click
413
+ if (this._options.zoom) {
414
+ this._image.addEventListener('click', function (e) {
415
+ e.stopPropagation();
416
+ self._toggleZoom();
417
+ });
418
+ }
419
+
420
+ // Thumbnail clicks
421
+ if (this._thumbButtons) {
422
+ this._thumbButtons.forEach(function (btn, idx) {
423
+ btn.addEventListener('click', function () { self._goTo(idx); });
424
+ });
425
+ }
426
+
427
+ // Click on stage background to close (but not on image / buttons)
428
+ this._stage.addEventListener('click', function (e) {
429
+ if (e.target === self._stage || e.target === self._slideWrapper) {
430
+ self.close();
431
+ }
432
+ });
433
+
434
+ // Keyboard
435
+ this._boundHandlers.keydown = function (e) { self._onKeyDown(e); };
436
+ document.addEventListener('keydown', this._boundHandlers.keydown);
437
+
438
+ // Touch / Swipe
439
+ this._setupTouch();
440
+
441
+ // Hash change
442
+ if (this._options.hashNavigation) {
443
+ this._boundHandlers.hashchange = function () { self._onHashChange(); };
444
+ window.addEventListener('hashchange', this._boundHandlers.hashchange);
445
+ }
446
+
447
+ // Fullscreen change
448
+ this._boundHandlers.fullscreenchange = function () { self._onFullscreenChange(); };
449
+ document.addEventListener('fullscreenchange', this._boundHandlers.fullscreenchange);
450
+ document.addEventListener('webkitfullscreenchange', this._boundHandlers.fullscreenchange);
451
+ };
452
+
453
+ /**
454
+ * Keyboard handler.
455
+ */
456
+ NeikiGallery.prototype._onKeyDown = function (e) {
457
+ if (!this._isOpen) return;
458
+
459
+ switch (e.key) {
460
+ case 'Escape':
461
+ e.preventDefault();
462
+ this.close();
463
+ break;
464
+ case 'ArrowLeft':
465
+ e.preventDefault();
466
+ this.prev();
467
+ break;
468
+ case 'ArrowRight':
469
+ e.preventDefault();
470
+ this.next();
471
+ break;
472
+ case 'Home':
473
+ e.preventDefault();
474
+ this._goTo(0);
475
+ break;
476
+ case 'End':
477
+ e.preventDefault();
478
+ this._goTo(this._items.length - 1);
479
+ break;
480
+ case 'f':
481
+ e.preventDefault();
482
+ this._toggleFullscreen();
483
+ break;
484
+ }
485
+ };
486
+
487
+ /**
488
+ * Touch swipe support (pointer events with fallback to touch events).
489
+ */
490
+ NeikiGallery.prototype._setupTouch = function () {
491
+ var self = this;
492
+ var startX = 0;
493
+ var startY = 0;
494
+ var distX = 0;
495
+ var tracking = false;
496
+ var threshold = 50;
497
+
498
+ function onStart(e) {
499
+ if (!self._isOpen || self._isZoomed) return;
500
+ var point = e.touches ? e.touches[0] : e;
501
+ startX = point.clientX;
502
+ startY = point.clientY;
503
+ distX = 0;
504
+ tracking = true;
505
+ }
506
+
507
+ function onMove(e) {
508
+ if (!tracking) return;
509
+ var point = e.touches ? e.touches[0] : e;
510
+ distX = point.clientX - startX;
511
+ var distY = point.clientY - startY;
512
+ // If horizontal swipe is dominant, prevent scroll
513
+ if (Math.abs(distX) > Math.abs(distY) && Math.abs(distX) > 10) {
514
+ e.preventDefault();
515
+ }
516
+ }
517
+
518
+ function onEnd() {
519
+ if (!tracking) return;
520
+ tracking = false;
521
+ if (distX > threshold) {
522
+ self.prev();
523
+ } else if (distX < -threshold) {
524
+ self.next();
525
+ }
526
+ }
527
+
528
+ this._stage.addEventListener('touchstart', onStart, { passive: true });
529
+ this._stage.addEventListener('touchmove', onMove, { passive: false });
530
+ this._stage.addEventListener('touchend', onEnd, { passive: true });
531
+
532
+ // Store refs for cleanup
533
+ this._boundHandlers.touchstart = onStart;
534
+ this._boundHandlers.touchmove = onMove;
535
+ this._boundHandlers.touchend = onEnd;
536
+ };
537
+
538
+ /* ========================================================================
539
+ Lightbox — Navigation Logic
540
+ ======================================================================== */
541
+
542
+ NeikiGallery.prototype._goTo = function (index, skipHash) {
543
+ if (this._destroyed) return;
544
+
545
+ var len = this._items.length;
546
+
547
+ // Handle loop / bounds
548
+ if (this._options.loop) {
549
+ index = ((index % len) + len) % len;
550
+ } else {
551
+ if (index < 0) index = 0;
552
+ if (index >= len) index = len - 1;
553
+ }
554
+
555
+ if (index === this._currentIndex && this._isOpen && this._image.src) {
556
+ // Already showing this image
557
+ return;
558
+ }
559
+
560
+ var prevIndex = this._currentIndex;
561
+ this._currentIndex = index;
562
+ var item = this._items[index];
563
+
564
+ // Reset zoom
565
+ if (this._isZoomed) this._toggleZoom();
566
+
567
+ // Update nav button visibility when not looping
568
+ this._updateNavButtons();
569
+
570
+ // Show spinner
571
+ this._spinner.classList.remove('neiki-hidden');
572
+
573
+ // Load image
574
+ var self = this;
575
+ var img = this._image;
576
+
577
+ // Transition class for animation direction (slide)
578
+ if (this._options.transition === 'slide') {
579
+ var direction = index > prevIndex ? 1 : -1;
580
+ // If looping from last to first or vice versa, determine visually correct direction
581
+ if (this._options.loop) {
582
+ if (prevIndex === len - 1 && index === 0) direction = 1;
583
+ if (prevIndex === 0 && index === len - 1) direction = -1;
584
+ }
585
+ this._slideWrapper.style.transform = 'translateX(' + (-direction * 40) + 'px)';
586
+ img.style.opacity = '0';
587
+ requestAnimationFrame(function () {
588
+ requestAnimationFrame(function () {
589
+ self._slideWrapper.style.transform = 'translateX(0)';
590
+ img.style.opacity = '1';
591
+ });
592
+ });
593
+ } else {
594
+ // Fade
595
+ img.classList.add('neiki-entering');
596
+ img.classList.remove('neiki-active');
597
+ }
598
+
599
+ // Set src
600
+ img.setAttribute('alt', item.caption || '');
601
+
602
+ // Preload into a temp image to catch load event correctly
603
+ var tempImg = new Image();
604
+ tempImg.onload = function () {
605
+ img.src = item.src;
606
+ self._spinner.classList.add('neiki-hidden');
607
+ if (self._options.transition === 'fade') {
608
+ // Small delay to trigger CSS transition
609
+ requestAnimationFrame(function () {
610
+ img.classList.remove('neiki-entering');
611
+ img.classList.add('neiki-active');
612
+ });
613
+ }
614
+ };
615
+ tempImg.onerror = function () {
616
+ img.src = item.src; // show broken image
617
+ self._spinner.classList.add('neiki-hidden');
618
+ if (self._options.transition === 'fade') {
619
+ requestAnimationFrame(function () {
620
+ img.classList.remove('neiki-entering');
621
+ img.classList.add('neiki-active');
622
+ });
623
+ }
624
+ };
625
+ tempImg.src = item.src;
626
+
627
+ // If already cached, fire immediately
628
+ if (tempImg.complete) {
629
+ img.src = item.src;
630
+ self._spinner.classList.add('neiki-hidden');
631
+ if (self._options.transition === 'fade') {
632
+ requestAnimationFrame(function () {
633
+ img.classList.remove('neiki-entering');
634
+ img.classList.add('neiki-active');
635
+ });
636
+ }
637
+ }
638
+
639
+ // Counter
640
+ if (this._counter) {
641
+ this._counter.textContent = (index + 1) + ' / ' + len;
642
+ }
643
+
644
+ // Caption
645
+ if (this._caption) {
646
+ this._caption.textContent = item.caption || '';
647
+ }
648
+
649
+ // Active thumbnail
650
+ if (this._thumbButtons) {
651
+ this._thumbButtons.forEach(function (btn, idx) {
652
+ btn.classList.toggle('neiki-thumb--active', idx === index);
653
+ btn.setAttribute('aria-selected', idx === index ? 'true' : 'false');
654
+ });
655
+ // Scroll active thumbnail into view
656
+ var activeThumb = this._thumbButtons[index];
657
+ if (activeThumb && this._thumbsContainer) {
658
+ activeThumb.scrollIntoView({ inline: 'center', block: 'nearest', behavior: 'smooth' });
659
+ }
660
+ }
661
+
662
+ // Hash
663
+ if (this._options.hashNavigation && !skipHash) {
664
+ this._setHash(index);
665
+ }
666
+
667
+ // Preload adjacent
668
+ this._preloadAdjacent(index);
669
+
670
+ // Fire event
671
+ this._emit('change', index);
672
+ };
673
+
674
+ NeikiGallery.prototype._updateNavButtons = function () {
675
+ if (this._options.loop) {
676
+ this._prevBtn.style.display = '';
677
+ this._nextBtn.style.display = '';
678
+ return;
679
+ }
680
+ this._prevBtn.style.display = this._currentIndex <= 0 ? 'none' : '';
681
+ this._nextBtn.style.display = this._currentIndex >= this._items.length - 1 ? 'none' : '';
682
+ };
683
+
684
+ /**
685
+ * Preload adjacent images.
686
+ */
687
+ NeikiGallery.prototype._preloadAdjacent = function (index) {
688
+ var count = this._options.preload || 1;
689
+ var len = this._items.length;
690
+ for (var i = 1; i <= count; i++) {
691
+ var nextIdx = (index + i) % len;
692
+ var prevIdx = ((index - i) % len + len) % len;
693
+ if (this._items[nextIdx]) new Image().src = this._items[nextIdx].src;
694
+ if (this._items[prevIdx]) new Image().src = this._items[prevIdx].src;
695
+ }
696
+ };
697
+
698
+ /* ========================================================================
699
+ Zoom
700
+ ======================================================================== */
701
+
702
+ NeikiGallery.prototype._toggleZoom = function () {
703
+ if (!this._options.zoom) return;
704
+ this._isZoomed = !this._isZoomed;
705
+ this._image.classList.toggle('neiki-zoomed', this._isZoomed);
706
+ };
707
+
708
+ /* ========================================================================
709
+ Fullscreen
710
+ ======================================================================== */
711
+
712
+ NeikiGallery.prototype._toggleFullscreen = function () {
713
+ if (!this._options.fullscreen) return;
714
+
715
+ if (!document.fullscreenElement && !document.webkitFullscreenElement) {
716
+ var el = this._lightbox;
717
+ if (el.requestFullscreen) {
718
+ el.requestFullscreen();
719
+ } else if (el.webkitRequestFullscreen) {
720
+ el.webkitRequestFullscreen();
721
+ }
722
+ } else {
723
+ if (document.exitFullscreen) {
724
+ document.exitFullscreen();
725
+ } else if (document.webkitExitFullscreen) {
726
+ document.webkitExitFullscreen();
727
+ }
728
+ }
729
+ };
730
+
731
+ NeikiGallery.prototype._onFullscreenChange = function () {
732
+ this._isFullscreen = !!(document.fullscreenElement || document.webkitFullscreenElement);
733
+ if (this._fsBtn) {
734
+ this._fsBtn.innerHTML = this._isFullscreen ? ICONS.exitFullscreen : ICONS.fullscreen;
735
+ }
736
+ };
737
+
738
+ /* ========================================================================
739
+ Hash Navigation
740
+ ======================================================================== */
741
+
742
+ NeikiGallery.prototype._setHash = function (index) {
743
+ if (typeof index === 'number' && this._isOpen) {
744
+ history.replaceState(null, '', '#neiki-' + this._id + '=' + (index + 1));
745
+ }
746
+ };
747
+
748
+ NeikiGallery.prototype._clearHash = function () {
749
+ if (window.location.hash.indexOf('#neiki-' + this._id + '=') === 0) {
750
+ history.replaceState(null, '', window.location.pathname + window.location.search);
751
+ }
752
+ };
753
+
754
+ NeikiGallery.prototype._checkHash = function () {
755
+ if (!this._options.hashNavigation) return;
756
+ var hash = window.location.hash;
757
+ var prefix = '#neiki-' + this._id + '=';
758
+ if (hash.indexOf(prefix) === 0) {
759
+ var idx = parseInt(hash.substring(prefix.length), 10);
760
+ if (!isNaN(idx) && idx >= 1 && idx <= this._items.length) {
761
+ this.open(idx - 1);
762
+ }
763
+ }
764
+ };
765
+
766
+ NeikiGallery.prototype._onHashChange = function () {
767
+ this._checkHash();
768
+ };
769
+
770
+ /* ========================================================================
771
+ Event Emitter
772
+ ======================================================================== */
773
+
774
+ NeikiGallery.prototype._emit = function (event, data) {
775
+ var listeners = this._events[event];
776
+ if (listeners) {
777
+ listeners.forEach(function (fn) { fn(data); });
778
+ }
779
+ };
780
+
781
+ /* ========================================================================
782
+ Public API
783
+ ======================================================================== */
784
+
785
+ /**
786
+ * Open the lightbox at a given index.
787
+ */
788
+ NeikiGallery.prototype.open = function (index) {
789
+ if (this._destroyed) return;
790
+ if (typeof index !== 'number') index = 0;
791
+ if (index < 0) index = 0;
792
+ if (index >= this._items.length) index = this._items.length - 1;
793
+
794
+ this._isOpen = true;
795
+
796
+ // Prevent body scroll
797
+ document.body.style.overflow = 'hidden';
798
+
799
+ // Show overlay
800
+ this._lightbox.classList.add('neiki-lightbox--visible');
801
+
802
+ // Focus the lightbox for keyboard events
803
+ this._lightbox.focus();
804
+
805
+ // Force a fresh load (reset current to -1 so _goTo actually proceeds)
806
+ this._currentIndex = -1;
807
+ this._goTo(index);
808
+
809
+ this._emit('open', index);
810
+ };
811
+
812
+ /**
813
+ * Close the lightbox.
814
+ */
815
+ NeikiGallery.prototype.close = function () {
816
+ if (!this._isOpen) return;
817
+ this._isOpen = false;
818
+
819
+ // Exit fullscreen if active
820
+ if (this._isFullscreen) {
821
+ if (document.exitFullscreen) document.exitFullscreen();
822
+ else if (document.webkitExitFullscreen) document.webkitExitFullscreen();
823
+ }
824
+
825
+ // Reset zoom
826
+ if (this._isZoomed) {
827
+ this._isZoomed = false;
828
+ this._image.classList.remove('neiki-zoomed');
829
+ }
830
+
831
+ // Restore body scroll
832
+ document.body.style.overflow = '';
833
+
834
+ // Hide overlay
835
+ this._lightbox.classList.remove('neiki-lightbox--visible');
836
+
837
+ // Clear hash
838
+ if (this._options.hashNavigation) {
839
+ this._clearHash();
840
+ }
841
+
842
+ this._emit('close');
843
+ };
844
+
845
+ /**
846
+ * Go to the next image.
847
+ */
848
+ NeikiGallery.prototype.next = function () {
849
+ this._goTo(this._currentIndex + 1);
850
+ };
851
+
852
+ /**
853
+ * Go to the previous image.
854
+ */
855
+ NeikiGallery.prototype.prev = function () {
856
+ this._goTo(this._currentIndex - 1);
857
+ };
858
+
859
+ /**
860
+ * Register an event listener.
861
+ */
862
+ NeikiGallery.prototype.on = function (event, callback) {
863
+ if (!this._events[event]) this._events[event] = [];
864
+ this._events[event].push(callback);
865
+ return this;
866
+ };
867
+
868
+ /**
869
+ * Remove an event listener.
870
+ */
871
+ NeikiGallery.prototype.off = function (event, callback) {
872
+ var listeners = this._events[event];
873
+ if (listeners) {
874
+ this._events[event] = listeners.filter(function (fn) { return fn !== callback; });
875
+ }
876
+ return this;
877
+ };
878
+
879
+ /**
880
+ * Destroy the gallery instance — remove all DOM, listeners, observers.
881
+ */
882
+ NeikiGallery.prototype.destroy = function () {
883
+ if (this._destroyed) return;
884
+ this._destroyed = true;
885
+
886
+ // Close if open
887
+ if (this._isOpen) this.close();
888
+
889
+ // Remove keyboard listener
890
+ if (this._boundHandlers.keydown) {
891
+ document.removeEventListener('keydown', this._boundHandlers.keydown);
892
+ }
893
+
894
+ // Remove hashchange listener
895
+ if (this._boundHandlers.hashchange) {
896
+ window.removeEventListener('hashchange', this._boundHandlers.hashchange);
897
+ }
898
+
899
+ // Remove fullscreen listener
900
+ if (this._boundHandlers.fullscreenchange) {
901
+ document.removeEventListener('fullscreenchange', this._boundHandlers.fullscreenchange);
902
+ document.removeEventListener('webkitfullscreenchange', this._boundHandlers.fullscreenchange);
903
+ }
904
+
905
+ // Remove touch listeners
906
+ if (this._boundHandlers.touchstart) {
907
+ this._stage.removeEventListener('touchstart', this._boundHandlers.touchstart);
908
+ this._stage.removeEventListener('touchmove', this._boundHandlers.touchmove);
909
+ this._stage.removeEventListener('touchend', this._boundHandlers.touchend);
910
+ }
911
+
912
+ // Disconnect intersection observer
913
+ if (this._observer) {
914
+ this._observer.disconnect();
915
+ this._observer = null;
916
+ }
917
+
918
+ // Remove lightbox DOM
919
+ if (this._lightbox && this._lightbox.parentNode) {
920
+ this._lightbox.parentNode.removeChild(this._lightbox);
921
+ }
922
+
923
+ // Remove grid classes
924
+ this._container.classList.remove('neiki-gallery', 'neiki-gallery--masonry', 'neiki-gallery--grid');
925
+
926
+ // Clear event listeners
927
+ this._events = {};
928
+ };
929
+
930
+ /* ========================================================================
931
+ Auto-Init
932
+ ======================================================================== */
933
+
934
+ function autoInit() {
935
+ var galleries = $$('[data-neiki-gallery]');
936
+ galleries.forEach(function (el) {
937
+ // Don't double-init
938
+ if (el._neikiGallery) return;
939
+ el._neikiGallery = new NeikiGallery(el);
940
+ });
941
+ }
942
+
943
+ // Auto-init when DOM is ready
944
+ if (document.readyState === 'loading') {
945
+ document.addEventListener('DOMContentLoaded', autoInit);
946
+ } else {
947
+ // DOM already loaded (script at bottom or defer)
948
+ autoInit();
949
+ }
950
+
951
+ // Expose auto-init for dynamic content
952
+ NeikiGallery.autoInit = autoInit;
953
+
954
+ return NeikiGallery;
955
+ });