/* 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);
}
}));
})();