PageSourceSearch

https://linantai.taipei/Scripts/hamalightGallery/hamalightGallery.js

js linantai.taipei collected 2026-09-26 10:26:35 UTC 27,622 bytes, 755 lines download raw bytes

1let __hamalightId__ = 0;
2
3class HamaLightGallery {
4    constructor(element, options = {}) {
5        this.element = typeof element === "string" ? document.querySelector(element) : element;
6        this.options = {
7            selector: "",
8            container: "",
9            transitionDuration: 300,
10            showDownloadButton: true,
11            showCloseButton: true,
12            enableKeyboardNavigation: true,
13            ...options
14        };
15
16        this.items = [];
17        this.media = [];
18        this.previousIndexes = [];
19        this.currentIndex = 0;
20        this.currentMedia = null;
21        this.isOpen = false;
22        this.isBusy = false;
23        this.focusableElements = [];
24        this.lastFocusedElement = null;
25
26        this.$container = null;
27        this.$backdrop = null;
28        this.$inner = null;
29        this.$toolbar = null;
30        this.$list = null;
31        this.$slide = null;
32        this.$description = null;
33
34        this.$close = null;
35        this.$prev = null;
36        this.$next = null;
37        this.$counter = null;
38        this.$download = null;
39
40        // 先綁定一次,removeEventListener 才能移除同一個函式參考
41        this.handleKeyDown = this.handleKeyDown.bind(this);
42
43        this.initSettings();
44        this.init();
45
46        __hamalightId__++;
47    }
48
49    initSettings() {
50        if (!this.options.container) {
51            this.options.container = document.body;
52        }
53        else {
54            if (typeof this.options.container === "string") {
55                this.options.container = document.querySelector(this.options.container);
56            }
57        }
58    }
59
60    init() {
61        if (!this.element) {
62            console.error("HamaLightGallery: Element not found");
63            return;
64        }
65
66        this.items = this.getItems();
67        this.media = this.getMediaItems(this.items);
68        this.bindEvents(this.items);
69        this.buildStructure();
70    }
71
72    buildStructure() {
73        if (this.$container) return;
74
75        const labels = this._getLabels();
76        const closeButton = this.options.showCloseButton ? `<a href="#" class="halg-close" id="halg-close-${__hamalightId__}" role="button" aria-label="${labels.close}" tabindex="0"></a>` : "";
77        const downloadButton = this.options.showDownloadButton ? `<a class="halg-download" id="halg-download-${__hamalightId__}" target="_blank" rel="noopener" title ="「另開新視窗」" download aria-label="${labels.downloadLink}" tabindex="0"></a>` : "";
78        const counter = `
79            <div class="halg-count" id="halg-count-${__hamalightId__}">
80                <span class="halg-count-current" id="halg-count-current-${__hamalightId__}"></span> / <span class="halg-count-total">${this.media.length}</span>
81            </div>
82        `;
83        const template = `
84            <div class="halg-lightgallery" id="halg-container-${__hamalightId__}" aria-modal="false" role="dialog" tabindex="-1" hidden="">
85                <div class="halg-backdrop" id="halg-backdrop-${__hamalightId__}"></div>
86                <div class="halg-inner" id="halg-inner-${__hamalightId__}">
87                    <span class="halg-focus-sentinel" id="halg-sentinel-start-${__hamalightId__}" tabindex="0"></span>
88                    <div class="halg-toolgroup" id="halg-toolgroup-${__hamalightId__}">
89                        ${counter}
90                        <div class="halg-toolbtn">
91                            ${downloadButton}
92                            ${closeButton}
93                        </div>
94                    </div>
95                    <div class="halg-list" id="halg-list-${__hamalightId__}">
96                        <ul id="halg-slide-${__hamalightId__}"></ul>
97                        <div class="halg-actions">
98                            <a href="#" class="halg-prev" id="halg-prev-${__hamalightId__}" role="button" aria-label="${labels.prev}" tabindex="0"></a>
99                            <a href="#" class="halg-next" id="halg-next-${__hamalightId__}" role="button" aria-label="${labels.next}" tabindex="0"></a>
100                        </div>
101                    </div>
102                    <div class="halg-description" id="halg-description-${__hamalightId__}"></div>
103                    <span class="halg-focus-sentinel" id="halg-sentinel-end-${__hamalightId__}" tabindex="0"></span>
104                </div>
105            </div>
106        `;
107
108        this.options.container.insertAdjacentHTML("beforeend", template);
109
110        this.$container = document.querySelector(`#halg-container-${__hamalightId__}`);
111        this.$backdrop = document.querySelector(`#halg-backdrop-${__hamalightId__}`);
112        this.$inner = document.querySelector(`#halg-inner-${__hamalightId__}`);
113        this.$toolbar = document.querySelector(`#halg-toolgroup-${__hamalightId__}`);
114        this.$list = document.querySelector(`#halg-list-${__hamalightId__}`);
115        this.$slide = document.querySelector(`#halg-slide-${__hamalightId__}`);
116        this.$description = document.querySelector(`#halg-description-${__hamalightId__}`);
117
118        this.$close = this.options.showCloseButton ? document.querySelector(`#halg-close-${__hamalightId__}`) : null;
119        this.$prev = document.querySelector(`#halg-prev-${__hamalightId__}`);
120        this.$next = document.querySelector(`#halg-next-${__hamalightId__}`);
121        this.$counter = document.querySelector(`#halg-count-current-${__hamalightId__}`);
122        this.$download = document.querySelector(`#halg-download-${__hamalightId__}`);
123        this.$sentinelStart = document.querySelector(`#halg-sentinel-start-${__hamalightId__}`);
124        this.$sentinelEnd = document.querySelector(`#halg-sentinel-end-${__hamalightId__}`);
125
126        // 焦點落在哨兵上時轉送到頭/尾的真實元素,形成焦點循環。
127        // 不攔截 Tab keydown,焦點才能自然走進 video 控制列(shadow DOM)的內部按鈕。
128        this.$sentinelStart.addEventListener("focus", () => {
129            this.updateFocusableElements();
130            const els = this.focusableElements;
131            if (els.length) els[els.length - 1].focus();
132        });
133
134        this.$sentinelEnd.addEventListener("focus", () => {
135            this.updateFocusableElements();
136            if (this.focusableElements.length) this.focusableElements[0].focus();
137        });
138
139        if (this.$close) {
140            this.$close.addEventListener("click", (e) => {
141                e.preventDefault();
142                this.close();
143            });
144
145            this.$close.addEventListener("keydown", (e) => {
146                if (e.key === "Enter" || e.key === " ") {
147                    e.preventDefault();
148                    this.close();
149                }
150            });
151        }
152
153        if (this.$download) {
154            this.$download.addEventListener("keydown", (e) => {
155                if (e.key === "Enter" || e.key === " ") {
156                    // 空格鍵也觸發下載,但保留默認行為
157                    if (e.key === " ") {
158                        e.preventDefault();
159                        this.$download.click();
160                    }
161                }
162            });
163        }
164
165        if (this.media.length <= 1) {
166            this.$prev.style.display = "none";
167            this.$next.style.display = "none";
168        } else {
169            this.$prev.addEventListener("click", (e) => {
170                e.preventDefault();
171                this.prev();
172            });
173
174            this.$prev.addEventListener("keydown", (e) => {
175                if (e.key === "Enter" || e.key === " ") {
176                    e.preventDefault();
177                    this.prev();
178                }
179            });
180
181            this.$next.addEventListener("click", (e) => {
182                e.preventDefault();
183                this.next();
184            });
185
186            this.$next.addEventListener("keydown", (e) => {
187                if (e.key === "Enter" || e.key === " ") {
188                    e.preventDefault();
189                    this.next();
190                }
191            });
192        }
193    }
194
195    bindEvents(items) {
196        items.forEach((item, index) => {
197            // 找到可聚焦的連結元素
198            const tag = item.tagName.toLowerCase();
199            // 優先使用 <a> 標籤,如果沒有則嘗試在項目內查找 <a>
200            const link = tag === "a" ? item : item.querySelector("a");
201
202            // 如果找不到連結,則使用項目本身
203            const focusableElement = link || item;
204
205            // 確保元件可聚焦
206            if (!focusableElement.hasAttribute('tabindex')) {
207                focusableElement.setAttribute('tabindex', '0');
208            }
209
210            item.addEventListener("click", (e) => {
211                e.preventDefault();
212                // 記錄實際的連結元素,以便關閉時可以重新聚焦
213                this.lastFocusedElement = focusableElement;
214                this.open(index);
215            });
216
217            // 支援鍵盤按下 Enter 或空格時打開畫廊
218            focusableElement.addEventListener("keydown", (e) => {
219                if (e.key === "Enter" || e.key === " ") {
220                    e.preventDefault();
221                    // 同樣記錄鍵盤操作的元件
222                    this.lastFocusedElement = focusableElement;
223                    this.open(index);
224                }
225            });
226
227            // 設置游標樣式
228            item.style.cursor = "pointer";
229        });
230    }
231
232    getItems() {
233        return this.options.selector
234            ? [...this.element.querySelectorAll(this.options.selector)]
235            : [...this.element.children];
236    }
237
238    getMediaItems(items) {
239        return Array.from(items)
240            .map((el, index) => {
241                const tag = el.tagName.toLowerCase();
242                const img = tag === "img" ? el : el.querySelector("img");
243                const a = tag === "a" ? el : el.querySelector("a");
244
245                const src = el.dataset.src || a.href;
246                const videoOption = this._isVideo(el);
247
248                if (videoOption) {
249                    return {
250                        src: src,
251                        alt: img.alt || `Video ${index + 1}`,
252                        type: "video",
253                        el: this._createVideo(src, videoOption, a && a.dataset.title),
254                        thumbnailEl: img,
255                        ...videoOption
256                    };
257                }
258                else if (img) {
259                    return {
260                        src: src,
261                        alt: img.alt || `Image ${index + 1}`,
262                        type: "image",
263                        el: null,
264                        thumbnailEl: img,
265                    };
266                }
267                else {
268                    console.warn(`HamaLightGallery: Unsupported element in gallery item #${index}`);
269                    return null;
270                }
271            })
272            .filter(Boolean);
273    }
274
275    open(index) {
276        if (!this.media.length) return;
277
278        this.currentIndex = index;
279        this.isOpen = true;
280
281        this.toggleState();
282        this.updateSlide(index, null, null);
283
284        // 防止背景滾動
285        this.options.container.style.overflow = "hidden";
286    }
287
288    close() {
289        this.isOpen = false;
290
291        this.toggleState();
292        this.$slide.innerHTML = "";
293
294        this.options.container.style.overflow = "";
295
296        this.previousIndexes = [];
297        this.isBusy = false;
298
299        // 關閉畫廊時將焦點返回原本點擊的元件
300        if (this.lastFocusedElement) {
301            setTimeout(() => {
302                this.lastFocusedElement.focus();
303            }, 10);
304        }
305    }
306
307    prev() {
308        if (this.isBusy) return;
309
310        const newIndex = this._wrapIndex(this.currentIndex - 1);
311        this.updateSlide(newIndex, this.currentIndex, "prev");
312        this.currentIndex = newIndex;
313    }
314
315    next() {
316        if (this.isBusy) return;
317
318        const newIndex = this._wrapIndex(this.currentIndex + 1);
319        this.updateSlide(newIndex, this.currentIndex, "next");
320        this.currentIndex = newIndex;
321    }
322
323    updateSlide(to, from, direction) {
324        if (this.isBusy) return;
325
326        this.isBusy = true;
327
328        // 更新內容
329        this.updateSlideContent(to, from, direction);
330
331        // 更新計數器
332        this.updateCounter(to);
333
334        // 更新下載按鈕
335        this.updateDownloadLink(to);
336
337        // 更新檔名
338        this.updateComponents(to);
339    }
340
341    updateSlideContent(to, from, direction) {
342        const maxCount = Math.min(3, this.media.length);
343        const indexes = Array(maxCount).fill(0).map((_, i) => this._wrapIndex(to + (i - maxCount + (maxCount - 1))));
344        const removedIndexes = this._diffIndexes(indexes, this.previousIndexes);
345
346        // 移除舊元素
347        removedIndexes.forEach((item) => {
348            if (this.previousIndexes.includes(item)) {
349                const $slideItem = this.$slide.querySelector(`.halg-item-${item}`);
350                $slideItem.remove();
351            }
352        });
353
354        // 新增元素
355        const renderSlideContent = (media, i) => {
356            if (!this.isOpen) return;
357
358            const $slideItem = this.$slide.querySelector(`.halg-item-${i}`);
359
360            if ($slideItem.children.length === 0) {
361                $slideItem.append(media);
362            }
363
364            if (i === to) {
365                $slideItem.classList.add("halg-item-current");
366
367                setTimeout(() => {
368                    this.currentMedia = media;
369                    this.isBusy = false;
370                    this.previousIndexes = indexes;
371                    this._controlVideo(to, from);
372                }, this.options.transitionDuration);
373            }
374        };
375
376        // 更新元素
377        new Promise((resolve) => {
378            const fragment = document.createDocumentFragment();
379            indexes.forEach((item) => {
380                let $slideItem = this.$slide.querySelector(`.halg-item-${item}`);
381
382                if (removedIndexes.includes(item) || $slideItem == null) {
383                    $slideItem = this._createSlide(item);
384                    fragment.append($slideItem);
385                }
386
387                $slideItem.classList.remove("halg-item-current", "halg-item-prev", "halg-item-next");
388
389                if (direction === "next") {
390                    setTimeout(() => {
391                        if (item === from) {
392                            $slideItem.classList.add("halg-item-prev");
393                        }
394                        else {
395                            $slideItem.classList.add("halg-item-next");
396                        }
397                    }, this.options.transitionDuration / 2);
398                }
399
400                if (direction === "prev") {
401                    setTimeout(() => {
402                        if (item === from) {
403                            $slideItem.classList.add("halg-item-next");
404                        }
405                        else {
406                            $slideItem.classList.add("halg-item-prev");
407                        }
408                    }, this.options.transitionDuration / 2);
409                }
410            });
411            this.$slide.append(fragment);
412
413            setTimeout(() => {
414                resolve();
415            }, this.options.transitionDuration);
416        }).then(() => {
417            indexes.forEach((i) => {
418                const item = this.media[i];
419
420                if (item.el) {
421                    if (i === to) renderSlideContent(item.el, i);
422                }
423                else {
424                    if (item.type === "image") {
425                        const img = new Image();
426
427                        img.src = item.src;
428                        img.alt = item.alt || "";
429                        img.classList.add("halg-img-loading");
430
431                        img.onload = () => {
432                            img.classList.add("halg-img-loaded");
433                            item.el = img;
434                            if (i === to) renderSlideContent(img, i);
435                        };
436
437                        img.onerror = () => {
438                            item.el = item.thumbnailEl;
439                            if (i === to) renderSlideContent(item.thumbnailEl, i);
440                        };
441                    }
442                }
443            });
444        });
445    }
446
447    updateCounter(index) {
448        this.$counter.textContent = index + 1;
449    }
450
451    updateDownloadLink(index) {
452        if (this.options.showDownloadButton) {
453            const currentItem = this.media[index];
454            this.$download.href = currentItem.src;
455            this.$download.setAttribute("download", true);
456        }
457    }
458
459    updateComponents(index) {
460        const currentItem = this.media[index];
461        this.$description.textContent = currentItem.alt;
462    }
463
464    toggleState() {
465        const open = this.isOpen;
466        this.$container.classList.toggle("is-active", open);
467
468        if (open) {
469            this.$container.setAttribute("aria-modal", "true");
470            this.$container.removeAttribute("hidden", "");
471            this.initKeyboardNavigation();
472        } else {
473            this.$container.setAttribute("aria-modal", "false");
474            this.$container.setAttribute("hidden", "");
475            this.removeKeyboardNavigation();
476        }
477    }
478
479    _createSlide(i) {
480        const div = document.createElement("li");
481        div.dataset.index = i;
482        div.className = `halg-item halg-item-${i}`;
483        return div;
484    }
485
486    initKeyboardNavigation() {
487        if (!this.options.enableKeyboardNavigation) return;
488
489        // 添加鍵盤事件監聽
490        document.addEventListener('keydown', this.handleKeyDown);
491
492        // 收集可聚焦元素,初始焦點設在第一個元素(下載按鈕),Tab 順序由上而下:下載 → 關閉 → 影片
493        this.updateFocusableElements();
494
495        if (this.focusableElements.length > 0) {
496            this.focusableElements[0].focus();
497        } else {
498            this.$container.focus({ preventScroll: true });
499        }
500    }
501
502    removeKeyboardNavigation() {
503        if (!this.options.enableKeyboardNavigation) return;
504
505        document.removeEventListener('keydown', this.handleKeyDown);
506    }
507
508    updateFocusableElements() {
509        // 獲取所有可聚焦的元素(含影片播放器與 YouTube iframe,排除哨兵)
510        this.focusableElements = Array.from(
511            this.$container.querySelectorAll(
512                'a[href], button, input, textarea, select, video[controls], iframe, [tabindex]:not([tabindex="-1"])'
513            )
514        ).filter(el =>
515            !el.classList.contains('halg-focus-sentinel') &&
516            !el.hasAttribute('disabled') &&
517            el.offsetParent !== null
518        );
519    }
520
521    handleKeyDown(e) {
522        if (!this.isOpen) return;
523
524        // 焦點在影片播放器上時,方向鍵交給播放器本身(快轉/倒轉),不切換上下張
525        const target = e.target;
526        const inMedia = target && (target.tagName === 'VIDEO' || target.tagName === 'IFRAME');
527
528        switch (e.key) {
529            case 'Escape':
530                this.close();
531                // close() 方法已經包含了返回焦點的處理
532                break;
533            case 'ArrowLeft':
534                if (!inMedia) this.prev();
535                break;
536            case 'ArrowRight':
537                if (!inMedia) this.next();
538                break;
539        }
540    }
541
542    _isVideo(el) {
543        const src = el.dataset.src;
544        const html = el.dataset.html;
545        if (!src && html) {
546            return {
547                video: true
548            };
549        }
550
551        const youtube = src.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/);
552        if (youtube) {
553            return {
554                youtube: youtube[1]
555            }
556        }
557
558        return null;
559    }
560
561    _createVideo(src, option, title) {
562        const labels = this._getLabels();
563
564        if (option.video) {
565            const video = document.createElement("video");
566            const source = document.createElement("source");
567
568            source.src = src;
569            source.type = "video/mp4";
570
571            video.setAttribute("controls", true);
572            video.setAttribute("playsinline", true);
573            video.setAttribute("loop", true);
574            video.classList.add("halg-video-loaded");
575            video.append(source);
576
577            return video;
578        }
579        else if (option.youtube) {
580            const iframe = document.createElement("iframe");
581            const params = new URLSearchParams({
582                wmode: "opaque",
583                autoplay: 0,
584                mute: 0,
585                enablejsapi: 1,
586            });
587
588            iframe.src = `https://www.youtube.com/embed/${option.youtube}?${params}`;
589            // 帶入原始 youtube 連結的 data-title 屬性,若無則使用預設值。
590            // 此處使用 DOM 屬性指定(property assignment),瀏覽器會將值視為純字串,
591            // 不會解析成 HTML,因此可避免 XSS 攻擊。
592            iframe.title = (typeof title === "string" && title.trim() !== "")
593                ? title
594                : labels.youtubePlayer;
595            iframe.width = "80%";
596            iframe.height = "100%";
597            iframe.frameBorder = "0";
598            iframe.allow = "accelerometer; encrypted-media; gyroscope; picture-in-picture; web-share";
599            iframe.referrerpolicy = "strict-origin-when-cross-origin";
600
601            iframe.setAttribute("allowfullscreen", true);
602
603            return iframe
604        }
605    }
606
607    _getLabels() {
608        const labels = {
609            1: {
610                youtubePlayer: "YouTube 影片播放器",
611                prev: "上一張",
612                next: "下一張",
613                downloadLink: "下載連結",
614                close: "關閉"
615            },
616            2: {
617                youtubePlayer: "YouTube video player",
618                prev: "Previous image",
619                next: "Next image",
620                downloadLink: "Download link",
621                close: "Close"
622            },
623            3: {
624                youtubePlayer: "YouTube 動画プレーヤー",
625                prev: "前の画像",
626                next: "次の画像",
627                downloadLink: "ダウンロードリンク",
628                close: "閉じる"
629            },
630            4: {
631                youtubePlayer: "Trình phát video YouTube",
632                prev: "Ảnh trước",
633                next: "Ảnh tiếp theo",
634                downloadLink: "Liên kết tải xuống",
635                close: "Đóng"
636            },
637            5: {
638                youtubePlayer: "Pemutar video YouTube",
639                prev: "Gambar sebelumnya",
640                next: "Gambar berikutnya",
641                downloadLink: "Tautan unduhan",
642                close: "Tutup"
643            },
644            6: {
645                youtubePlayer: "เครื่องเล่นวิดีโอ YouTube",
646                prev: "ก่อนหน้า",
647                next: "ถัดไป",
648                downloadLink: "ลิงค์ดาวน์โหลด",
649                close: "ปิด"
650            },
651            7: {
652                youtubePlayer: "Player ng video sa YouTube",
653                prev: "Nakaraang larawan",
654                next: "Susunod na larawan",
655                downloadLink: "Link sa pag-download",
656                close: "Isara"
657            },
658            8: {
659                youtubePlayer: "កម្មវិធីចាក់វីដេអូ YouTube",
660                prev: "រូបភាពមុន",
661                next: "រូបភាពបន្ទាប់",
662                downloadLink: "តំណភ្ជាប់ទាញយក",
663                close: "បិទ"
664            },
665            9: {
666                youtubePlayer: "YouTube ဗီဒီယိုဖွင့်စက်",
667                prev: "ယခင်ပုံ",
668                next: "နောက်ပုံ",
669                downloadLink: "ေဒါင္းလုဒ္လင္႔",
670                close: "ပိတ်"
671            },
672            10: {
673                youtubePlayer: "YouTube 동영상 플레이어",
674                prev: "이전 이미지",
675                next: "다음 이미지",
676                downloadLink: "다운로드 링크",
677                close: "닫기"
678            }
679        };
680        const languageSN = Number(window.CCMS_LanguageSN);
681
682        return labels[languageSN] || labels[1];
683    }
684
685    _controlVideo(to, from) {
686        const toVideo = this.media[to] ?? {};
687        const fromVideo = this.media[from] ?? {};
688
689        if (toVideo.type === "video") {
690            if (toVideo.video) {
691                toVideo.el.play();
692            }
693
694            if (toVideo.youtube) {
695                try {
696                    const message = JSON.stringify({
697                        event: "command",
698                        func: "playVideo",
699                        args: []
700                    });
701
702                    toVideo.el.contentWindow.postMessage(message, "*");
703
704                    toVideo.el.onload = () => {
705                        toVideo.el.contentWindow.postMessage(message, "*");
706                    };
707                } catch (error) {
708                    console.error("HamaLightGallery: Error sending postMessage:", error);
709                }
710            }
711        }
712
713        if (fromVideo.type === "video") {
714            if (fromVideo.video) {
715                fromVideo.el.pause();
716            }
717
718            if (fromVideo.youtube) {
719                try {
720                    const message = JSON.stringify({
721                        event: "command",
722                        func: "pauseVideo",
723                        args: []
724                    });
725
726                    fromVideo.el.contentWindow.postMessage(message, "*");
727
728                    fromVideo.el.onload = () => {
729                        fromVideo.el.contentWindow.postMessage(message, "*");
730                    };
731                } catch (error) {
732                    console.error("HamaLightGallery: Error sending postMessage:", error);
733                }
734            }
735        }
736    }
737
738    _wrapIndex(i) {
739        return (i + this.media.length) % this.media.length;
740    }
741
742    _diffIndexes(arr1, arr2) {
743        const set1 = new Set(arr1);
744        const set2 = new Set(arr2);
745
746        const diff1 = arr1.filter(item => !set2.has(item));
747        const diff2 = arr2.filter(item => !set1.has(item));
748
749        return [...new Set([...diff1, ...diff2])];
750    }
751}
752
753function hamalightGallery(el, options) {
754    return new HamaLightGallery(el, options);
755}

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.