Files
tront.noname.company/image-viewer.js

284 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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);
}
}));
})();