PageSourceSearch

https://tagankateatr.ru/_inc/lightbox.js?5776

js tagankateatr.ru collected 2026-10-02 09:59:10 UTC 41,042 bytes, 1,055 lines download raw bytes

1/* ============================================================
2   LIGHTBOX.JS — Полноэкранный просмотрщик с FLIP-анимацией
3
4   Использование:
5     const lightbox = new Lightbox({
6       trigger:    '.show__gallery-photo',  // селектор кликабельных фото
7       container:  document,                // где искать триггеры
8       title:      'Превращение',           // общий заголовок
9       imageAttr:  'data-src',              // путь к full-res
10       captionAttr:'data-caption',          // подпись (кто на фото)
11       authorAttr: 'data-author',           // автор фото
12     });
13
14   На каждом элементе-триггере:
15     <div class="show__gallery-photo"
16          data-src="path/to/full.jpg"
17          data-caption="Анна Иванова в роли Дамы"
18          data-author="Фото: Иван Петров">
19       <img src="path/to/thumb.jpg">
20     </div>
21
22   Триггер сам должен содержать <img> внутри — именно с него JS
23   снимет геометрию для FLIP-анимации.
24
25   Зависимости: window.TagankaPage.lockScroll/unlockScroll.
26
27   Управление:
28     — клик по триггеру → открывает лайтбокс на этом фото
29     — клик по крестику / Esc / клик по backdrop → закрывает
30     — стрелки ◀ ▶ / клавиши ←/→ → перелистывание
31     — горизонтальный свайп → перелистывание
32   ============================================================ */
33
34(function () {
35  'use strict';
36
37  const FLIP_DURATION = 350;           // мс
38  const FLIP_EASING   = 'cubic-bezier(0.25, 1, 0.5, 1)';
39  const SWIPE_THRESHOLD = 50;          // px
40  const OPEN_PRELOAD_TIMEOUT = 1200;   // мс: ждём full-фото перед FLIP, потом fallback на thumb
41  const SWITCH_PRELOAD_TIMEOUT = 1200; // мс: при стрелках не показываем lazy/blur-плейсхолдер
42
43  /* ── SVG иконки (inline, чтобы не зависеть от ассетов) ── */
44  const ICON_CLOSE = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">' +
45    '<line x1="2" y1="2" x2="22" y2="22" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>' +
46    '<line x1="22" y1="2" x2="2" y2="22" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>' +
47  '</svg>';
48
49  const ICON_PREV = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">' +
50    '<line x1="14" y1="8" x2="2" y2="8"></line>' +
51    '<polyline points="7 3 2 8 7 13"></polyline>' +
52  '</svg>';
53
54  const ICON_NEXT = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">' +
55    '<line x1="2" y1="8" x2="14" y2="8"></line>' +
56    '<polyline points="9 3 14 8 9 13"></polyline>' +
57  '</svg>';
58
59
60  class Lightbox {
61    constructor(opts) {
62      this._title       = opts.title || '';
63      this._imageAttr   = opts.imageAttr   || 'data-src';
64      this._captionAttr = opts.captionAttr || 'data-caption';
65      this._authorAttr  = opts.authorAttr  || 'data-author';
66      this._videoAttr   = opts.videoAttr   || 'data-video';
67
68      const root = opts.container || document;
69      this._triggers = Array.prototype.slice.call(
70        root.querySelectorAll(opts.trigger)
71      );
72
73      if (!this._triggers.length) return;
74
75      this._currentIndex = 0;
76      this._isOpen = false;
77      this._isAnimating = false;
78      this._flybackEl = null;
79      this._closeTimer = null;
80      this._switchTimer = null;
81      this._openToken = 0;
82      this._switchToken = 0;
83      this._preloadCache = Object.create(null);
84
85      this._buildDOM();
86      this._bindTriggers();
87      this._bindKeyboard();
88      this._warmupFullImages();
89    }
90
91    /* ── 1. Построение overlay-DOM ── */
92    _buildDOM() {
93      const el = document.createElement('div');
94      el.className = 'lightbox';
95      el.setAttribute('hidden', '');
96      el.setAttribute('role', 'dialog');
97      el.setAttribute('aria-modal', 'true');
98      el.setAttribute('aria-label', 'Просмотр фотографии');
99
100      el.innerHTML = `
101        <div class="lightbox__backdrop"></div>
102        <div class="lightbox__stage">
103          <img class="lightbox__image" alt="">
104        </div>
105        <button type="button" class="lightbox__close" aria-label="Закрыть">${ICON_CLOSE}</button>
106        <button type="button" class="lightbox__nav lightbox__nav--prev" aria-label="Предыдущее фото">${ICON_PREV}</button>
107        <button type="button" class="lightbox__nav lightbox__nav--next" aria-label="Следующее фото">${ICON_NEXT}</button>
108        <div class="lightbox__caption">
109          <p class="lightbox__counter"></p>
110          <div class="lightbox__title-block">
111            <p class="lightbox__title"></p>
112            <p class="lightbox__info"></p>
113          </div>
114        </div>
115      `;
116
117      document.body.appendChild(el);
118
119      this._el        = el;
120      this._backdrop  = el.querySelector('.lightbox__backdrop');
121      this._stage     = el.querySelector('.lightbox__stage');
122      this._image     = el.querySelector('.lightbox__image');
123      this._closeBtn  = el.querySelector('.lightbox__close');
124      this._prevBtn   = el.querySelector('.lightbox__nav--prev');
125      this._nextBtn   = el.querySelector('.lightbox__nav--next');
126      this._counterEl = el.querySelector('.lightbox__counter');
127      this._titleEl   = el.querySelector('.lightbox__title');
128      this._infoEl    = el.querySelector('.lightbox__info');
129
130      this._closeBtn.addEventListener('click', () => this.close());
131      this._backdrop.addEventListener('click', () => this.close());
132      this._prevBtn.addEventListener('click', () => this.prev());
133      this._nextBtn.addEventListener('click', () => this.next());
134
135      /* iOS Safari: подавляем bubbling touch'а до корня лайтбокса
136         (где висит swipe-обработчик), чтобы тап по кнопке не считался
137         началом свайпа. Backdrop НЕ включаем — на нём свайп должен ловиться. */
138      [this._closeBtn, this._prevBtn, this._nextBtn].forEach((node) => {
139        node.addEventListener('touchstart', (e) => e.stopPropagation(), { passive: true });
140        node.addEventListener('touchend',   (e) => e.stopPropagation(), { passive: true });
141      });
142
143      this._bindSwipe();
144    }
145
146    /* ── 2. Клик по миниатюрам ── */
147    _bindTriggers() {
148      this._triggers.forEach((t, idx) => {
149        t.style.cursor = 'pointer';
150        if (!t.hasAttribute('tabindex')) t.setAttribute('tabindex', '0');
151        if (!t.hasAttribute('role')) t.setAttribute('role', 'button');
152        if (!t.hasAttribute('aria-label')) {
153          t.setAttribute('aria-label', 'Открыть фото на весь экран');
154        }
155
156        t.addEventListener('click', (e) => {
157          e.preventDefault();
158          this.open(idx);
159        });
160
161        t.addEventListener('keydown', (e) => {
162          if (e.key === 'Enter' || e.key === ' ') {
163            e.preventDefault();
164            this.open(idx);
165          }
166        });
167      });
168    }
169
170    /* ── 3. Клавиатура ── */
171    _bindKeyboard() {
172      this._onKey = (e) => {
173        if (!this._isOpen) return;
174        if (e.key === 'Escape')      { e.preventDefault(); this.close(); }
175        else if (e.key === 'ArrowLeft')  { e.preventDefault(); this.prev(); }
176        else if (e.key === 'ArrowRight') { e.preventDefault(); this.next(); }
177      };
178      document.addEventListener('keydown', this._onKey);
179    }
180
181    /* ── 4. Свайп ── */
182    _bindSwipe() {
183      let startX = null;
184      let startY = null;
185
186      /* Swipe ловим на корневом .lightbox — он получает все touch.
187         .lightbox__stage с pointer-events: none не получает события. */
188      this._el.addEventListener('touchstart', (e) => {
189        if (e.touches.length !== 1) return;
190        startX = e.touches[0].clientX;
191        startY = e.touches[0].clientY;
192      }, { passive: true });
193
194      this._el.addEventListener('touchend', (e) => {
195        if (startX === null) return;
196        const dx = e.changedTouches[0].clientX - startX;
197        const dy = e.changedTouches[0].clientY - startY;
198        startX = null;
199        startY = null;
200
201        /* Игнорируем вертикальные жесты (скролл),
202           реагируем только на горизонтальные. */
203        if (Math.abs(dx) < SWIPE_THRESHOLD) return;
204        if (Math.abs(dy) > Math.abs(dx)) return;
205
206        if (dx > 0) this.prev();
207        else        this.next();
208      });
209    }
210
211    /* ── 5. Открытие с FLIP ── */
212    async open(index) {
213      if (this._isAnimating || this._isOpen) return;
214
215      const trigger = this._triggers[index];
216      if (!trigger) return;
217
218      const openToken = ++this._openToken;
219      this._switchToken++;
220      this._currentIndex = index;
221      this._isAnimating = true;
222
223      this._el.classList.remove('is-open', 'is-settled', 'is-loading-full', 'is-closing');
224      if (this._closeTimer) {
225        clearTimeout(this._closeTimer);
226        this._closeTimer = null;
227      }
228      if (this._flybackEl) {
229        this._flybackEl.remove();
230        this._flybackEl = null;
231      }
232
233      this._image.style.transition = 'none';
234      this._image.style.opacity = '1';
235      this._image.style.visibility = '';
236      this._image.style.filter = '';
237
238      const isVideo = this._isVideoTrigger(trigger);
239      const triggerMedia = isVideo
240        ? trigger.querySelector('video')
241        : trigger.querySelector('img');
242      const fromRect = (triggerMedia || trigger).getBoundingClientRect();
243
244      /* До старта FLIP готовим именно full-файл, если он доступен.
245         Раньше лайтбокс стартовал thumb-картинкой и считал финальный
246         размер по thumb 4:3. Если full имел другую пропорцию или ещё
247         грузился, внутри появлялся blur/lazy-фон и визуальный сдвиг
248         по высоте. Теперь target считается по реальному full natural
249         size; thumb — только аварийный fallback без blur. */
250      const media = await this._resolveOpenMedia(trigger, triggerMedia);
251      if (openToken !== this._openToken) {
252        this._isAnimating = false;
253        return;
254      }
255
256      this._isOpen = true;
257
258      /* Скрываем исходник только когда уже готовы открыть overlay.
259         Так при медленном full-load пользователь не видит пустую дырку
260         в галерее до появления лайтбокса. */
261      trigger.classList.add('lightbox-source--active');
262      this._pauseTileVideo(trigger);
263
264      this._el.removeAttribute('hidden');
265      this._image.alt = media.alt || '';
266
267      if (window.TagankaPage && window.TagankaPage.lockScroll) {
268        window.TagankaPage.lockScroll();
269      }
270
271      /* iOS Safari после lockScroll может менять фактическую высоту
272         fixed-overlay не в тот же layout tick. Два кадра дают stage
273         стабильный getBoundingClientRect(), поэтому фото не “плавает”
274         выше/ниже между открытиями. */
275      await this._nextFrame();
276      await this._nextFrame();
277      if (openToken !== this._openToken || !this._isOpen) {
278        this._isAnimating = false;
279        return;
280      }
281
282      void this._el.offsetWidth;
283
284      const target = this._calcTargetRect(media.measureMedia || triggerMedia);
285
286      this._image.style.transition = 'none';
287      this._image.style.width  = fromRect.width  + 'px';
288      this._image.style.height = fromRect.height + 'px';
289      this._image.style.left   = fromRect.left   + 'px';
290      this._image.style.top    = fromRect.top    + 'px';
291      this._image.style.opacity = '1';
292      if (media.displaySrc) {
293        this._image.src = media.displaySrc;
294      } else {
295        this._image.removeAttribute('src');
296      }
297      this._el.classList.remove('is-loading-full');
298
299      this._maybeSwapFallbackToFull(media, index, openToken);
300
301      /* Forced reflow — гарантирует что браузер закоммитит начальное состояние
302         до применения transition. На iOS Safari двойной rAF не всегда срабатывает. */
303      void this._image.offsetWidth;
304
305      this._el.classList.add('is-open');
306
307      this._image.style.transition =
308        'width '  + FLIP_DURATION + 'ms ' + FLIP_EASING + ', ' +
309        'height ' + FLIP_DURATION + 'ms ' + FLIP_EASING + ', ' +
310        'left '   + FLIP_DURATION + 'ms ' + FLIP_EASING + ', ' +
311        'top '    + FLIP_DURATION + 'ms ' + FLIP_EASING;
312      this._image.style.width  = target.w + 'px';
313      this._image.style.height = target.h + 'px';
314      this._image.style.left   = target.x + 'px';
315      this._image.style.top    = target.y + 'px';
316
317      const fallbackTimeout = setTimeout(() => {
318        this._image.removeEventListener('transitionend', onEnd);
319        this._finishOpen();
320      }, FLIP_DURATION + 140);
321
322      const onEnd = (e) => {
323        if (e.propertyName !== 'left') return;
324        this._image.removeEventListener('transitionend', onEnd);
325        clearTimeout(fallbackTimeout);
326        this._finishOpen();
327      };
328      this._image.addEventListener('transitionend', onEnd);
329
330      this._renderCaption();
331    }
332
333    _finishOpen() {
334      if (!this._isOpen) return;
335
336      /* Снимаем только transition, ОСТАВЛЯЕМ inline width/height/left/top.
337         Это значит картинка остаётся в position: absolute с теми же
338         размерами что после FLIP. Без прыжка в natural-размер картинки. */
339      this._image.style.transition = '';
340      this._el.classList.add('is-settled');
341      this._isAnimating = false;
342
343      if (this._isVideoTrigger(this._triggers[this._currentIndex])) {
344        this._mountVideo();
345      }
346    }
347
348    /* Целевые координаты для open()/switch(): максимальный
349       прямоугольник внутри безопасной зоны по натуральной пропорции full-медиа. */
350    _calcTargetRect(srcMedia) {
351      /* Не используем visualViewport.height и не полагаемся на stageRect.height
352         как основной источник: в Safari они могут меняться при схлопывании
353         адресной строки/после lockScroll. documentElement.clientHeight даёт
354         более стабильную высоту для fixed overlay, поэтому фото больше не
355         “гуляет” вверх-вниз между открытиями. */
356      const stageRect = this._stage
357        ? this._stage.getBoundingClientRect()
358        : { left: 0, top: 0, width: 0, height: 0 };
359
360      const vw = document.documentElement.clientWidth || stageRect.width || window.innerWidth || 1;
361      const vh = document.documentElement.clientHeight || stageRect.height || window.innerHeight || 1;
362
363      const isMobile = vw < 1024;
364      const maxW = Math.max(1, vw - (isMobile ? 20 : 200));
365
366      const zoneTop    = isMobile ? 64  : 100;
367      const zoneBottom = isMobile ? 140 : 100;
368      const zoneH = Math.max(1, vh - zoneTop - zoneBottom);
369
370      const naturalW = (srcMedia && (srcMedia.naturalWidth  || srcMedia.videoWidth  || srcMedia.width))  || 4;
371      const naturalH = (srcMedia && (srcMedia.naturalHeight || srcMedia.videoHeight || srcMedia.height)) || 3;
372      const ratio = naturalW > 0 && naturalH > 0 ? naturalW / naturalH : 4 / 3;
373
374      let w = maxW;
375      let h = w / ratio;
376      if (h > zoneH) {
377        h = zoneH;
378        w = h * ratio;
379      }
380
381      const x = (vw - w) / 2;
382      const y = zoneTop + (zoneH - h) / 2;
383      return { x, y, w, h };
384    }
385
386    /* ── 6. Закрытие с плавным fly-back к миниатюре ── */
387    close() {
388      if (!this._isOpen || this._isAnimating) return;
389      this._openToken++;
390      this._switchToken++;
391      this._closeWithFlyback();
392    }
393
394    _closeWithFlyback() {
395      const trigger = this._triggers[this._currentIndex];
396      if (!trigger) {
397        this._isAnimating = true;
398        this._isOpen = false;
399        this._finishClose(null);
400        return;
401      }
402
403      /* Видео убираем сразу — fly-back летит последним кадром
404         (this._image), как и при обычном открытии. */
405      this._teardownVideo();
406
407      /* Синхронизируем горизонтальный скролл до расчёта toRect.
408         Иначе при закрытии после перелистывания картинка может лететь
409         в координаты карточки, которая уже частично ушла за viewport. */
410      this._scrollIntoViewSync(trigger);
411
412      const triggerMedia = this._getTriggerMedia(trigger);
413      const fromRect = this._image.getBoundingClientRect();
414      const toRect = (triggerMedia || trigger).getBoundingClientRect();
415
416      const prefersReducedMotion = window.matchMedia &&
417        window.matchMedia('(prefers-reduced-motion: reduce)').matches;
418
419      if (prefersReducedMotion ||
420          !fromRect.width || !fromRect.height ||
421          !toRect.width || !toRect.height) {
422        this._isAnimating = true;
423        this._isOpen = false;
424        this._finishClose(trigger);
425        return;
426      }
427
428      this._isAnimating = true;
429      this._isOpen = false;
430      this._el.classList.add('is-closing');
431
432      const flyback = document.createElement('div');
433      flyback.className = 'lightbox-flyback';
434      flyback.setAttribute('aria-hidden', 'true');
435      Object.assign(flyback.style, {
436        left: fromRect.left + 'px',
437        top: fromRect.top + 'px',
438        width: fromRect.width + 'px',
439        height: fromRect.height + 'px'
440      });
441
442      const fullClone = this._createFlybackMediaClone(this._image, 'full');
443      const thumbClone = this._createFlybackMediaClone(triggerMedia, 'thumb');
444      flyback.appendChild(fullClone);
445      flyback.appendChild(thumbClone);
446      document.body.appendChild(flyback);
447      this._flybackEl = flyback;
448
449      /* Реальный lightbox-media прячем сразу: дальше пользователь видит
450         только flyback-клон, в котором full contain плавно сменяется
451         на thumb cover. Это убирает скачок из-за разных пропорций. */
452      this._image.style.visibility = 'hidden';
453
454      this._el.classList.remove('is-settled');
455      this._el.classList.remove('is-loading-full');
456      this._el.classList.remove('is-open');
457
458      /* Коммитим стартовую геометрию до назначения transition. */
459      void flyback.offsetWidth;
460
461      requestAnimationFrame(() => {
462        requestAnimationFrame(() => {
463          if (this._flybackEl !== flyback) return;
464
465          flyback.classList.add('is-ending');
466          Object.assign(flyback.style, {
467            transition:
468              'left ' + FLIP_DURATION + 'ms ' + FLIP_EASING + ', ' +
469              'top ' + FLIP_DURATION + 'ms ' + FLIP_EASING + ', ' +
470              'width ' + FLIP_DURATION + 'ms ' + FLIP_EASING + ', ' +
471              'height ' + FLIP_DURATION + 'ms ' + FLIP_EASING + ', ' +
472              'opacity 220ms ease',
473            left: toRect.left + 'px',
474            top: toRect.top + 'px',
475            width: toRect.width + 'px',
476            height: toRect.height + 'px'
477          });
478        });
479      });
480
481      let isDone = false;
482      let onEnd = null;
483
484      const done = () => {
485        if (isDone) return;
486        isDone = true;
487
488        if (this._closeTimer) {
489          clearTimeout(this._closeTimer);
490          this._closeTimer = null;
491        }
492        if (onEnd) {
493          flyback.removeEventListener('transitionend', onEnd);
494        }
495
496        this._finishClose(trigger);
497      };
498
499      onEnd = (e) => {
500        if (e.target !== flyback) return;
501        if (e.propertyName !== 'left' &&
502            e.propertyName !== 'top' &&
503            e.propertyName !== 'width' &&
504            e.propertyName !== 'height') return;
505        done();
506      };
507
508      flyback.addEventListener('transitionend', onEnd);
509      this._closeTimer = setTimeout(done, FLIP_DURATION + 120);
510    }
511
512    _getTriggerMedia(trigger) {
513      if (!trigger) return null;
514      return trigger.querySelector('img') || trigger.querySelector('video');
515    }
516
517    _createFlybackMediaClone(sourceMedia, mode) {
518      let src = '';
519
520      if (sourceMedia && sourceMedia.tagName === 'VIDEO') {
521        src = this._grabVideoFrame(sourceMedia) || sourceMedia.getAttribute('poster') || '';
522      } else if (sourceMedia && sourceMedia.tagName === 'IMG') {
523        src = sourceMedia.currentSrc ||
524              sourceMedia.getAttribute('src') ||
525              sourceMedia.src ||
526              '';
527      }
528
529      const clone = src ? document.createElement('img') : document.createElement('span');
530      clone.className = 'lightbox-flyback__media lightbox-flyback__media--' + mode;
531      clone.setAttribute('aria-hidden', 'true');
532
533      if (src) {
534        clone.src = src;
535        clone.alt = '';
536        clone.draggable = false;
537        clone.decoding = 'async';
538      }
539
540      return clone;
541    }
542
543    _finishClose(triggerToFocus) {
544      this._openToken += 1;
545
546      if (this._switchTimer) {
547        clearTimeout(this._switchTimer);
548        this._switchTimer = null;
549      }
550
551      if (this._closeTimer) {
552        clearTimeout(this._closeTimer);
553        this._closeTimer = null;
554      }
555
556      if (this._flybackEl) {
557        this._flybackEl.remove();
558        this._flybackEl = null;
559      }
560
561      this._teardownVideo();
562
563      this._triggers.forEach((t) => {
564        t.classList.remove('lightbox-source--active');
565      });
566
567      this._el.classList.remove('is-open', 'is-settled', 'is-loading-full', 'is-closing');
568      this._el.setAttribute('hidden', '');
569
570      this._image.style.transition = '';
571      this._image.style.width = '';
572      this._image.style.height = '';
573      this._image.style.left = '';
574      this._image.style.top = '';
575      this._image.style.opacity = '';
576      this._image.style.visibility = '';
577      this._image.removeAttribute('src');
578      this._image.alt = '';
579
580      this._isAnimating = false;
581      this._isOpen = false;
582
583      if (window.TagankaPage && window.TagankaPage.unlockScroll) {
584        window.TagankaPage.unlockScroll();
585      }
586
587      if (triggerToFocus && triggerToFocus.focus) {
588        try {
589          triggerToFocus.focus({ preventScroll: true });
590        } catch (e) {
591          triggerToFocus.focus();
592        }
593      }
594    }
595
596    /* ── 7. Перелистывание ── */
597    prev() {
598      if (this._isAnimating) return;
599      const newIndex = (this._currentIndex - 1 + this._triggers.length) % this._triggers.length;
600      this._switchTo(newIndex);
601    }
602
603    next() {
604      if (this._isAnimating) return;
605      const newIndex = (this._currentIndex + 1) % this._triggers.length;
606      this._switchTo(newIndex);
607    }
608
609    /* Простое переключение с fade: full-фото сначала готовится,
610       потом невидимо меняется src + геометрия. Так не появляется
611       blur/lazy-фон и не видно скачка при разных пропорциях фото. */
612    _switchTo(newIndex) {
613      if (this._isAnimating) return;
614
615      const oldTrigger = this._triggers[this._currentIndex];
616      const newTrigger = this._triggers[newIndex];
617      if (!newTrigger) return;
618
619      const switchToken = ++this._switchToken;
620      this._isAnimating = true;
621
622      this._teardownVideo();
623
624      if (oldTrigger) oldTrigger.classList.remove('lightbox-source--active');
625      newTrigger.classList.add('lightbox-source--active');
626      this._pauseTileVideo(newTrigger);
627
628      this._currentIndex = newIndex;
629      this._scrollIntoViewSync(newTrigger);
630
631      const triggerMedia = this._isVideoTrigger(newTrigger)
632        ? newTrigger.querySelector('video')
633        : newTrigger.querySelector('img');
634
635      /* Не гасим старое фото до подготовки нового: иначе при медленном
636         full-load пользователь видит чёрный фон/плейсхолдер. */
637      this._resolveOpenMedia(newTrigger, triggerMedia, SWITCH_PRELOAD_TIMEOUT)
638        .then((media) => {
639          if (!this._isOpen || this._currentIndex !== newIndex || this._switchToken !== switchToken) {
640            this._isAnimating = false;
641            return;
642          }
643
644          this._image.style.transition = 'opacity ' + (FLIP_DURATION / 2) + 'ms ease';
645          this._image.style.opacity = '0';
646
647          setTimeout(() => {
648            if (!this._isOpen || this._currentIndex !== newIndex || this._switchToken !== switchToken) {
649              this._isAnimating = false;
650              return;
651            }
652
653            const target = this._calcTargetRect(media.measureMedia || triggerMedia);
654
655            this._image.style.transition = 'none';
656            this._image.style.width  = target.w + 'px';
657            this._image.style.height = target.h + 'px';
658            this._image.style.left   = target.x + 'px';
659            this._image.style.top    = target.y + 'px';
660            this._image.src = media.displaySrc || '';
661            this._image.alt = media.alt || '';
662            this._image.style.opacity = '0';
663            this._el.classList.remove('is-loading-full');
664            this._renderCaption();
665
666            this._maybeSwapFallbackToFull(media, newIndex, this._openToken);
667
668            void this._image.offsetWidth;
669
670            this._image.style.transition = 'opacity ' + (FLIP_DURATION / 2) + 'ms ease';
671            this._image.style.opacity = '1';
672
673            if (this._isVideoTrigger(newTrigger)) {
674              this._mountVideo();
675            }
676
677            setTimeout(() => {
678              if (this._switchToken !== switchToken) return;
679              this._image.style.transition = '';
680              this._isAnimating = false;
681            }, (FLIP_DURATION / 2) + 40);
682          }, FLIP_DURATION / 2);
683        })
684        .catch(() => {
685          this._isAnimating = false;
686        });
687    }
688
689
690
691/* Мгновенный scrollIntoView через scrollLeft родителя.
692       На iOS Safari нативный scrollIntoView({ behavior: 'instant' })
693       может игнорироваться. Идём по родителям вверх и для каждого
694       horizontal-scroll-контейнера вычисляем нужный scrollLeft. */
695    _scrollIntoViewSync(el) {
696      let parent = el.parentElement;
697      while (parent && parent !== document.body) {
698        const cs = window.getComputedStyle(parent);
699        const overflowX = cs.overflowX;
700        if (overflowX === 'auto' || overflowX === 'scroll') {
701          const elRect    = el.getBoundingClientRect();
702          const parRect   = parent.getBoundingClientRect();
703          /* Если элемент справа за рамкой — доскроллить вправо */
704          if (elRect.right > parRect.right) {
705            parent.scrollLeft += (elRect.right - parRect.right);
706          }
707          /* Если слева — доскроллить влево */
708          else if (elRect.left < parRect.left) {
709            parent.scrollLeft -= (parRect.left - elRect.left);
710          }
711        }
712        parent = parent.parentElement;
713      }
714    }
715
716
717    _nextFrame() {
718      return new Promise((resolve) => requestAnimationFrame(resolve));
719    }
720
721    _getMediaSrc(media) {
722      if (!media) return '';
723      if (media.tagName === 'IMG') {
724        return media.currentSrc || media.getAttribute('src') || media.src || '';
725      }
726      if (media.tagName === 'VIDEO') {
727        return this._grabVideoFrame(media) || media.getAttribute('poster') || '';
728      }
729      return '';
730    }
731
732    _loadImage(src) {
733      if (!src) return Promise.resolve(null);
734      if (this._preloadCache[src]) return this._preloadCache[src];
735
736      this._preloadCache[src] = new Promise((resolve) => {
737        const img = new Image();
738        let done = false;
739
740        const finish = (value) => {
741          if (done) return;
742          done = true;
743          resolve(value);
744        };
745
746        img.decoding = 'async';
747        if ('fetchPriority' in img) {
748          img.fetchPriority = 'high';
749        }
750
751        img.onload = () => {
752          if (img.decode) {
753            img.decode().then(() => finish(img)).catch(() => finish(img));
754          } else {
755            finish(img);
756          }
757        };
758        img.onerror = () => finish(null);
759        img.src = src;
760
761        if (img.complete && img.naturalWidth) {
762          if (img.decode) {
763            img.decode().then(() => finish(img)).catch(() => finish(img));
764          } else {
765            finish(img);
766          }
767        }
768      });
769
770      return this._preloadCache[src];
771    }
772
773    _withTimeout(promise, timeoutMs) {
774      return new Promise((resolve) => {
775        let settled = false;
776        const timer = setTimeout(() => {
777          if (settled) return;
778          settled = true;
779          resolve(null);
780        }, timeoutMs);
781
782        promise.then((value) => {
783          if (settled) return;
784          settled = true;
785          clearTimeout(timer);
786          resolve(value);
787        }).catch(() => {
788          if (settled) return;
789          settled = true;
790          clearTimeout(timer);
791          resolve(null);
792        });
793      });
794    }
795
796    async _resolveOpenMedia(trigger, triggerMedia, timeoutMs) {
797      const isVideo = this._isVideoTrigger(trigger);
798
799      if (isVideo) {
800        const frameSrc = this._grabVideoFrame(triggerMedia);
801        return {
802          displaySrc: frameSrc,
803          measureMedia: triggerMedia,
804          alt: '',
805          fullReady: true,
806          fullSrc: frameSrc,
807          pendingFullPromise: null
808        };
809      }
810
811      const thumbSrc = this._getMediaSrc(triggerMedia);
812      const fullSrc = trigger.getAttribute(this._imageAttr) || thumbSrc;
813      const alt = (triggerMedia && triggerMedia.alt) || '';
814
815      if (!fullSrc || fullSrc === thumbSrc) {
816        return {
817          displaySrc: thumbSrc,
818          measureMedia: triggerMedia,
819          alt,
820          fullReady: true,
821          fullSrc: thumbSrc,
822          pendingFullPromise: null
823        };
824      }
825
826      const pendingFullPromise = this._loadImage(fullSrc);
827      const fullImg = await this._withTimeout(pendingFullPromise, timeoutMs || OPEN_PRELOAD_TIMEOUT);
828
829      if (fullImg && fullImg.naturalWidth && fullImg.naturalHeight) {
830        return {
831          displaySrc: fullSrc,
832          measureMedia: fullImg,
833          alt,
834          fullReady: true,
835          fullSrc,
836          pendingFullPromise
837        };
838      }
839
840      return {
841        displaySrc: thumbSrc,
842        measureMedia: triggerMedia,
843        alt,
844        fullReady: false,
845        fullSrc,
846        pendingFullPromise
847      };
848    }
849
850    _maybeSwapFallbackToFull(media, index, openToken) {
851      if (!media || media.fullReady || !media.pendingFullPromise || !media.fullSrc) return;
852
853      media.pendingFullPromise.then((img) => {
854        if (!img || !img.naturalWidth || !img.naturalHeight) return;
855        if (!this._isOpen || this._currentIndex !== index || this._openToken !== openToken) return;
856
857        /* Если full пришёл позже fallback'а и у него другая пропорция,
858           не подменяем его в старую 4:3-рамку. Именно такая поздняя
859           подмена давала ощущение, что фото “плавает” внутри лайтбокса.
860           Full уже окажется в cache и откроется корректно со следующего раза. */
861        const oldW = media.measureMedia && (media.measureMedia.naturalWidth || media.measureMedia.videoWidth || media.measureMedia.width);
862        const oldH = media.measureMedia && (media.measureMedia.naturalHeight || media.measureMedia.videoHeight || media.measureMedia.height);
863        const oldRatio = oldW && oldH ? oldW / oldH : 4 / 3;
864        const newRatio = img.naturalWidth / img.naturalHeight;
865        if (Math.abs(oldRatio - newRatio) > 0.01) return;
866
867        this._image.src = media.fullSrc;
868      }).catch(() => {});
869    }
870
871    _warmupFullImages() {
872      const run = () => {
873        this._triggers.forEach((trigger) => {
874          if (this._isVideoTrigger(trigger)) return;
875          const fullSrc = trigger.getAttribute(this._imageAttr);
876          if (fullSrc) this._loadImage(fullSrc);
877        });
878      };
879
880      if ('requestIdleCallback' in window) {
881        window.requestIdleCallback(run, { timeout: 1600 });
882      } else {
883        setTimeout(run, 600);
884      }
885    }
886
887
888    /* ── Видео-слайд ──
889       Триггер = видео, если на нём есть data-video. FLIP летит
890       кадром (this._image), как у фото — open()/close()/_switchTo
891       не меняются. После settled поверх кадра монтируется
892       <video controls autoplay>. */
893    _isVideoTrigger(trigger) {
894      return !!(trigger && trigger.getAttribute(this._videoAttr));
895    }
896
897    /* Пауза плиточного <video> триггера, пока он открыт в лайтбоксе.
898       visibility:hidden от lightbox-source--active НЕ останавливает
899       воспроизведение (известный принцип проекта) — глушим Ñ
899Ð²Ð½Ð¾,
900       чтобы не было двойного звука ни в одном браузере. */
901    _pauseTileVideo(trigger) {
902      if (!trigger) return;
903      const tv = trigger.querySelector('video');
904      if (tv && !tv.paused) {
905        try { tv.pause(); } catch (e) { /* noop */ }
906      }
907    }
908
909    /* Снимаем текущий кадр <video> плитки в <canvas> → data-URL.
910       Это «обложка» для FLIP — отдельной картинки нет.
911       Если кадр ещё не готов (metadata не загружена) — вернётся
912       пустая строка, FLIP стартует без кадра (фон чёрный),
913       затем видео подменит. */
914    _grabVideoFrame(video) {
915      if (!video) return '';
916      const w = video.videoWidth;
917      const h = video.videoHeight;
918      if (!w || !h) return '';
919      try {
920        const canvas = document.createElement('canvas');
921        canvas.width  = w;
922        canvas.height = h;
923        canvas.getContext('2d').drawImage(video, 0, 0, w, h);
924        return canvas.toDataURL('image/jpeg', 0.85);
925      } catch (e) {
926        /* CORS-tainted (видео с другого домена) — кадр снять нельзя.
927           FLIP стартует без кадра, видео подменит после settled. */
928        return '';
929      }
930    }
931
932    /* <video controls autoplay> поверх settled-кадра, в тех же
933       inline-координатах что this._image. Со звуком. По окончании —
934       стоп-кадр (loop выключен, currentTime НЕ сбрасываем). */
935    _mountVideo() {
936      const trigger = this._triggers[this._currentIndex];
937      if (!this._isVideoTrigger(trigger)) return;
938
939      const src = trigger.getAttribute(this._videoAttr);
940      if (!src) return;
941
942      this._teardownVideo();
943
944      const v = document.createElement('video');
945      v.className   = 'lightbox__video';
946      v.preload     = 'auto';
947
948      /* iOS Safari уводит видео в нативный полноэкранный плеер при
949         play(), если playsinline не выставлен КАК HTML-АТРИБУТ.
950         DOM-свойство v.playsInline на iOS ненадёжно (тот же класс
951         бага, что v.hidden на SVG) — ставим именно атрибуты.
952         webkit-playsinline нужен для старых iOS. controls тоже
953         атрибутом — для единообразия. */
954      v.setAttribute('playsinline', '');
955      v.setAttribute('webkit-playsinline', '');
956      v.setAttribute('controls', '');
957      v.playsInline = true;   // дублируем свойством — не мешает
958      v.controls    = true;
959
960      /* src назначаем ПОСЛЕ playsinline: на iOS источник, заданный
961         до атрибута inline, в части версий всё равно открывается
962         фуллскрином. */
963      v.src = src;
964
965      /* Совмещаем геометрию с кадром: те же inline left/top/width/height,
966         что JS выставил на this._image после FLIP. */
967      v.style.left   = this._image.style.left;
968      v.style.top    = this._image.style.top;
969      v.style.width  = this._image.style.width;
970      v.style.height = this._image.style.height;
971
972      this._stage.appendChild(v);
973      this._video = v;
974
975      /* iOS Safari: touch по нативным контролам (скраббинг, громкость)
976         не должен считаться свайпом лайтбокса. Гасим bubbling до
977         корня, где висит swipe-обработчик. */
978      v.addEventListener('touchstart', (e) => e.stopPropagation(), { passive: true });
979      v.addEventListener('touchend',   (e) => e.stopPropagation(), { passive: true });
980
981      /* Fade-in поверх кадра, когда готов первый кадр.
982         loadeddata надёжнее canplay на iOS. Fallback по таймеру. */
983      const reveal = () => {
984        if (this._video !== v) return;
985        v.classList.add('is-ready');
986      };
987      v.addEventListener('loadeddata', reveal, { once: true });
988      this._videoRevealTimer = setTimeout(reveal, 600);
989
990      /* Автозапуск со звуком. Если браузер заблокировал autoplay
991         с аудио (политика) — пробуем без звука; пользователь
992         включит звук нативным контролом. */
993      v.play().catch(() => {
994        if (this._video !== v) return;
995        v.muted = true;
996        v.play().catch(() => { /* пользователь запустит сам через controls */ });
997      });
998    }
999
1000    _teardownVideo() {
1001      if (this._videoRevealTimer) {
1002        clearTimeout(this._videoRevealTimer);
1003        this._videoRevealTimer = null;
1004      }
1005      if (this._video) {
1006        try {
1007          this._video.pause();
1008          this._video.removeAttribute('src');
1009          this._video.load();
1010        } catch (e) { /* noop */ }
1011        if (this._video.parentNode) {
1012          this._video.parentNode.removeChild(this._video);
1013        }
1014        this._video = null;
1015      }
1016    }
1017
1018
1019    /* ── 8. Подпись ── */
1020    _renderCaption() {
1021      const trigger = this._triggers[this._currentIndex];
1022      const total   = this._triggers.length;
1023
1024      this._counterEl.textContent = (this._currentIndex + 1) + ' / ' + total;
1025      this._titleEl.textContent   = this._title;
1026
1027      const caption = trigger.getAttribute(this._captionAttr) || '';
1028      const author  = trigger.getAttribute(this._authorAttr)  || '';
1029      const info    = [caption, author].filter(Boolean).join(' · ');
1030      this._infoEl.textContent = info;
1031    }
1032
1033    destroy() {
1034      this._teardownVideo();
1035      if (this._closeTimer) {
1036        clearTimeout(this._closeTimer);
1037        this._closeTimer = null;
1038      }
1039      if (this._switchTimer) {
1040        clearTimeout(this._switchTimer);
1041        this._switchTimer = null;
1042      }
1043      if (this._flybackEl) {
1044        this._flybackEl.remove();
1045        this._flybackEl = null;
1046      }
1047      document.removeEventListener('keydown', this._onKey);
1048      if (this._el && this._el.parentNode) {
1049        this._el.parentNode.removeChild(this._el);
1050      }
1051    }
1052  }
1053
1054  window.Lightbox = Lightbox;
1055})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.