03일치한 검색어 위치를 계산해 점프하는 검색 하이라이트
입력한 검색어와 일치하는 글자의 페이지 번호와 세로 위치를 계산해 목록으로 모으고, 하나를 고르면 그 위치로 스크롤이 이동하며 글자 배경이 밝아진다.
쓰임계약서 조항 번호 찾기
buildPagespageOfjumpTo

<main class="stage">
<div class="pvs is-demo" id="pvsRoot">
<div class="pvs__bar">
<input class="pvs__input" id="pvsInput" type="text" placeholder="검색" aria-label="문서 안 검색" autocomplete="off">
<span class="pvs__count" id="pvsCount">0개 일치</span>
</div>
<div class="pvs__body">
<div class="pvs__doc" id="pvsDoc"></div>
<div class="pvs__results" id="pvsResults"></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
.pvs {
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;
}
.pvs__bar {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2 $sp-3;
background: $stage-ink;
}
.pvs__input {
flex: 1 1 auto;
min-width: 0;
border: none;
border-radius: $r-pill;
padding: $sp-1 $sp-3;
font-size: 12px;
background: rgba(255, 255, 255, .92);
color: $subject-ink;
}
.pvs__count {
flex: 0 0 auto;
color: #fff;
font-size: 10px;
opacity: .8;
}
.pvs__body {
flex: 1 1 auto;
display: flex;
min-height: 0;
}
.pvs__doc {
flex: 1 1 auto;
overflow-y: auto;
padding: $sp-2 $sp-3;
box-sizing: border-box;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
.doc__page {
background: #fff;
border-radius: $r-sm;
padding: $sp-2;
margin-bottom: $sp-2;
box-shadow: 0 2px 6px rgba(23, 20, 26, .1);
}
.doc__title { height: 7px; border-radius: $r-xs; background: rgba(23, 20, 26, .32); margin-bottom: $sp-1; }
.pvs__lineText {
margin: $sp-1 0 0;
font-size: 9px;
line-height: 1.6;
color: rgba(23, 20, 26, .55);
}
.pvs__w {
border-radius: $r-xs;
padding: 0 $sp-1;
transition: background-color $dur-base ease, color $dur-base ease;
&--hit { background: $subject-blue-light; color: $subject-ink; font-weight: 700; }
}
.pvs__results {
flex: 0 0 auto;
width: 27%;
border-left: 1px solid rgba(23, 20, 26, .12);
overflow-y: auto;
padding: $sp-1;
display: flex;
flex-direction: column;
gap: $sp-1;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
.pvs__result {
border: none;
border-radius: $r-sm;
padding: $sp-1 $sp-2;
font-size: 9px;
text-align: left;
background: rgba(47, 109, 246, .12);
color: $subject-ink;
cursor: pointer;
}
@media (max-width: 340px) {
.pvs__lineText { font-size: 8px; }
.pvs__result { font-size: 8px; }
}
@media (prefers-reduced-motion: reduce) {
.pvs__w { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pvs {
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;
}
.pvs__bar {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #17141a;
}
.pvs__input {
flex: 1 1 auto;
min-width: 0;
border: none;
border-radius: 999px;
padding: 4px 12px;
font-size: 12px;
background: rgba(255, 255, 255, 0.92);
color: #17141a;
}
.pvs__count {
flex: 0 0 auto;
color: #fff;
font-size: 10px;
opacity: 0.8;
}
.pvs__body {
flex: 1 1 auto;
display: flex;
min-height: 0;
}
.pvs__doc {
flex: 1 1 auto;
overflow-y: auto;
padding: 8px 12px;
box-sizing: border-box;
scrollbar-width: none;
}
.pvs__doc::-webkit-scrollbar {
display: none;
}
.doc__page {
background: #fff;
border-radius: 8px;
padding: 8px;
margin-bottom: 8px;
box-shadow: 0 2px 6px rgba(23, 20, 26, 0.1);
}
.doc__title {
height: 7px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.32);
margin-bottom: 4px;
}
.pvs__lineText {
margin: 4px 0 0;
font-size: 9px;
line-height: 1.6;
color: rgba(23, 20, 26, 0.55);
}
.pvs__w {
border-radius: 4px;
padding: 0 4px;
transition: background-color 300ms ease, color 300ms ease;
}
.pvs__w--hit {
background: #5b8cf8;
color: #17141a;
font-weight: 700;
}
.pvs__results {
flex: 0 0 auto;
width: 27%;
border-left: 1px solid rgba(23, 20, 26, 0.12);
overflow-y: auto;
padding: 4px;
display: flex;
flex-direction: column;
gap: 4px;
scrollbar-width: none;
}
.pvs__results::-webkit-scrollbar {
display: none;
}
.pvs__result {
border: none;
border-radius: 8px;
padding: 4px 8px;
font-size: 9px;
text-align: left;
background: rgba(47, 109, 246, 0.12);
color: #17141a;
cursor: pointer;
}
@media (max-width: 340px) {
.pvs__lineText {
font-size: 8px;
}
.pvs__result {
font-size: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.pvs__w {
transition: none;
}
}(function () {
// 기법: 실제 텍스트 span 을 문서에 심어 두고, 검색어와 일치하는 span 을 순회로 찾아
// 페이지 번호(data-page)와 세로 위치(offsetTop)를 계산해 목록으로 모은다. 목록에서 하나를
// 고르면 그 위치까지 남은 거리를 이지(ease-out)로 나눠 스크롤하고, 도착 뒤 배경색을
// transition 으로 밝혔다 되돌린다.
var root = document.getElementById('pvsRoot');
var input = document.getElementById('pvsInput');
var count = document.getElementById('pvsCount');
var doc = document.getElementById('pvsDoc');
var resultsBox = document.getElementById('pvsResults');
var LOOP_MS = 2000;
var VOCAB = ['alpha', 'beta', 'gamma', 'delta', 'omega', 'sigma', 'kappa', 'lambda', 'theta', 'zeta'];
function seedRand(seed) { var s = seed >>> 0; return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; }
function buildPages() {
var rnd = seedRand(430031), pages = [];
for (var p = 0; p < 4; p += 1) {
var lines = [];
for (var l = 0; l < 5; l += 1) {
var words = [];
for (var w = 0; w < 5; w += 1) words.push(VOCAB[Math.floor(rnd() * VOCAB.length)]);
lines.push(words);
}
pages.push(lines);
}
pages[1][2][0] = 'alpha';
pages[3][1][2] = 'alpha';
pages[0][4][3] = 'theta';
pages[2][0][1] = 'theta';
return pages;
}
var pages = buildPages();
pages.forEach(function (lines, pIdx) {
var page = document.createElement('div');
page.className = 'doc__page';
var title = document.createElement('div');
title.className = 'doc__title';
title.style.width = (46 + (pIdx % 3) * 10) + '%';
page.appendChild(title);
lines.forEach(function (words) {
var lineEl = document.createElement('p');
lineEl.className = 'pvs__lineText';
words.forEach(function (word, wi) {
var span = document.createElement('span');
span.className = 'pvs__w';
span.textContent = word;
lineEl.appendChild(span);
if (wi < words.length - 1) lineEl.appendChild(document.createTextNode(' '));
});
page.appendChild(lineEl);
});
doc.appendChild(page);
});
var words = Array.prototype.slice.call(doc.querySelectorAll('.pvs__w'));
function pageOf(el) {
var i = 1, node = el;
while (node && !node.classList.contains('doc__page')) node = node.parentElement;
var all = Array.prototype.slice.call(doc.children);
return all.indexOf(node) + 1;
}
function find(q) {
q = q.toLowerCase();
if (!q) return [];
return words.filter(function (w) { return w.textContent.toLowerCase().indexOf(q) === 0; });
}
var scrollAnim = null;
function startScroll(to, dur, nowMs) {
scrollAnim = { from: doc.scrollTop, to: to, start: nowMs, dur: dur };
}
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
var lastHit = null;
function jumpTo(el, nowMs) {
var target = Math.max(0, el.offsetTop - doc.clientHeight * 0.35);
startScroll(target, 460, nowMs);
if (lastHit) lastHit.classList.remove('pvs__w--hit');
lastHit = el;
}
function ignite(el) { el.classList.add('pvs__w--hit'); }
function extinguish(el) { el.classList.remove('pvs__w--hit'); }
function renderList(matches) {
count.textContent = matches.length + '개 일치';
resultsBox.innerHTML = '';
matches.slice(0, 5).forEach(function (el) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'pvs__result';
b.textContent = '페이지 ' + pageOf(el);
b.addEventListener('click', function () { userActive = true; root.classList.remove('is-demo'); jumpTo(el, lastTs || 0); ignite(el); });
resultsBox.appendChild(b);
});
}
var userActive = false;
function onInput() {
userActive = true; root.classList.remove('is-demo');
var matches = find(input.value);
renderList(matches);
}
input.addEventListener('input', onInput);
input.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
var matches = find(input.value);
if (matches.length) { userActive = true; root.classList.remove('is-demo'); jumpTo(matches[0], lastTs || 0); ignite(matches[0]); }
}
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { root.classList.remove('is-demo'); userActive = true; }
var fired = {};
var SCHEDULE = [
{ t: 0, run: function () { input.value = ''; renderList([]); startScroll(0, 260, elapsedNow); if (lastHit) { extinguish(lastHit); lastHit = null; } } },
{ t: 150, run: function () { input.value = 'al'; var m = find('al'); renderList(m); if (m[0]) jumpTo(m[0], elapsedNow); } },
{ t: 650, run: function () { if (lastHit) ignite(lastHit); } },
{ t: 980, run: function () { if (lastHit) extinguish(lastHit); } },
{ t: 1150, run: function () { input.value = ''; renderList([]); } },
{ t: 1300, run: function () { input.value = 'th'; var m = find('th'); renderList(m); var pick = m[1] || m[0]; if (pick) jumpTo(pick, elapsedNow); } },
{ t: 1780, run: function () { if (lastHit) ignite(lastHit); } }
];
var lastTs = null, elapsed = 0, elapsedNow = 0;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!userActive) {
elapsed += dt;
elapsedNow = elapsed;
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(); }
});
}
if (scrollAnim) {
var p = Math.max(0, Math.min(1, (elapsedNow - scrollAnim.start) / scrollAnim.dur));
doc.scrollTop = scrollAnim.from + (scrollAnim.to - scrollAnim.from) * easeOutCubic(p);
if (p >= 1) scrollAnim = null;
}
requestAnimationFrame(tick);
}
renderList([]);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./PdfViewerSearchMatchHighlightJump.module.scss";
type KeyEvt = { key: string; preventDefault(): void };
type InputEvt = { target: { value: string } };
export default function PdfViewerSearchMatchHighlightJump() {
const rootRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const countRef = useRef<HTMLSpanElement>(null);
const docRef = useRef<HTMLDivElement>(null);
const resultsRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const root = rootRef.current, input = inputRef.current, count = countRef.current;
const doc = docRef.current, resultsBox = resultsRef.current;
if (!root || !input || !count || !doc || !resultsBox) return;
const LOOP_MS = 2000;
const VOCAB = ["alpha", "beta", "gamma", "delta", "omega", "sigma", "kappa", "lambda", "theta", "zeta"];
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function buildPages() {
const rnd = seedRand(430031);
const pages: string[][][] = [];
for (let p = 0; p < 4; p += 1) {
const lines: string[][] = [];
for (let l = 0; l < 5; l += 1) {
const words: string[] = [];
for (let w = 0; w < 5; w += 1) words.push(VOCAB[Math.floor(rnd() * VOCAB.length)]);
lines.push(words);
}
pages.push(lines);
}
pages[1][2][0] = "alpha";
pages[3][1][2] = "alpha";
pages[0][4][3] = "theta";
pages[2][0][1] = "theta";
return pages;
}
const pages = buildPages();
pages.forEach((lines, pIdx) => {
const page = document.createElement("div");
page.className = styles.docPage;
const title = document.createElement("div");
title.className = styles.docTitle;
title.style.width = (46 + (pIdx % 3) * 10) + "%";
page.appendChild(title);
lines.forEach((words) => {
const lineEl = document.createElement("p");
lineEl.className = styles.lineText;
words.forEach((word, wi) => {
const span = document.createElement("span");
span.className = styles.w;
span.textContent = word;
lineEl.appendChild(span);
if (wi < words.length - 1) lineEl.appendChild(document.createTextNode(" "));
});
page.appendChild(lineEl);
});
doc.appendChild(page);
});
const words = Array.prototype.slice.call(doc.querySelectorAll("." + styles.w)) as HTMLElement[];
function pageOf(el: HTMLElement) {
let node: HTMLElement | null = el;
while (node && !node.classList.contains(styles.docPage)) node = node.parentElement;
const all = Array.prototype.slice.call(doc!.children) as Element[];
return all.indexOf(node as Element) + 1;
}
function find(q: string) {
q = q.toLowerCase();
if (!q) return [] as HTMLElement[];
return words.filter((w) => w.textContent!.toLowerCase().indexOf(q) === 0);
}
let scrollAnim: { from: number; to: number; start: number; dur: number } | null = null;
function startScroll(to: number, dur: number, nowMs: number) {
scrollAnim = { from: doc!.scrollTop, to, start: nowMs, dur };
}
function easeOutCubic(t: number) { return 1 - Math.pow(1 - t, 3); }
let lastHit: HTMLElement | null = null;
function jumpTo(el: HTMLElement, nowMs: number) {
const target = Math.max(0, el.offsetTop - doc!.clientHeight * 0.35);
startScroll(target, 460, nowMs);
if (lastHit) lastHit.classList.remove(styles.hit);
lastHit = el;
}
function ignite(el: HTMLElement) { el.classList.add(styles.hit); }
function extinguish(el: HTMLElement) { el.classList.remove(styles.hit); }
function renderList(matches: HTMLElement[]) {
count!.textContent = matches.length + "개 일치";
resultsBox!.innerHTML = "";
matches.slice(0, 5).forEach((el) => {
const b = document.createElement("button");
b.type = "button";
b.className = styles.result;
b.textContent = "페이지 " + pageOf(el);
b.addEventListener("click", () => { userActive = true; root!.classList.remove("is-demo"); jumpTo(el, lastTs || 0); ignite(el); });
resultsBox!.appendChild(b);
});
}
let userActive = false;
function onInput(e: InputEvt) {
userActive = true; root!.classList.remove("is-demo");
const matches = find(e.target.value);
renderList(matches);
}
function onKeydown(e: KeyEvt) {
if (e.key === "Enter") {
e.preventDefault();
const matches = find(input!.value);
if (matches.length) { userActive = true; root!.classList.remove("is-demo"); jumpTo(matches[0], lastTs || 0); ignite(matches[0]); }
}
}
input.addEventListener("input", onInput as unknown as EventListener);
input.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; }
const fired: Record<string, boolean> = {};
let elapsedNow = 0;
const SCHEDULE = [
{ t: 0, run: () => { input!.value = ""; renderList([]); startScroll(0, 260, elapsedNow); if (lastHit) { extinguish(lastHit); lastHit = null; } } },
{ t: 150, run: () => { input!.value = "al"; const m = find("al"); renderList(m); if (m[0]) jumpTo(m[0], elapsedNow); } },
{ t: 650, run: () => { if (lastHit) ignite(lastHit); } },
{ t: 980, run: () => { if (lastHit) extinguish(lastHit); } },
{ t: 1150, run: () => { input!.value = ""; renderList([]); } },
{ t: 1300, run: () => { input!.value = "th"; const m = find("th"); renderList(m); const pick = m[1] || m[0]; if (pick) jumpTo(pick, elapsedNow); } },
{ t: 1780, run: () => { if (lastHit) ignite(lastHit); } }
];
let lastTs: number | null = null, elapsed = 0, raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!userActive) {
elapsed += dt;
elapsedNow = elapsed;
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(); }
});
}
if (scrollAnim) {
const p = Math.max(0, Math.min(1, (elapsedNow - scrollAnim.start) / scrollAnim.dur));
doc!.scrollTop = scrollAnim.from + (scrollAnim.to - scrollAnim.from) * easeOutCubic(p);
if (p >= 1) scrollAnim = null;
}
raf = requestAnimationFrame(tick);
}
renderList([]);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
input.removeEventListener("input", onInput as unknown as EventListener);
input.removeEventListener("keydown", onKeydown as unknown as EventListener);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.pvs} is-demo`} ref={rootRef}>
<div className={styles.bar}>
<input className={styles.input} ref={inputRef} type="text" placeholder="검색" aria-label="문서 안 검색" autoComplete="off" />
<span className={styles.count} ref={countRef}>0개 일치</span>
</div>
<div className={styles.body}>
<div className={styles.doc} ref={docRef} />
<div className={styles.results} ref={resultsRef} />
</div>
</div>
</main>
);
}03 일치한 검색어 위치를 계산해 점프하는 검색 하이라이트 — 측정 누적면적 40.346% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · pdf 미리보기 구현 9가지 — 스크롤로 페이지를 계산하는 pdf 뷰어 ui
이 데모가 속한 글
pdf 미리보기 구현 9가지 — 스크롤로 페이지를 계산하는 pdf 뷰어 ui스크롤 값이 페이지 번호를 만드는 걸까, 페이지 번호가 스크롤 값을 만드는 걸까 — 02번 확대 중심은 포인터 좌표로, 04번 다음 페이지는 손을 뗄 때의 속도로, 08번 다운로드 링은 받은 바이트 수로 답한다. p 이 글의 데모 전부 보기


