Public Source Viewer
비나래아카이브 개발자 포털
실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.
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