09여러 계산을 한 툴바에 모은 문서 뷰어 히어로 장면
01 썸네일 동기화, 02 포인터 초점 줌, 08 다운로드 진행 링을 한 툴바 위에 동시에 배치해 문서를 훑고 확대하고 내려받는 완성 장면을 만든다.
쓰임문서 협업 툴 임베드 위젯
HERO_ZOOM_STEPHERO_RAIL_LERPHERO_TOTAL_BYTES

<main class="stage">
<div class="pht is-demo" id="phtRoot" tabindex="0" aria-label="문서 훑기·확대·다운로드가 함께 있는 장면, 방향키로 페이지 이동·+/- 로 확대">
<div class="pht__toolbar">
<div class="pht__rail" id="phtRail">
<div class="pht__railTrack" id="phtRailTrack"></div>
</div>
<button class="pht__ringBtn" id="phtRingBtn" type="button" aria-label="다운로드">
<svg class="pht__ring" viewBox="0 0 40 40" width="26" height="26" aria-hidden="true">
<circle class="pht__ringTrack" cx="20" cy="20" r="15"></circle>
<circle class="pht__ringFill" id="phtRingFill" cx="20" cy="20" r="15" transform="rotate(-90 20 20)"></circle>
<line class="pht__ringCheck" id="phtCheckA" x1="12" y1="21" x2="17" y2="27"></line>
<line class="pht__ringCheck" id="phtCheckB" x1="12" y1="21" x2="28" y2="12"></line>
</svg>
</button>
</div>
<div class="pht__stage" id="phtStage">
<div class="pht__doc" id="phtDoc">
<div class="pht__docInner" id="phtDocInner"></div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.pht {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
display: flex;
flex-direction: column;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-cream;
outline: none;
}
.pht__toolbar {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2;
background: $subject-ink;
}
.pht__rail {
flex: 1 1 auto;
overflow: hidden;
min-width: 0;
}
.pht__railTrack {
display: flex;
gap: $sp-2;
will-change: transform;
}
.pht__thumb {
flex: 0 0 auto;
width: 40px;
height: 52px;
border: 2px solid transparent;
border-radius: $r-sm;
background: $subject-cream;
padding: 4px;
box-sizing: border-box;
cursor: pointer;
&--active { border-color: $subject-blue-light; }
}
.pht__miniTitle { height: 4px; border-radius: $r-xs; background: rgba(23, 20, 26, .4); margin-bottom: 4px; }
.pht__miniLine { height: 2px; border-radius: $r-xs; background: rgba(23, 20, 26, .22); margin-top: 4px; }
.pht__miniFig { height: 28%; margin-top: 4px; border-radius: $r-xs; background: rgba(47, 109, 246, .22); }
.pht__ringBtn {
flex: 0 0 auto;
border: none;
border-radius: $r-pill;
width: 34px;
height: 34px;
display: grid;
place-items: center;
background: rgba(255, 255, 255, .12);
cursor: pointer;
}
.pht__ring { display: block; }
.pht__ringTrack { fill: none; stroke: rgba(255, 255, 255, .25); stroke-width: 4; }
.pht__ringFill { fill: none; stroke: $subject-blue-light; stroke-width: 4; stroke-linecap: round; transition: opacity $dur-base ease; }
.pht__ringCheck { stroke: #fff; stroke-width: 4; stroke-linecap: round; fill: none; opacity: 0; transition: opacity $dur-base ease; }
.pht--done .pht__ringFill { opacity: 0; }
.pht--done .pht__ringCheck { opacity: 1; }
.pht__stage {
flex: 1 1 auto;
position: relative;
overflow: hidden;
}
.pht__doc {
position: absolute;
inset: 0;
overflow-y: auto;
padding: $sp-3;
box-sizing: border-box;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
.pht__docInner { will-change: transform; }
.pht__page {
background: #fff;
border-radius: $r-sm;
padding: $sp-3;
margin-bottom: $sp-3;
box-shadow: 0 2px 6px rgba(23, 20, 26, .1);
min-height: 120px;
box-sizing: border-box;
}
.pht__docTitle { height: 8px; border-radius: $r-xs; background: rgba(23, 20, 26, .4); margin-bottom: $sp-2; }
.pht__docLine { height: 5px; border-radius: $r-xs; background: rgba(23, 20, 26, .18); margin-top: $sp-1; }
.pht__docFig { height: 30%; margin-top: $sp-2; border-radius: $r-sm; background: rgba(47, 109, 246, .22); }
@media (max-width: 340px) {
.pht__thumb { width: 32px; height: 42px; }
}
@media (prefers-reduced-motion: reduce) {
.pht__docInner, .pht__ringFill, .pht__ringCheck { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pht {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
display: flex;
flex-direction: column;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
outline: none;
}
.pht__toolbar {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
background: #17141a;
}
.pht__rail {
flex: 1 1 auto;
overflow: hidden;
min-width: 0;
}
.pht__railTrack {
display: flex;
gap: 8px;
will-change: transform;
}
.pht__thumb {
flex: 0 0 auto;
width: 40px;
height: 52px;
border: 2px solid transparent;
border-radius: 8px;
background: #fff7e6;
padding: 4px;
box-sizing: border-box;
cursor: pointer;
}
.pht__thumb--active {
border-color: #5b8cf8;
}
.pht__miniTitle {
height: 4px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.4);
margin-bottom: 4px;
}
.pht__miniLine {
height: 2px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.22);
margin-top: 4px;
}
.pht__miniFig {
height: 28%;
margin-top: 4px;
border-radius: 4px;
background: rgba(47, 109, 246, 0.22);
}
.pht__ringBtn {
flex: 0 0 auto;
border: none;
border-radius: 999px;
width: 34px;
height: 34px;
display: grid;
place-items: center;
background: rgba(255, 255, 255, 0.12);
cursor: pointer;
}
.pht__ring {
display: block;
}
.pht__ringTrack {
fill: none;
stroke: rgba(255, 255, 255, 0.25);
stroke-width: 4;
}
.pht__ringFill {
fill: none;
stroke: #5b8cf8;
stroke-width: 4;
stroke-linecap: round;
transition: opacity 300ms ease;
}
.pht__ringCheck {
stroke: #fff;
stroke-width: 4;
stroke-linecap: round;
fill: none;
opacity: 0;
transition: opacity 300ms ease;
}
.pht--done .pht__ringFill {
opacity: 0;
}
.pht--done .pht__ringCheck {
opacity: 1;
}
.pht__stage {
flex: 1 1 auto;
position: relative;
overflow: hidden;
}
.pht__doc {
position: absolute;
inset: 0;
overflow-y: auto;
padding: 12px;
box-sizing: border-box;
scrollbar-width: none;
}
.pht__doc::-webkit-scrollbar {
display: none;
}
.pht__docInner {
will-change: transform;
}
.pht__page {
background: #fff;
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
box-shadow: 0 2px 6px rgba(23, 20, 26, 0.1);
min-height: 120px;
box-sizing: border-box;
}
.pht__docTitle {
height: 8px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.4);
margin-bottom: 8px;
}
.pht__docLine {
height: 5px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.18);
margin-top: 4px;
}
.pht__docFig {
height: 30%;
margin-top: 8px;
border-radius: 8px;
background: rgba(47, 109, 246, 0.22);
}
@media (max-width: 340px) {
.pht__thumb {
width: 32px;
height: 42px;
}
}
@media (prefers-reduced-motion: reduce) {
.pht__docInner, .pht__ringFill, .pht__ringCheck {
transition: none;
}
}(function () {
// 09 = 01(썸네일 레일 동기)+02(포인터 초점 줌)+08(다운로드 링) 결합. 각 장면의 계산을
// 그대로 가져와 한 무대에 얹는다: 스크롤 겹침 비율로 레일을 맞추고, 포인터 좌표비로
// 확대 원점을 계산하고, 받은 바이트 비율로 링을 채운다.
var root = document.getElementById('phtRoot');
var rail = document.getElementById('phtRail');
var railTrack = document.getElementById('phtRailTrack');
var stage = document.getElementById('phtStage');
var doc = document.getElementById('phtDoc');
var docInner = document.getElementById('phtDocInner');
var ringBtn = document.getElementById('phtRingBtn');
var ringFill = document.getElementById('phtRingFill');
var checkA = document.getElementById('phtCheckA');
var checkB = document.getElementById('phtCheckB');
var HERO_ZOOM_STEP = 1.2;
var HERO_RAIL_LERP = 0.18;
var HERO_TOTAL_BYTES = 200000;
var HERO_SPEED = 230000;
var LOOP_MS = 2000;
var N = 4;
function seedRand(seed) { var s = seed >>> 0; return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; }
var rnd = seedRand(430091);
function pageHTML(prefix) {
var h = '<div class="' + prefix + 'Title" style="width:' + (46 + rnd() * 14) + '%"></div>';
for (var i = 0; i < 4; i += 1) h += '<div class="' + prefix + 'Line" style="width:' + (36 + rnd() * 56) + '%"></div>';
if (rnd() > 0.4) h += '<div class="' + prefix + 'Fig"></div>';
return h;
}
for (var p = 0; p < N; p += 1) {
var page = document.createElement('div');
page.className = 'pht__page';
page.innerHTML = pageHTML('pht__doc');
docInner.appendChild(page);
var thumb = document.createElement('button');
thumb.type = 'button';
thumb.className = 'pht__thumb';
thumb.setAttribute('aria-label', '페이지 ' + (p + 1) + '로 이동');
thumb.innerHTML = pageHTML('pht__mini');
railTrack.appendChild(thumb);
}
var pages = Array.prototype.slice.call(docInner.children);
var thumbs = Array.prototype.slice.call(railTrack.children);
function computeCurrent() {
var top = doc.scrollTop, h = doc.clientHeight, best = 0, bestRatio = -1;
pages.forEach(function (pg, i) {
var pTop = pg.offsetTop, pBot = pTop + pg.offsetHeight;
var visTop = Math.max(top, pTop), visBot = Math.min(top + h, pBot);
var ratio = Math.max(0, visBot - visTop) / pg.offsetHeight;
if (ratio > bestRatio) { bestRatio = ratio; best = i; }
});
return best;
}
var activeIdx = -1;
function markRail(idx) {
if (idx === activeIdx) return;
activeIdx = idx;
thumbs.forEach(function (t, i) { t.classList.toggle('pht__thumb--active', i === idx); });
}
var railX = 0;
function railTarget(idx) {
var t = thumbs[idx]; if (!t) return 0;
var want = t.offsetLeft + t.offsetWidth / 2 - rail.clientWidth / 2;
return -Math.max(0, Math.min(railTrack.scrollWidth - rail.clientWidth, want));
}
var zoomManual = { s: 1, x: .5, y: .5 };
function applyZoom(scale, ox, oy) {
docInner.style.transformOrigin = (ox * 100) + '% ' + (oy * 100) + '%';
docInner.style.transform = 'scale(' + scale.toFixed(3) + ')';
}
var phase = 'idle', received = 0, doneAt = 0, checkAnim = null;
var R = 15, CIRC = 2 * Math.PI * R;
ringFill.style.strokeDasharray = CIRC.toFixed(2);
function lineLen(el) { var x1 = +el.getAttribute('x1'), y1 = +el.getAttribute('y1'), x2 = +el.getAttribute('x2'), y2 = +el.getAttribute('y2'); return Math.hypot(x2 - x1, y2 - y1); }
var lenA = lineLen(checkA), lenB = lineLen(checkB);
checkA.style.strokeDasharray = lenA.toFixed(2); checkB.style.strokeDasharray = lenB.toFixed(2);
checkA.style.strokeDashoffset = lenA.toFixed(2); checkB.style.strokeDashoffset = lenB.toFixed(2);
function renderRing(ratio) { ringFill.style.strokeDashoffset = (CIRC * (1 - ratio)).toFixed(2); }
function startDownload() {
if (phase === 'loading') return;
phase = 'loading'; received = 0;
root.classList.remove('pht--done');
checkA.style.strokeDashoffset = lenA.toFixed(2); checkB.style.strokeDashoffset = lenB.toFixed(2);
renderRing(0);
}
function finishDownload(nowT) { phase = 'done'; doneAt = nowT; root.classList.add('pht--done'); checkAnim = { start: nowT }; }
function resetDownload() { phase = 'idle'; received = 0; root.classList.remove('pht--done'); renderRing(0); }
var userActive = false;
function markActive() { if (!userActive) { userActive = true; root.classList.remove('is-demo'); } }
doc.addEventListener('scroll', function () { markRail(computeCurrent()); });
stage.addEventListener('wheel', function (e) {
e.preventDefault(); markActive();
var r = stage.getBoundingClientRect();
var ox = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
var oy = Math.max(0, Math.min(1, (e.clientY - r.top) / r.height));
zoomManual.s = Math.max(1, Math.min(HERO_ZOOM_STEP * HERO_ZOOM_STEP * 1.4, zoomManual.s * Math.pow(HERO_ZOOM_STEP, (e.deltaY < 0 ? 1 : -1) * 0.6)));
zoomManual.x = ox; zoomManual.y = oy;
applyZoom(zoomManual.s, ox, oy);
}, { passive: false });
thumbs.forEach(function (t, i) {
t.addEventListener('click', function () {
markActive(); markRail(i);
doc.scrollTop = pages[i].offsetTop;
});
});
ringBtn.addEventListener('click', function () { markActive(); if (phase !== 'loading') { resetDownload(); startDownload(); } });
root.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault(); markActive();
var next = Math.max(0, Math.min(N - 1, activeIdx + (e.key === 'ArrowDown' ? 1 : -1)));
doc.scrollTop = pages[next].offsetTop;
} else if (e.key === '+' || e.key === '=' || e.key === '-') {
e.preventDefault(); markActive();
zoomManual.s = Math.max(1, Math.min(HERO_ZOOM_STEP * HERO_ZOOM_STEP * 1.4, zoomManual.s * Math.pow(HERO_ZOOM_STEP, e.key === '-' ? -0.6 : 0.6)));
applyZoom(zoomManual.s, zoomManual.x, zoomManual.y);
}
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { root.classList.remove('is-demo'); userActive = true; markRail(0); applyZoom(1, .5, .5); renderRing(0.4); }
var fired = {};
var autoScale = { s: 1 };
var SCHEDULE = [
{ t: 0, run: function () { doc.scrollTop = 0; } },
{ t: 200, run: function () { doc.scrollTop = pages[1] ? pages[1].offsetTop : 0; } },
{ t: 550, run: function () { autoScale.target = HERO_ZOOM_STEP; autoScale.ox = .3; autoScale.oy = .4; } },
{ t: 900, run: function () { autoScale.target = 1; } },
{ t: 1050, run: function () { doc.scrollTop = pages[2] ? pages[2].offsetTop : 0; } },
{ t: 1300, run: function () { if (phase === 'idle') startDownload(); } },
{ t: 1750, run: function () { doc.scrollTop = pages[3] ? pages[3].offsetTop : 0; } }
];
var lastTs = null, originTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!userActive) {
if (originTs === null) originTs = ts;
var elapsed = ts - originTs;
var loopIdx = Math.floor(elapsed / LOOP_MS);
var phaseT = elapsed % LOOP_MS;
SCHEDULE.forEach(function (a, i) {
var key = loopIdx + ':' + i;
if (phaseT >= a.t && !fired[key]) { fired[key] = true; a.run(); }
});
if (autoScale.target !== undefined) {
zoomManual.s += (autoScale.target - zoomManual.s) * 0.14;
applyZoom(zoomManual.s, autoScale.ox || .5, autoScale.oy || .5);
}
markRail(computeCurrent());
}
railX += (railTarget(activeIdx < 0 ? 0 : activeIdx) - railX) * HERO_RAIL_LERP;
railTrack.style.transform = 'translateX(' + railX.toFixed(1) + 'px)';
if (phase === 'loading') {
received = Math.min(HERO_TOTAL_BYTES, received + HERO_SPEED * (dt / 1000));
var ratio = received / HERO_TOTAL_BYTES;
renderRing(ratio);
if (ratio >= 1) finishDownload(ts);
} else if (phase === 'done') {
if (checkAnim) {
var pA = Math.max(0, Math.min(1, (ts - checkAnim.start) / 180));
var pB = Math.max(0, Math.min(1, (ts - checkAnim.start - 110) / 220));
checkA.style.strokeDashoffset = (lenA * (1 - pA)).toFixed(2);
checkB.style.strokeDashoffset = (lenB * (1 - pB)).toFixed(2);
if (pB >= 1) checkAnim = null;
}
if (ts - doneAt > 560 && !userActive) resetDownload();
}
requestAnimationFrame(tick);
}
markRail(0);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./PdfViewerHeroToolbarScene.module.scss";
type KeyEvt = { key: string; preventDefault(): void };
type WheelEvt = { deltaY: number; clientX: number; clientY: number; preventDefault(): void };
export default function PdfViewerHeroToolbarScene() {
const rootRef = useRef<HTMLDivElement>(null);
const railRef = useRef<HTMLDivElement>(null);
const railTrackRef = useRef<HTMLDivElement>(null);
const stageRef = useRef<HTMLDivElement>(null);
const docRef = useRef<HTMLDivElement>(null);
const docInnerRef = useRef<HTMLDivElement>(null);
const ringBtnRef = useRef<HTMLButtonElement>(null);
const ringFillRef = useRef<SVGCircleElement>(null);
const checkARef = useRef<SVGLineElement>(null);
const checkBRef = useRef<SVGLineElement>(null);
useEffect(() => {
const root = rootRef.current, rail = railRef.current, railTrack = railTrackRef.current;
const stage = stageRef.current, doc = docRef.current, docInner = docInnerRef.current;
const ringBtn = ringBtnRef.current, ringFill = ringFillRef.current;
const checkA = checkARef.current, checkB = checkBRef.current;
if (!root || !rail || !railTrack || !stage || !doc || !docInner || !ringBtn || !ringFill || !checkA || !checkB) return;
const HERO_ZOOM_STEP = 1.2;
const HERO_RAIL_LERP = 0.18;
const HERO_TOTAL_BYTES = 200000;
const HERO_SPEED = 230000;
const LOOP_MS = 2000;
const N = 4;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rnd = seedRand(430091);
function pageHTML(titleCls: string, lineCls: string, figCls: string) {
let h = '<div class="' + titleCls + '" style="width:' + (46 + rnd() * 14) + '%"></div>';
for (let i = 0; i < 4; i += 1) h += '<div class="' + lineCls + '" style="width:' + (36 + rnd() * 56) + '%"></div>';
if (rnd() > 0.4) h += '<div class="' + figCls + '"></div>';
return h;
}
for (let p = 0; p < N; p += 1) {
const page = document.createElement("div");
page.className = styles.page;
page.innerHTML = pageHTML(styles.docTitle, styles.docLine, styles.docFig);
docInner.appendChild(page);
const thumb = document.createElement("button");
thumb.type = "button";
thumb.className = styles.thumb;
thumb.setAttribute("aria-label", "페이지 " + (p + 1) + "로 이동");
thumb.innerHTML = pageHTML(styles.miniTitle, styles.miniLine, styles.miniFig);
railTrack.appendChild(thumb);
}
const pages = Array.prototype.slice.call(docInner.children) as HTMLElement[];
const thumbs = Array.prototype.slice.call(railTrack.children) as HTMLElement[];
function computeCurrent() {
const top = doc!.scrollTop, h = doc!.clientHeight;
let best = 0, bestRatio = -1;
pages.forEach((pg, i) => {
const pTop = pg.offsetTop, pBot = pTop + pg.offsetHeight;
const visTop = Math.max(top, pTop), visBot = Math.min(top + h, pBot);
const ratio = Math.max(0, visBot - visTop) / pg.offsetHeight;
if (ratio > bestRatio) { bestRatio = ratio; best = i; }
});
return best;
}
let activeIdx = -1;
function markRail(idx: number) {
if (idx === activeIdx) return;
activeIdx = idx;
thumbs.forEach((t, i) => { t.classList.toggle(styles.thumbActive, i === idx); });
}
let railX = 0;
function railTarget(idx: number) {
const t = thumbs[idx]; if (!t) return 0;
const want = t.offsetLeft + t.offsetWidth / 2 - rail!.clientWidth / 2;
return -Math.max(0, Math.min(railTrack!.scrollWidth - rail!.clientWidth, want));
}
const zoomManual = { s: 1, x: .5, y: .5 };
function applyZoom(scale: number, ox: number, oy: number) {
docInner!.style.transformOrigin = (ox * 100) + "% " + (oy * 100) + "%";
docInner!.style.transform = "scale(" + scale.toFixed(3) + ")";
}
let phase: "idle" | "loading" | "done" = "idle", received = 0, doneAt = 0;
let checkAnim: { start: number } | null = null;
const R = 15, CIRC = 2 * Math.PI * R;
ringFill.style.strokeDasharray = CIRC.toFixed(2);
function lineLen(el: SVGLineElement) {
const x1 = +el.getAttribute("x1")!, y1 = +el.getAttribute("y1")!;
const x2 = +el.getAttribute("x2")!, y2 = +el.getAttribute("y2")!;
return Math.hypot(x2 - x1, y2 - y1);
}
const lenA = lineLen(checkA), lenB = lineLen(checkB);
checkA.style.strokeDasharray = lenA.toFixed(2); checkB.style.strokeDasharray = lenB.toFixed(2);
checkA.style.strokeDashoffset = lenA.toFixed(2); checkB.style.strokeDashoffset = lenB.toFixed(2);
function renderRing(ratio: number) { ringFill!.style.strokeDashoffset = (CIRC * (1 - ratio)).toFixed(2); }
function startDownload() {
if (phase === "loading") return;
phase = "loading"; received = 0;
root!.classList.remove(styles.done);
checkA!.style.strokeDashoffset = lenA.toFixed(2); checkB!.style.strokeDashoffset = lenB.toFixed(2);
renderRing(0);
}
function finishDownload(nowT: number) { phase = "done"; doneAt = nowT; root!.classList.add(styles.done); checkAnim = { start: nowT }; }
function resetDownload() { phase = "idle"; received = 0; root!.classList.remove(styles.done); renderRing(0); }
let userActive = false;
function markActive() { if (!userActive) { userActive = true; root!.classList.remove("is-demo"); } }
function onScroll() { markRail(computeCurrent()); }
function onWheel(e: WheelEvt) {
e.preventDefault(); markActive();
const r = stage!.getBoundingClientRect();
const ox = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
const oy = Math.max(0, Math.min(1, (e.clientY - r.top) / r.height));
zoomManual.s = Math.max(1, Math.min(HERO_ZOOM_STEP * HERO_ZOOM_STEP * 1.4, zoomManual.s * Math.pow(HERO_ZOOM_STEP, (e.deltaY < 0 ? 1 : -1) * 0.6)));
zoomManual.x = ox; zoomManual.y = oy;
applyZoom(zoomManual.s, ox, oy);
}
doc.addEventListener("scroll", onScroll);
stage.addEventListener("wheel", onWheel as unknown as EventListener, { passive: false });
const thumbHandlers: Array<() => void> = [];
thumbs.forEach((t, i) => {
const h = () => { markActive(); markRail(i); doc!.scrollTop = pages[i].offsetTop; };
thumbHandlers.push(h);
t.addEventListener("click", h);
});
function onRingClick() { markActive(); if (phase !== "loading") { resetDownload(); startDownload(); } }
ringBtn.addEventListener("click", onRingClick);
function onKeydown(e: KeyEvt) {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault(); markActive();
const next = Math.max(0, Math.min(N - 1, activeIdx + (e.key === "ArrowDown" ? 1 : -1)));
doc!.scrollTop = pages[next].offsetTop;
} else if (e.key === "+" || e.key === "=" || e.key === "-") {
e.preventDefault(); markActive();
zoomManual.s = Math.max(1, Math.min(HERO_ZOOM_STEP * HERO_ZOOM_STEP * 1.4, zoomManual.s * Math.pow(HERO_ZOOM_STEP, e.key === "-" ? -0.6 : 0.6)));
applyZoom(zoomManual.s, zoomManual.x, zoomManual.y);
}
}
root.addEventListener("keydown", onKeydown as unknown as EventListener);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) { root.classList.remove("is-demo"); userActive = true; markRail(0); applyZoom(1, .5, .5); renderRing(0.4); }
const fired: Record<string, boolean> = {};
const autoScale: { target?: number; ox?: number; oy?: number } = {};
const SCHEDULE = [
{ t: 0, run: () => { doc!.scrollTop = 0; } },
{ t: 200, run: () => { doc!.scrollTop = pages[1] ? pages[1].offsetTop : 0; } },
{ t: 550, run: () => { autoScale.target = HERO_ZOOM_STEP; autoScale.ox = .3; autoScale.oy = .4; } },
{ t: 900, run: () => { autoScale.target = 1; } },
{ t: 1050, run: () => { doc!.scrollTop = pages[2] ? pages[2].offsetTop : 0; } },
{ t: 1300, run: () => { if (phase === "idle") startDownload(); } },
{ t: 1750, run: () => { doc!.scrollTop = pages[3] ? pages[3].offsetTop : 0; } }
];
let lastTs: number | null = null, originTs: number | null = null, raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!userActive) {
if (originTs === null) originTs = ts;
const elapsed = ts - originTs;
const loopIdx = Math.floor(elapsed / LOOP_MS);
const phaseT = elapsed % LOOP_MS;
SCHEDULE.forEach((a, i) => {
const key = loopIdx + ":" + i;
if (phaseT >= a.t && !fired[key]) { fired[key] = true; a.run(); }
});
if (autoScale.target !== undefined) {
zoomManual.s += (autoScale.target - zoomManual.s) * 0.14;
applyZoom(zoomManual.s, autoScale.ox || .5, autoScale.oy || .5);
}
markRail(computeCurrent());
}
railX += (railTarget(activeIdx < 0 ? 0 : activeIdx) - railX) * HERO_RAIL_LERP;
railTrack!.style.transform = "translateX(" + railX.toFixed(1) + "px)";
if (phase === "loading") {
received = Math.min(HERO_TOTAL_BYTES, received + HERO_SPEED * (dt / 1000));
const ratio = received / HERO_TOTAL_BYTES;
renderRing(ratio);
if (ratio >= 1) finishDownload(ts);
} else if (phase === "done") {
if (checkAnim) {
const pA = Math.max(0, Math.min(1, (ts - checkAnim.start) / 180));
const pB = Math.max(0, Math.min(1, (ts - checkAnim.start - 110) / 220));
checkA!.style.strokeDashoffset = (lenA * (1 - pA)).toFixed(2);
checkB!.style.strokeDashoffset = (lenB * (1 - pB)).toFixed(2);
if (pB >= 1) checkAnim = null;
}
if (ts - doneAt > 560 && !userActive) resetDownload();
}
raf = requestAnimationFrame(tick);
}
markRail(0);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
doc.removeEventListener("scroll", onScroll);
stage.removeEventListener("wheel", onWheel as unknown as EventListener);
thumbs.forEach((t, i) => t.removeEventListener("click", thumbHandlers[i]));
ringBtn.removeEventListener("click", onRingClick);
root.removeEventListener("keydown", onKeydown as unknown as EventListener);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.pht} is-demo`} ref={rootRef} tabIndex={0} aria-label="문서 훑기·확대·다운로드가 함께 있는 장면, 방향키로 페이지 이동·+/- 로 확대">
<div className={styles.toolbar}>
<div className={styles.rail} ref={railRef}>
<div className={styles.railTrack} ref={railTrackRef} />
</div>
<button className={styles.ringBtn} ref={ringBtnRef} type="button" aria-label="다운로드">
<svg className={styles.ring} viewBox="0 0 40 40" width={26} height={26} aria-hidden="true">
<circle className={styles.ringTrack} cx={20} cy={20} r={15} />
<circle className={styles.ringFill} ref={ringFillRef} cx={20} cy={20} r={15} transform="rotate(-90 20 20)" />
<line className={styles.ringCheck} ref={checkARef} x1={12} y1={21} x2={17} y2={27} />
<line className={styles.ringCheck} ref={checkBRef} x1={12} y1={21} x2={28} y2={12} />
</svg>
</button>
</div>
<div className={styles.stageArea} ref={stageRef}>
<div className={styles.doc} ref={docRef}>
<div className={styles.docInner} ref={docInnerRef} />
</div>
</div>
</div>
</main>
);
}09 여러 계산을 한 툴바에 모은 문서 뷰어 히어로 장면 — 측정 누적면적 40.053% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · pdf 미리보기 구현 9가지 — 스크롤로 페이지를 계산하는 pdf 뷰어 ui
이 데모가 속한 글
pdf 미리보기 구현 9가지 — 스크롤로 페이지를 계산하는 pdf 뷰어 ui스크롤 값이 페이지 번호를 만드는 걸까, 페이지 번호가 스크롤 값을 만드는 걸까 — 02번 확대 중심은 포인터 좌표로, 04번 다음 페이지는 손을 뗄 때의 속도로, 08번 다운로드 링은 받은 바이트 수로 답한다. p 이 글의 데모 전부 보기


