0690도 단위로 각도를 맞추는 회전 스냅 컨트롤
버튼을 누를 때마다 목표 각도가 90도씩 커지고, 현재 각도에서 목표까지 비율에 맞춰 연속적으로 회전하며 지나치는 각도마다 가로세로 비율도 함께 다시 계산된다.
쓰임스캔한 문서 가로 방향 교정
ROTATE_SNAP_DEGboxSizerotateBy

<main class="stage">
<div class="pvr is-demo" id="pvrRoot">
<div class="pvr__frame" id="pvrFrame">
<div class="pvr__page" id="pvrPage"></div>
</div>
<div class="pvr__controls">
<button class="pvr__btn" id="pvrLeft" type="button" aria-label="왼쪽으로 회전">↺</button>
<span class="pvr__deg" id="pvrDeg">0°</span>
<button class="pvr__btn" id="pvrRight" type="button" aria-label="오른쪽으로 회전">↻</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-yellow);
.pvr {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $sp-4;
}
.pvr__frame {
position: relative;
transition: none;
}
.pvr__page {
position: absolute;
left: 50%;
top: 50%;
background: $subject-cream;
border-radius: $r-sm;
box-shadow: $shadow-raised;
padding: $sp-3;
box-sizing: border-box;
will-change: transform;
}
.pvr__title { height: 9px; border-radius: $r-xs; background: rgba(23, 20, 26, .4); margin-bottom: $sp-2; }
.pvr__line { height: 5px; border-radius: $r-xs; background: rgba(23, 20, 26, .18); margin-top: $sp-1; }
.pvr__fig { height: 26%; margin-top: $sp-2; border-radius: $r-sm; background: rgba(47, 109, 246, .22); }
.pvr__controls {
display: flex;
align-items: center;
gap: $sp-3;
}
.pvr__btn {
border: none;
border-radius: $r-pill;
width: 34px;
height: 34px;
font-size: 16px;
background: $subject-ink;
color: #fff;
cursor: pointer;
}
.pvr__deg {
min-width: 40px;
text-align: center;
font-size: 12px;
font-weight: 700;
color: $subject-ink;
}
@media (max-width: 340px) {
.pvr__btn { width: 28px; height: 28px; font-size: 13px; }
}
@media (prefers-reduced-motion: reduce) {
.pvr__page, .pvr__frame { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pvr {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
}
.pvr__frame {
position: relative;
transition: none;
}
.pvr__page {
position: absolute;
left: 50%;
top: 50%;
background: #fff7e6;
border-radius: 8px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 12px;
box-sizing: border-box;
will-change: transform;
}
.pvr__title {
height: 9px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.4);
margin-bottom: 8px;
}
.pvr__line {
height: 5px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.18);
margin-top: 4px;
}
.pvr__fig {
height: 26%;
margin-top: 8px;
border-radius: 8px;
background: rgba(47, 109, 246, 0.22);
}
.pvr__controls {
display: flex;
align-items: center;
gap: 12px;
}
.pvr__btn {
border: none;
border-radius: 999px;
width: 34px;
height: 34px;
font-size: 16px;
background: #17141a;
color: #fff;
cursor: pointer;
}
.pvr__deg {
min-width: 40px;
text-align: center;
font-size: 12px;
font-weight: 700;
color: #17141a;
}
@media (max-width: 340px) {
.pvr__btn {
width: 28px;
height: 28px;
font-size: 13px;
}
}
@media (prefers-reduced-motion: reduce) {
.pvr__page, .pvr__frame {
transition: none;
}
}(function () {
// 기법: 버튼을 누른 횟수 × ROTATE_SNAP_DEG 로 목표 각도를 계산하고, 현재 각도를 매 프레임
// 그 목표로 보간(lerp)한다. 회전 중인 각도로 "회전된 사각형의 바운딩박스" 폭·높이를
// 매 프레임 다시 계산해 프레임 자체 크기를 바꿔가며 다시 그린다 — 90도를 넘길 때
// 가로세로가 뚝 끊겨 바뀌는 게 아니라 각도에 비례해 연속적으로 바뀐다.
var root = document.getElementById('pvrRoot');
var frame = document.getElementById('pvrFrame');
var page = document.getElementById('pvrPage');
var degEl = document.getElementById('pvrDeg');
var btnL = document.getElementById('pvrLeft');
var btnR = document.getElementById('pvrRight');
// 변형 06: ROTATE_SNAP_DEG 90 → 45 (전값이 소스 초기값)
var ROTATE_SNAP_DEG = 90;
var LOOP_MS = 2000;
var PAGE_W = 88, PAGE_H = 70;
function seedRand(seed) { var s = seed >>> 0; return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; }
(function paint() {
var rnd = seedRand(430061);
var html = '<div class="pvr__title" style="width:' + (46 + rnd() * 14) + '%"></div>';
for (var i = 0; i < 5; i += 1) html += '<div class="pvr__line" style="width:' + (38 + rnd() * 54) + '%"></div>';
html += '<div class="pvr__fig"></div>';
page.innerHTML = html;
})();
function boxSize(angleDeg, w, h) {
var r = angleDeg * Math.PI / 180;
var c = Math.abs(Math.cos(r)), s = Math.abs(Math.sin(r));
return { w: w * c + h * s, h: w * s + h * c };
}
var target = 0, current = 0;
function render() {
var box = boxSize(current, PAGE_W, PAGE_H);
frame.style.width = box.w + 'px';
frame.style.height = box.h + 'px';
page.style.width = PAGE_W + 'px';
page.style.height = PAGE_H + 'px';
page.style.transform = 'translate(-50%,-50%) rotate(' + current.toFixed(1) + 'deg)';
degEl.textContent = Math.round(((current % 360) + 360) % 360) + '°';
}
function rotateBy(dir) { target += dir * ROTATE_SNAP_DEG; }
var userActive = false;
function markActive() { if (!userActive) { userActive = true; root.classList.remove('is-demo'); } }
btnL.addEventListener('click', function () { markActive(); rotateBy(-1); });
btnR.addEventListener('click', function () { markActive(); rotateBy(1); });
root.addEventListener('keydown', function (e) {
if (e.key === 'ArrowLeft' || e.key === '[') { e.preventDefault(); markActive(); rotateBy(-1); }
else if (e.key === 'ArrowRight' || e.key === ']') { e.preventDefault(); markActive(); rotateBy(1); }
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { root.classList.remove('is-demo'); userActive = true; current = target = 0; render(); }
var fired = {};
var SCHEDULE = [
{ t: 150, run: function () { rotateBy(1); } },
{ t: 700, run: function () { rotateBy(1); } },
{ t: 1250, run: function () { rotateBy(-1); } },
{ t: 1650, run: function () { rotateBy(-1); } }
];
var lastTs = null, originTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
lastTs = ts;
if (!userActive) {
if (originTs === null) originTs = ts;
var elapsed = ts - originTs;
var loopIdx = Math.floor(elapsed / LOOP_MS);
var phase = elapsed % LOOP_MS;
SCHEDULE.forEach(function (a, i) {
var key = loopIdx + ':' + i;
if (phase >= a.t && !fired[key]) { fired[key] = true; a.run(); }
});
}
current += (target - current) * 0.18;
render();
requestAnimationFrame(tick);
}
render();
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./PdfViewerRotateSnapControl.module.scss";
type KeyEvt = { key: string; preventDefault(): void };
export default function PdfViewerRotateSnapControl() {
const rootRef = useRef<HTMLDivElement>(null);
const frameRef = useRef<HTMLDivElement>(null);
const pageRef = useRef<HTMLDivElement>(null);
const degRef = useRef<HTMLSpanElement>(null);
const btnLRef = useRef<HTMLButtonElement>(null);
const btnRRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const root = rootRef.current, frame = frameRef.current, page = pageRef.current;
const degEl = degRef.current, btnL = btnLRef.current, btnR = btnRRef.current;
if (!root || !frame || !page || !degEl || !btnL || !btnR) return;
// 변형 06: ROTATE_SNAP_DEG 90 → 45 (전값이 소스 초기값)
const ROTATE_SNAP_DEG = 90;
const LOOP_MS = 2000;
const PAGE_W = 88, PAGE_H = 70;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rnd = seedRand(430061);
let html = '<div class="' + styles.title + '" style="width:' + (46 + rnd() * 14) + '%"></div>';
for (let i = 0; i < 5; i += 1) html += '<div class="' + styles.line + '" style="width:' + (38 + rnd() * 54) + '%"></div>';
html += '<div class="' + styles.fig + '"></div>';
page.innerHTML = html;
function boxSize(angleDeg: number, w: number, h: number) {
const r = angleDeg * Math.PI / 180;
const c = Math.abs(Math.cos(r)), s = Math.abs(Math.sin(r));
return { w: w * c + h * s, h: w * s + h * c };
}
let target = 0, current = 0;
function render() {
const box = boxSize(current, PAGE_W, PAGE_H);
frame!.style.width = box.w + "px";
frame!.style.height = box.h + "px";
page!.style.width = PAGE_W + "px";
page!.style.height = PAGE_H + "px";
page!.style.transform = "translate(-50%,-50%) rotate(" + current.toFixed(1) + "deg)";
degEl!.textContent = Math.round(((current % 360) + 360) % 360) + "°";
}
function rotateBy(dir: number) { target += dir * ROTATE_SNAP_DEG; }
let userActive = false;
function markActive() { if (!userActive) { userActive = true; root!.classList.remove("is-demo"); } }
function onLeftClick() { markActive(); rotateBy(-1); }
function onRightClick() { markActive(); rotateBy(1); }
btnL.addEventListener("click", onLeftClick);
btnR.addEventListener("click", onRightClick);
function onKeydown(e: KeyEvt) {
if (e.key === "ArrowLeft" || e.key === "[") { e.preventDefault(); markActive(); rotateBy(-1); }
else if (e.key === "ArrowRight" || e.key === "]") { e.preventDefault(); markActive(); rotateBy(1); }
}
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; current = target = 0; render(); }
const fired: Record<string, boolean> = {};
const SCHEDULE = [
{ t: 150, run: () => { rotateBy(1); } },
{ t: 700, run: () => { rotateBy(1); } },
{ t: 1250, run: () => { rotateBy(-1); } },
{ t: 1650, run: () => { rotateBy(-1); } }
];
let lastTs: number | null = null, originTs: number | null = null, raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
lastTs = ts;
if (!userActive) {
if (originTs === null) originTs = ts;
const elapsed = ts - originTs;
const loopIdx = Math.floor(elapsed / LOOP_MS);
const phase = elapsed % LOOP_MS;
SCHEDULE.forEach((a, i) => {
const key = loopIdx + ":" + i;
if (phase >= a.t && !fired[key]) { fired[key] = true; a.run(); }
});
}
current += (target - current) * 0.18;
render();
raf = requestAnimationFrame(tick);
}
render();
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
btnL.removeEventListener("click", onLeftClick);
btnR.removeEventListener("click", onRightClick);
root.removeEventListener("keydown", onKeydown as unknown as EventListener);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.pvr} is-demo`} ref={rootRef}>
<div className={styles.frame} ref={frameRef}>
<div className={styles.page} ref={pageRef} />
</div>
<div className={styles.controls}>
<button className={styles.btn} ref={btnLRef} type="button" aria-label="왼쪽으로 회전">↺</button>
<span className={styles.deg} ref={degRef}>0°</span>
<button className={styles.btn} ref={btnRRef} type="button" aria-label="오른쪽으로 회전">↻</button>
</div>
</div>
</main>
);
}06 90도 단위로 각도를 맞추는 회전 스냅 컨트롤 — 측정 누적면적 11.449% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · pdf 미리보기 구현 9가지 — 스크롤로 페이지를 계산하는 pdf 뷰어 ui
이 데모가 속한 글
pdf 미리보기 구현 9가지 — 스크롤로 페이지를 계산하는 pdf 뷰어 ui스크롤 값이 페이지 번호를 만드는 걸까, 페이지 번호가 스크롤 값을 만드는 걸까 — 02번 확대 중심은 포인터 좌표로, 04번 다음 페이지는 손을 뗄 때의 속도로, 08번 다운로드 링은 받은 바이트 수로 답한다. p 이 글의 데모 전부 보기


