284 lines
16 KiB
JavaScript
284 lines
16 KiB
JavaScript
/* 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 = `
|
||
<header class="viewer-header">
|
||
<div class="viewer-heading"><p class="viewer-brand"></p><h2 id="image-viewer-title"></h2></div>
|
||
<div class="viewer-controls">
|
||
<div class="viewer-zoom" role="group" aria-label="이미지 확대 및 축소"><button type="button" data-action="out" aria-label="축소">−</button><output class="viewer-scale" aria-label="확대 비율">—</output><button type="button" data-action="in" aria-label="확대">+</button></div>
|
||
<button type="button" class="viewer-size" data-action="actual" aria-label="원본 크기 100%로 보기">1:1</button>
|
||
<button type="button" class="viewer-size" data-action="fit">화면 맞춤</button>
|
||
<button type="button" class="viewer-close" data-action="close" aria-label="이미지 뷰어 닫기" autofocus><span class="viewer-close-icon" aria-hidden="true"></span><span>닫기</span></button>
|
||
</div>
|
||
</header>
|
||
<div class="viewer-body">
|
||
<div class="viewer-stage" tabindex="0" role="region" aria-label="확대한 이미지는 드래그하거나 스크롤하여 이동할 수 있습니다">
|
||
<div class="viewer-image-layout"><img class="viewer-image" alt="" draggable="false"></div>
|
||
</div>
|
||
<div class="viewer-message" role="status"><span></span><button type="button" data-action="retry" hidden>다시 불러오기</button></div>
|
||
<button type="button" class="viewer-direction viewer-previous" data-action="previous" aria-label="이전 이미지"><span aria-hidden="true" class="arrow-slot"><svg class="arrow-icon arrow-icon--left" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><use href="#arrow-right-alt"></use></svg></span></button>
|
||
<button type="button" class="viewer-direction viewer-next" data-action="next" aria-label="다음 이미지"><span aria-hidden="true" class="arrow-slot"><svg class="arrow-icon arrow-icon--right" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><use href="#arrow-right-alt"></use></svg></span></button>
|
||
</div>
|
||
<footer class="viewer-footer">
|
||
<div class="viewer-caption"><span class="viewer-counter" aria-live="polite" aria-atomic="true"></span><p></p><span class="viewer-keyboard-hint" aria-hidden="true">좌우 방향키로 이미지 이동 <i>·</i> ESC 닫기</span></div>
|
||
<div class="viewer-thumbnails" role="group" aria-label="이미지 선택"></div>
|
||
</footer>`;
|
||
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);
|
||
}
|
||
}));
|
||
})();
|