본문으로 건너뛰기
Public Source Viewer

비나래아카이브 개발자 포털

실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.

Redacted View
src/shared/novel-reader-runtime.ts
공개 가능
1 // Shared with HinanaStudioNovel. Synchronize using the Novel integration script.
2 // Only this fixed script may execute in reader documents. Manuscripts stay escaped text.
3 export const READER_SCRIPT_HASH = "vX5TyP30pvN0ZdmCT2UYD4E0zev3WSzHXDX/4CSlJ7g=";
4 export const READER_SCRIPT = String.raw`(() => {
5 'use strict';
6 const source = document.querySelector('.book');
7 const scrolling = document.querySelector('.scroll');
8 const stage = document.getElementById('page-stage');
9 const spread = document.getElementById('page-spread');
10 const status = document.getElementById('page-status');
11 const previous = document.getElementById('page-prev');
12 const next = document.getElementById('page-next');
13 const modeButton = document.getElementById('reading-mode');
14 const spreadSelect = document.getElementById('page-layout');
15 const progress = document.getElementById('reading-progress');
16 const vertical = document.body.dataset.writing === 'vertical';
17 const key = 'hinana-novel-position:' + document.body.dataset.book;
18 const blocks = [];
19 let offset = 0;
20 source.querySelectorAll('section').forEach((section, chapter) => {
21 Array.from(section.children).forEach((node, index) => {
22 const text = node.textContent || '';
23 blocks.push({ tag: node.tagName, className: node.className, text, start: offset, chapter, first: index === 0 });
24 offset += text.length;
25 });
26 });
27 const end = source.querySelector('.end');
28 if (end) blocks.push({ tag: 'DIV', className: 'end', text: end.textContent || '', start: offset, chapter: Math.max(0, source.querySelectorAll('section').length - 1), first: false });
29 let mode = document.body.dataset.reading;
30 let pages = [], pageIndex = 0, columns = 1, anchor = 0, generation = 0, cache = null, ready = false, resizeTimer;
31 let pendingAnchor = null;
32 const clamp = n => Math.max(0, Math.min(offset, Number(n) || 0));
33 function savePosition(value) {
34 anchor = clamp(value);
35 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
36 if (window.parent !== window) window.parent.postMessage({ type: 'novel:position:save', anchor }, '*');
37 }
38 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
39 window.addEventListener('message', event => {
40 if (event.source !== window.parent || !event.data || event.data.type !== 'novel:position:restore' || !Number.isFinite(event.data.anchor)) return;
41 anchor = clamp(event.data.anchor);
42 if (mode === 'pages' && ready) showPage(pageFor(anchor));
43 else { pendingAnchor = anchor; if (mode === 'scroll') requestAnimationFrame(() => scrollToAnchor(anchor)); }
44 });
45 if (window.parent !== window) window.parent.postMessage({ type: 'novel:position:load' }, '*');
46 function pageFor(value) { let found = 0; for (let i = 0; i < pages.length; i++) { if (pages[i].start <= value) found = i; else break; } return found; }
47 function updateButtons() {
48 previous.disabled = !ready || pageIndex === 0;
49 next.disabled = !ready || pageIndex + columns >= pages.length;
50 previous.textContent = vertical ? '이전 →' : '← 이전';
51 next.textContent = vertical ? '← 다음' : '다음 →';
52 previous.setAttribute('aria-label', '이전 페이지'); next.setAttribute('aria-label', '다음 페이지');
53 }
54 function showPage(index, remember = true) {
55 if (!ready || !pages.length) return;
56 pageIndex = Math.max(0, Math.min(pages.length - 1, index));
57 pageIndex = Math.floor(pageIndex / columns) * columns;
58 Array.from(spread.children).forEach(node => cache.append(node));
59 for (let i = pageIndex; i < Math.min(pageIndex + columns, pages.length); i++) spread.append(pages[i].element);
60 const last = Math.min(pageIndex + columns, pages.length);
61 status.textContent = (columns === 2 && last !== pageIndex + 1 ? (pageIndex + 1) + '–' + last : String(pageIndex + 1)) + ' / ' + pages.length;
62 progress.value = last / pages.length;
63 document.getElementById('reading-percent').textContent = Math.round(progress.value * 100) + '%';
64 updateButtons();
65 if (remember) savePosition(pages[pageIndex].start);
66 }
67 function changePage(delta) {
68 if (!ready || mode !== 'pages') return;
69 const target = pageIndex + delta * columns;
70 if (target < 0 || target >= pages.length) return;
71 showPage(target);
72 }
73 const segmenter = typeof Intl.Segmenter === 'function' ? new Intl.Segmenter('ko', { granularity: 'grapheme' }) : null;
74 function nextPieces(text, at) {
75 const sample = text.slice(at, at + 16448);
76 if (segmenter) return Array.from(segmenter.segment(sample)).filter(value => value.index + value.segment.length <= 16384).map(value => value.segment);
77 return Array.from(sample).slice(0, 8192);
78 }
79 async function paginate() {
80 if (mode !== 'pages') return;
81 const ticket = ++generation, wanted = pendingAnchor === null ? anchor : pendingAnchor; pendingAnchor = null;
82 ready = false; stage.dataset.ready = 'false'; updateButtons(); status.textContent = '페이지 계산 중…';
83 const rect = stage.getBoundingClientRect();
84 columns = (spreadSelect.value === 'double' || (spreadSelect.value === 'auto' && rect.width >= 900)) && rect.width >= 700 ? 2 : 1;
85 const paperWidth = rect.width / columns, paperHeight = rect.height;
86 const requestedPadding = Number(document.body.dataset.padding) || 40;
87 const padding = Math.min(requestedPadding, paperWidth < 480 ? 24 : requestedPadding, Math.max(12, paperHeight / 8));
88 const bodyWidth = Math.max(100, Math.min(Number(document.body.dataset.width) || 640, paperWidth - padding * 2)), bodyHeight = Math.max(100, paperHeight - padding * 2);
89 stage.style.setProperty('--paper-width', paperWidth + 'px'); stage.style.setProperty('--paper-height', paperHeight + 'px'); stage.style.setProperty('--page-padding', padding + 'px');
90 const newCache = document.createElement('div'); newCache.className = 'page-cache'; document.body.append(newCache);
91 const built = []; let current = null, units = 0;
92 const fits = () => current.content.scrollHeight <= current.content.clientHeight + 1 && current.content.scrollWidth <= current.content.clientWidth + 1;
93 function makePage(start, chapter) {
94 const element = document.createElement('div'); element.className = 'reader-page'; element.dataset.pageIndex = String(built.length); element.dataset.start = String(start);
95 element.style.width = paperWidth + 'px'; element.style.height = paperHeight + 'px'; element.style.padding = padding + 'px';
96 const content = document.createElement('div'); content.className = 'page-content'; content.style.width = bodyWidth + 'px'; content.style.height = bodyHeight + 'px';
97 element.append(content); newCache.append(element); current = { element, content, start, chapter }; built.push(current);
98 }
99 for (const block of blocks) {
100 if (ticket !== generation) { newCache.remove(); return; }
101 if (!current || (block.first && current.content.childNodes.length)) makePage(block.start, block.chapter);
102 let at = 0;
103 if (!block.text.length) { const blank = document.createElement('p'); blank.style.minBlockSize = '1em'; current.content.append(blank); if (!fits()) { blank.remove(); makePage(block.start, block.chapter); current.content.append(blank); } continue; }
104 while (at < block.text.length) {
105 if (ticket !== generation) { newCache.remove(); return; }
106 const node = document.createElement(block.tag); node.className = block.className;
107 if (at > 0) node.dataset.continued = 'true';
108 current.content.append(node);
109 const pieces = nextPieces(block.text, at);
110 node.textContent = pieces.join('');
111 let accepted = pieces.length;
112 if (!fits()) {
113 let low = 0, high = pieces.length;
114 while (low < high) { const middle = Math.ceil((low + high) / 2); node.textContent = pieces.slice(0, middle).join(''); if (fits()) low = middle; else high = middle - 1; }
115 accepted = low;
116 }
117 if (!accepted && current.content.childNodes.length > 1) { node.remove(); makePage(block.start + at, block.chapter); continue; }
118 if (!accepted) accepted = 1;
119 node.textContent = pieces.slice(0, accepted).join('');
120 at += node.textContent.length;
121 if (at < block.text.length) { makePage(block.start + at, block.chapter); }
122 units++;
123 if (units % 10 === 0) { status.textContent = built.length + '쪽 계산 중…'; await new Promise(resolve => requestAnimationFrame(resolve)); }
124 }
125 }
126 if (ticket !== generation) { newCache.remove(); return; }
127 if (!built.length) makePage(0, 0);
128 Array.from(spread.children).forEach(node => node.remove());
129 cache?.remove(); cache = newCache; pages = built; ready = true; stage.dataset.ready = 'true';
130 const targetAnchor = pendingAnchor === null ? wanted : pendingAnchor; pendingAnchor = null;
131 showPage(pageFor(targetAnchor), false);
132 anchor = clamp(targetAnchor);
133 }
134 function scrollAnchor() {
135 const main = scrolling.getBoundingClientRect(); let closest = 0, distance = Infinity;
136 const nodes = source.querySelectorAll('section > *'); let position = 0;
137 nodes.forEach(node => {
138 const rect = node.getBoundingClientRect();
139 const d = vertical ? Math.abs(main.right - rect.right) : Math.abs(main.top - rect.top);
140 if (d < distance) { distance = d; closest = position; }
141 position += (node.textContent || '').length;
142 });
143 return closest;
144 }
145 function scrollToAnchor(value) {
146 let position = 0, target = source.querySelector('section');
147 for (const node of source.querySelectorAll('section > *')) { if (position <= value) target = node; position += (node.textContent || '').length; if (position > value) break; }
148 target?.scrollIntoView({ block: 'start', inline: 'start' });
149 }
150 function setMode(value) {
151 if (mode === 'scroll' && value === 'pages') anchor = scrollAnchor();
152 mode = value; document.body.dataset.reading = mode;
153 const paged = mode === 'pages';
154 stage.hidden = !paged; scrolling.hidden = paged; document.getElementById('page-controls').hidden = !paged; spreadSelect.hidden = !paged;
155 modeButton.textContent = paged ? '스크롤 읽기' : '책처럼 읽기'; modeButton.setAttribute('aria-pressed', String(paged));
156 if (paged) paginate(); else { generation++; ready = false; cache?.remove(); cache = null; spread.replaceChildren(); scrollToAnchor(anchor); }
157 }
158 previous.addEventListener('click', () => changePage(-1)); next.addEventListener('click', () => changePage(1));
159 modeButton.addEventListener('click', () => setMode(mode === 'pages' ? 'scroll' : 'pages'));
160 spreadSelect.addEventListener('change', () => paginate());
161 document.addEventListener('keydown', event => {
162 if (mode !== 'pages' || /INPUT|TEXTAREA|SELECT|SUMMARY/.test(event.target.tagName) || event.altKey || event.metaKey || event.ctrlKey) return;
163 if (event.target.tagName === 'BUTTON' && (event.key === ' ' || event.key === 'Enter')) return;
164 let delta = 0;
165 if (event.key === 'ArrowRight') delta = vertical ? -1 : 1;
166 if (event.key === 'ArrowLeft') delta = vertical ? 1 : -1;
167 if (event.key === 'PageDown' || event.key === ' ') delta = event.shiftKey ? -1 : 1;
168 if (event.key === 'PageUp') delta = -1;
169 if (event.key === 'Home') { event.preventDefault(); showPage(0); return; }
170 if (event.key === 'End') { event.preventDefault(); showPage(pages.length - 1); return; }
171 if (delta) { event.preventDefault(); changePage(delta); }
172 });
173 let touch = null;
174 stage.addEventListener('touchstart', event => { if (event.touches.length === 1) touch = { x: event.touches[0].clientX, y: event.touches[0].clientY }; }, { passive: true });
175 stage.addEventListener('touchend', event => {
176 if (!touch || !event.changedTouches.length) return;
177 const dx = event.changedTouches[0].clientX - touch.x, dy = event.changedTouches[0].clientY - touch.y; touch = null;
178 if (Math.abs(dx) > 45 && Math.abs(dx) > Math.abs(dy) * 1.4) changePage((dx < 0 ? 1 : -1) * (vertical ? -1 : 1));
179 }, { passive: true });
180 stage.addEventListener('click', event => {
181 if (window.getSelection()?.toString()) return;
182 const rect = stage.getBoundingClientRect(), x = event.clientX - rect.left;
183 if (x < rect.width * .18) changePage(vertical ? 1 : -1);
184 else if (x > rect.width * .82) changePage(vertical ? -1 : 1);
185 });
186 document.querySelectorAll('nav a').forEach((link, chapter) => link.addEventListener('click', event => {
187 document.querySelector('details').open = false;
188 if (mode !== 'pages') return;
189 event.preventDefault(); if (!ready) return;
190 const target = pages.findIndex(page => page.chapter === chapter); if (target >= 0) showPage(target);
191 }));
192 let scrollTimer;
193 scrolling.addEventListener('scroll', () => { clearTimeout(scrollTimer); scrollTimer = setTimeout(() => { if (mode === 'scroll') savePosition(scrollAnchor()); }, 150); }, { passive: true });
194 window.addEventListener('resize', () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { if (mode === 'pages') paginate(); }, 150); });
195 window.addEventListener('pagehide', () => { if (mode === 'pages' && ready) savePosition(pages[pageIndex].start); });
196 setMode(mode);
197 document.fonts.ready.then(() => { if (mode === 'pages') paginate(); else scrollToAnchor(anchor); });
198 })();`;
199