/* Fullscreen image galleries, derived from each page's existing images and captions. */ (() => { 'use strict'; if (typeof HTMLDialogElement === 'undefined') return; const groups = []; const figures = [...document.querySelectorAll('.case-screen')]; if (figures.length) { groups.push({ name: document.querySelector('.case-project-brand')?.textContent.trim() || 'TRONT', items: figures.map(figure => ({ link: figure.querySelector('figcaption a'), preview: figure.querySelector('.screen-canvas'), image: figure.querySelector('img'), title: document.getElementById(figure.getAttribute('aria-labelledby'))?.textContent.trim().replace(/^\d+\s*/, '') || '개발 화면', caption: figure.querySelector('figcaption p')?.textContent.trim() || '' })) }); } const certificates = [...document.querySelectorAll('.patent-document')]; if (certificates.length) { groups.push({ name: 'TRONT / PATENTS', items: certificates.map(link => ({ link, image: link.querySelector('img'), title: link.closest('article').querySelector('h3').textContent.trim(), caption: link.closest('article').querySelector('.patent-description').textContent.trim() })) }); } if (!groups.length) return; const reduced = matchMedia('(prefers-reduced-motion: reduce)'); const dialog = document.createElement('dialog'); dialog.className = 'image-viewer'; dialog.setAttribute('aria-labelledby', 'image-viewer-title'); dialog.innerHTML = `

—
`; document.body.append(dialog); const $ = selector => dialog.querySelector(selector); const stage = $('.viewer-stage'); const layout = $('.viewer-image-layout'); const image = $('.viewer-image'); const message = $('.viewer-message'); const thumbnails = $('.viewer-thumbnails'); const historyKey = '__trontImageViewer'; const token = `gallery-${Date.now()}`; let group, index = 0, opener, savedScroll, savedBody; let naturalWidth = 0, naturalHeight = 0, scale = 1, fitted = true; let loadId = 0, imageAnimation, drag, swipe, waitingForBack = false; function lockPage() { savedScroll = { x: scrollX, y: scrollY }; savedBody = ['position', 'top', 'left', 'width', 'paddingRight'].map(key => [key, document.body.style[key]]); const scrollbar = innerWidth - document.documentElement.clientWidth; const padding = parseFloat(getComputedStyle(document.body).paddingRight) || 0; Object.assign(document.body.style, { position: 'fixed', top: `-${savedScroll.y}px`, left: `-${savedScroll.x}px`, width: '100%', paddingRight: `${padding + scrollbar}px` }); document.documentElement.classList.add('image-viewer-open'); } function unlockPage() { savedBody.forEach(([key, value]) => { document.body.style[key] = value; }); document.documentElement.classList.remove('image-viewer-open'); window.scrollTo({ left: savedScroll.x, top: savedScroll.y, behavior: 'instant' }); opener?.focus({ preventScroll: true }); } function fitScale() { return Math.min(1, Math.max(1, stage.clientWidth - 80) / naturalWidth, Math.max(1, stage.clientHeight - 36) / naturalHeight); } function setScale(value, fit = false, center = false) { if (!naturalWidth || !dialog.open) return; const oldWidth = parseFloat(image.style.width) || naturalWidth; const oldHeight = parseFloat(image.style.height) || naturalHeight; const x = Math.max(0, Math.min(1, (stage.scrollLeft + stage.clientWidth / 2 - (layout.offsetWidth - oldWidth) / 2) / oldWidth)); const y = Math.max(0, Math.min(1, (stage.scrollTop + stage.clientHeight / 2 - (layout.offsetHeight - oldHeight) / 2) / oldHeight)); fitted = fit; scale = Math.max(Math.min(.1, fitScale()), Math.min(4, fit ? fitScale() : value)); const width = Math.round(naturalWidth * scale), height = Math.round(naturalHeight * scale); image.style.width = `${width}px`; image.style.height = `${height}px`; layout.style.width = `${Math.max(stage.clientWidth, width + 80)}px`; layout.style.height = `${Math.max(stage.clientHeight, height + 36)}px`; stage.scrollLeft = (center ? .5 : x) * width + (layout.offsetWidth - width) / 2 - stage.clientWidth / 2; stage.scrollTop = (center ? .5 : y) * height + (layout.offsetHeight - height) / 2 - stage.clientHeight / 2; stage.classList.toggle('can-pan', width + 80 > stage.clientWidth + 1 || height + 36 > stage.clientHeight + 1); $('.viewer-scale').textContent = `${Math.round(scale * 100)}%`; $('[data-action="fit"]').setAttribute('aria-pressed', String(fitted)); $('[data-action="actual"]').setAttribute('aria-pressed', String(Math.abs(scale - 1) < .001)); } function showImage(next, direction = 0) { if (!dialog.open) return; index = (next + group.items.length) % group.items.length; const item = group.items[index], requestId = ++loadId; naturalWidth = naturalHeight = 0; drag = swipe = null; stage.classList.remove('is-dragging'); imageAnimation?.cancel(); dialog.classList.add('is-loading'); image.hidden = true; $('.viewer-scale').textContent = '—'; $('#image-viewer-title').textContent = item.title; $('.viewer-caption p').textContent = item.caption; $('.viewer-counter').textContent = `${String(index + 1).padStart(2, '0')} / ${String(group.items.length).padStart(2, '0')}`; message.hidden = false; $('.viewer-message > span').textContent = '이미지를 불러오는 중입니다.'; $('[data-action="retry"]').hidden = true; [...thumbnails.children].forEach((button, i) => { button.setAttribute('aria-pressed', String(i === index)); if (i === index) button.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'instant' }); }); const loader = new Image(); loader.onload = () => { if (requestId !== loadId || !dialog.open) return; naturalWidth = loader.naturalWidth; naturalHeight = loader.naturalHeight; image.src = loader.src; image.alt = item.image.alt || item.title; image.hidden = false; dialog.classList.remove('is-loading'); message.hidden = true; setScale(1, true, true); if (!reduced.matches) imageAnimation = image.animate([ { opacity: 0, transform: `translateX(${direction * 18}px) scale(.985)` }, { opacity: 1, transform: 'translateX(0) scale(1)' } ], { duration: 280, easing: 'cubic-bezier(.22,1,.36,1)' }); }; loader.onerror = () => { if (requestId !== loadId || !dialog.open) return; dialog.classList.remove('is-loading'); $('.viewer-message > span').textContent = '이미지를 불러오지 못했습니다.'; $('[data-action="retry"]').hidden = false; }; loader.src = item.link.href; } function openGallery(nextGroup, nextIndex, source, addHistory = true) { if (waitingForBack || dialog.open) return; group = nextGroup; opener = source; $('.viewer-brand').textContent = `${group.name} / IMAGE GALLERY`; thumbnails.replaceChildren(); group.items.forEach((item, i) => { const button = document.createElement('button'); button.type = 'button'; button.setAttribute('aria-label', `${i + 1}. ${item.title}`); const thumb = document.createElement('img'); thumb.src = item.link.href; thumb.alt = ''; thumb.loading = 'lazy'; button.append(thumb); button.addEventListener('click', () => showImage(i, Math.sign(i - index))); thumbnails.append(button); }); dialog.querySelectorAll('.viewer-direction').forEach(button => { button.hidden = group.items.length < 2; }); thumbnails.hidden = group.items.length < 2; if (addHistory) { try { history.pushState({ ...(typeof history.state === 'object' ? history.state : {}), [historyKey]: token }, '', location.href); } catch { /* Local file viewers still close with their controls. */ } } lockPage(); dialog.showModal(); showImage(nextIndex); $('[data-action="close"]').focus({ preventScroll: true }); } function closeUI() { if (!dialog.open) return; ++loadId; imageAnimation?.cancel(); dialog.close(); unlockPage(); drag = swipe = null; } function dismiss() { closeUI(); if (history.state?.[historyKey] === token) { waitingForBack = true; history.back(); } } window.addEventListener('popstate', event => { waitingForBack = false; if (event.state?.[historyKey] === token && group && !dialog.open) openGallery(group, index, opener, false); else if (event.state?.[historyKey] !== token) closeUI(); }); dialog.addEventListener('cancel', event => { event.preventDefault(); dismiss(); }); dialog.addEventListener('click', event => { const action = event.target.closest('[data-action]')?.dataset.action; if (action === 'close') dismiss(); if (action === 'previous' && group.items.length > 1) showImage(index - 1, -1); if (action === 'next' && group.items.length > 1) showImage(index + 1, 1); if (action === 'in') setScale(scale * 1.25); if (action === 'out') setScale(scale / 1.25); if (action === 'fit') setScale(1, true, true); if (action === 'actual') setScale(1, false, true); if (action === 'retry') showImage(index); }); dialog.addEventListener('keydown', event => { if (event.key === 'Tab') { const controls = [...dialog.querySelectorAll('button:not(:disabled), [tabindex="0"]')].filter(element => element.getClientRects().length && !element.closest('[hidden]')); const first = controls[0], last = controls[controls.length - 1]; if (event.shiftKey && (document.activeElement === first || document.activeElement === dialog)) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } return; } if (event.ctrlKey || event.metaKey || event.altKey) return; const key = event.key; if (['ArrowLeft', 'ArrowRight', 'Home', 'End', '+', '=', '-', '0', '1'].includes(key)) event.preventDefault(); if (group.items.length > 1) { if (key === 'ArrowLeft') showImage(index - 1, -1); if (key === 'ArrowRight') showImage(index + 1, 1); if (key === 'Home') showImage(0, -1); if (key === 'End') showImage(group.items.length - 1, 1); } if (key === '+' || key === '=') setScale(scale * 1.25); if (key === '-') setScale(scale / 1.25); if (key === '0') setScale(1, true, true); if (key === '1') setScale(1, false, true); }); stage.addEventListener('dblclick', () => setScale(fitted ? Math.max(1, scale * 2) : 1, !fitted, true)); stage.addEventListener('pointerdown', event => { if (event.pointerType !== 'mouse' || event.button !== 0 || !stage.classList.contains('can-pan')) return; drag = { x: event.clientX, y: event.clientY, left: stage.scrollLeft, top: stage.scrollTop }; stage.setPointerCapture(event.pointerId); stage.classList.add('is-dragging'); event.preventDefault(); }); stage.addEventListener('pointermove', event => { if (!drag) return; stage.scrollLeft = drag.left - event.clientX + drag.x; stage.scrollTop = drag.top - event.clientY + drag.y; }); const stopDrag = () => { drag = null; stage.classList.remove('is-dragging'); }; stage.addEventListener('pointerup', stopDrag); stage.addEventListener('pointercancel', stopDrag); stage.addEventListener('lostpointercapture', stopDrag); stage.addEventListener('touchstart', event => { swipe = fitted && event.touches.length === 1 ? { x: event.touches[0].clientX, y: event.touches[0].clientY } : null; }, { passive: true }); stage.addEventListener('touchmove', event => { if (event.touches.length !== 1) swipe = null; }, { passive: true }); stage.addEventListener('touchend', event => { if (!swipe || !event.changedTouches.length) return; const x = event.changedTouches[0].clientX - swipe.x, y = event.changedTouches[0].clientY - swipe.y; swipe = null; if (group.items.length > 1 && Math.abs(x) > 55 && Math.abs(x) > Math.abs(y) * 1.5) showImage(index + (x < 0 ? 1 : -1), x < 0 ? 1 : -1); }, { passive: true }); stage.addEventListener('touchcancel', () => { swipe = null; }, { passive: true }); new ResizeObserver(() => { if (dialog.open && naturalWidth) setScale(scale, fitted); }).observe(stage); reduced.addEventListener('change', () => imageAnimation?.cancel()); groups.forEach(current => current.items.forEach((item, i) => { const open = event => { if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || (event.type === 'click' && event.button !== 0)) return; event.preventDefault(); openGallery(current, i, event.currentTarget); }; item.link.addEventListener('click', open); item.link.setAttribute('aria-haspopup', 'dialog'); if (item.preview) { item.preview.setAttribute('role', 'button'); item.preview.tabIndex = 0; item.preview.setAttribute('aria-label', `${item.title} 전체 화면으로 보기`); item.preview.setAttribute('aria-haspopup', 'dialog'); item.preview.addEventListener('click', open); item.preview.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') open(event); }); const sizePreview = () => item.preview.style.setProperty('--image-natural-width', `${item.image.naturalWidth}px`); if (item.image.complete && item.image.naturalWidth) sizePreview(); item.image.addEventListener('load', sizePreview); } })); })();