Files
Aletheia/app/src/main/assets/wwwroot/index.html
T

1321 lines
67 KiB
HTML

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Алетея: чтение</title>
<style>
* { /* Сброс отступов и установка box-sizing для всех элементов */
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--reader-safe-left: 0px;
--reader-safe-top: 0px;
--reader-safe-right: 0px;
--reader-safe-bottom: 0px;
}
html, body { /* Растягиваем страницу на весь экран, отключаем прокрутку и выделение текста */
width: 100%;
height: 100%;
overflow: hidden;
background-color: #faf8ef; /* Цвет "сепия" для комфортного чтения */
font-family: serif;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-webkit-touch-callout: default;
-webkit-user-select: text;
user-select: text;
}
#reader-container { /* Основной контейнер для книги */
position: fixed;
top: var(--reader-safe-top);
right: var(--reader-safe-right);
bottom: var(--reader-safe-bottom);
left: var(--reader-safe-left);
overflow: hidden;
}
#book-content, #fb2-content { /* Контейнеры для EPUB и FB2 соответственно */
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
}
#fb2-content { /* Специфичные настройки для FB2: отступы и скрытие по умолчанию */
overflow: hidden;
padding: 28px 24px;
font-size: 18px;
line-height: 1.68;
display: none;
hyphens: auto;
overflow-wrap: break-word;
}
#loading { /* Центрированный экран загрузки */
display: flex;
justify-content: center;
align-items: center;
height: 100%;
padding: 32px;
text-align: center;
background:
radial-gradient(circle at 50% 42%, rgba(63, 111, 90, 0.12), transparent 32%),
linear-gradient(180deg, #FAF1E2 0%, #FFFDF8 100%);
}
.loading-panel {
width: min(320px, 100%);
padding: 28px 24px 26px;
border: 1px solid rgba(215, 205, 191, 0.86);
border-radius: 28px;
background: rgba(255, 252, 246, 0.88);
box-shadow: 0 18px 44px rgba(47, 38, 28, 0.10);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.loading-mark {
width: 58px;
height: 58px;
border-radius: 20px;
background: #ECE5DB;
display: flex;
justify-content: center;
align-items: center;
box-shadow: inset 0 0 0 1px rgba(215, 205, 191, 0.92);
}
.loading-title {
color: #24201B;
font-size: 20px;
font-weight: 700;
line-height: 1.2;
}
#loading-text {
color: #6B6258;
font-size: 14px;
line-height: 1.45;
}
.spinner { /* Анимация крутящегося индикатора загрузки */
width: 32px;
height: 32px;
border: 3px solid rgba(63, 111, 90, 0.18);
border-top-color: #3F6F5A;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin { /* Описание вращения спиннера */
to {
transform: rotate(360deg);
}
}
#error-display { /* Контейнер для вывода ошибок */
display: none;
justify-content: center;
align-items: center;
height: 100%;
font-size: 16px;
color: #B4493B;
padding: 32px;
text-align: center;
flex-direction: column;
gap: 12px;
line-height: 1.45;
}
.book-search-highlight {
background: rgba(226, 169, 66, 0.45);
border-radius: 3px;
}
</style>
</head>
<body>
<div id="reader-container">
<div id="book-content"></div> <div id="fb2-content"></div> <div id="loading">
<div class="loading-panel">
<div class="loading-mark">
<div class="spinner"></div>
</div>
<span class="loading-title">Открываю книгу</span>
<span id="loading-text">Подготавливаю читалку...</span>
</div>
</div>
<div id="error-display">
<span style="font-size:40px">!</span>
<span id="error-text"></span>
</div>
</div>
<script src="_framework/hybridwebview.js"></script>
<script src="js/jszip.min.js"></script>
<script src="js/epub.min.js"></script>
<script>
(function () { // Самовызывающаяся функция для изоляции переменных
'use strict';
// ========== КЭШИРОВАННЫЕ DOM-ЭЛЕМЕНТЫ ==========
const $ = id => document.getElementById(id); // Короткий псевдоним для поиска элементов
const els = { // Объект со ссылками на элементы для быстрого доступа
loading: $('loading'),
loadingText: $('loading-text'),
errorDisplay: $('error-display'),
errorText: $('error-text'),
bookContent: $('book-content'),
fb2Content: $('fb2-content'),
};
// ========== СОСТОЯНИЕ (STATE) ==========
const state = { // Глобальный объект состояния текущей книги
book: null, // Объект книги epub.js
rendition: null, // Объект отображения epub.js
currentCfi: null, // Текущая позиция (идентификатор) в EPUB
totalPages: 0, // Всего страниц
bookFormat: '', // epub или fb2
isBookLoaded: false,
fb2CurrentPage: 0,
fb2TotalPages: 1,
toc: [], // Оглавление
lastCfi: null,
currentPage: 0,
currentFontSize: 18,
currentFontFamily: 'serif',
currentTheme: 'sepia',
brightness: 100,
safeInsets: { left: 0, top: 0, right: 0, bottom: 0 },
relayoutTimer: null,
relayoutGeneration: 0,
resizeObserver: null,
search: {
query: '',
results: [],
currentIndex: -1,
generation: 0,
searching: false
},
lastSelection: null
};
// ========== УТИЛИТЫ ==========
function debugLog(msg) { // Логирование в консоль с префиксом
console.log('[Reader] ' + msg);
}
function showError(msg) { // Показ экрана ошибки пользователю
els.loading.style.display = 'none';
els.errorDisplay.style.display = 'flex';
els.errorText.textContent = msg;
debugLog('ERROR: ' + msg);
}
function setLoadingText(msg) { // Обновление текста на экране загрузки
if (els.loadingText) els.loadingText.textContent = msg;
debugLog(msg);
}
const _escDiv = document.createElement('div'); // Буфер для очистки HTML
function escapeHtml(text) { // Защита от XSS: превращает < в &lt; и т.д.
_escDiv.textContent = text;
return _escDiv.innerHTML;
}
function base64ToArrayBuffer(base64) { // Конвертация данных из строки (Base64) в бинарный массив
const bin = atob(base64); // Декодирование base64
const len = bin.length;
const buf = new ArrayBuffer(len);
const view = new Uint8Array(buf);
for (let i = 0; i < len; i++) {
view[i] = bin.charCodeAt(i); // Заполнение массива байтами
}
return buf;
}
function calculateOptimalLocationSize() {
// 1. Получаем размеры видимой области
const readerBounds = $('reader-container');
const width = readerBounds && readerBounds.clientWidth ? readerBounds.clientWidth : window.innerWidth;
const height = readerBounds && readerBounds.clientHeight ? readerBounds.clientHeight : window.innerHeight;
// 2. Получаем текущий размер шрифта (из состояния или напрямую из настроек)
// Если размер шрифта еще не задан, используем 18px по умолчанию
const fontSize = state.currentFontSize || 18;
// 3. Эмпирический коэффициент:
// На 1 квадратный пиксель при 18-м шрифте приходится примерно 0.005 - 0.007 символа.
// Мы рассчитываем "площадь" одного символа.
// Чем больше шрифт, тем больше места занимает символ (квадратичная зависимость).
const charArea = (fontSize * fontSize) * 0.55;
// 4. Рассчитываем общую вместимость экрана в символах
const screenArea = width * height;
let charactersPerScreen = Math.floor(screenArea / charArea);
// 5. Ограничиваем значения для стабильности epub.js
// Минимум 400 (чтобы не плодить тысячи локаций на маленьких текстах)
// Максимум 1500 (чтобы избежать "залипания" на больших экранах)
const finalSize = Math.max(400, Math.min(1000, charactersPerScreen));
debugLog(`Расчет локации: Экран ${width}x${height}, Шрифт ${fontSize}px => Размер локации: ${finalSize}`);
return finalSize;
}
// ========== MESSAGE BRIDGE (Связь с приложением) ==========
function sendMessage(action, data) { // Отправка данных в нативный код (C# / Swift / Kotlin)
const message = JSON.stringify({ action, data: data || {} });
try {
if (window.HybridWebView && typeof window.HybridWebView.SendRawMessage === 'function') {
window.HybridWebView.SendRawMessage(message);
} else if (window.AndroidBridge && typeof window.AndroidBridge.postMessage === 'function') {
window.AndroidBridge.postMessage(message);
}
} catch (e) {
debugLog('Bridge error: ' + e.message);
}
}
function applySafeAreaInsets() {
const insets = state.safeInsets;
document.documentElement.style.setProperty('--reader-safe-left', insets.left + 'px');
document.documentElement.style.setProperty('--reader-safe-top', insets.top + 'px');
document.documentElement.style.setProperty('--reader-safe-right', insets.right + 'px');
document.documentElement.style.setProperty('--reader-safe-bottom', insets.bottom + 'px');
scheduleReaderRelayout('safe-area', true);
}
window.setSafeAreaInsets = function (left, top, right, bottom) {
const nextInsets = {
left: Math.max(0, Number(left) || 0),
top: Math.max(0, Number(top) || 0),
right: Math.max(0, Number(right) || 0),
bottom: Math.max(0, Number(bottom) || 0)
};
if (
state.safeInsets.left === nextInsets.left &&
state.safeInsets.top === nextInsets.top &&
state.safeInsets.right === nextInsets.right &&
state.safeInsets.bottom === nextInsets.bottom
) {
return;
}
state.safeInsets = nextInsets;
applySafeAreaInsets();
};
function currentEpubCfi() {
return state.currentCfi || state.lastCfi || null;
}
function currentFb2Progress() {
const total = state.fb2TotalPages || 1;
return total > 1 ? state.fb2CurrentPage / (total - 1) : 0;
}
function relayoutEpubReader(preservePosition) {
if (!state.rendition || typeof state.rendition.resize !== 'function') {
return;
}
const cfiToKeep = preservePosition ? currentEpubCfi() : null;
const width = els.bookContent.clientWidth;
const height = els.bookContent.clientHeight;
if (width > 0 && height > 0) {
state.rendition.resize(width, height);
} else {
state.rendition.resize();
}
if (!cfiToKeep) {
return;
}
const generation = ++state.relayoutGeneration;
setTimeout(() => {
if (generation !== state.relayoutGeneration || !state.rendition) {
return;
}
try {
const result = state.rendition.display(cfiToKeep);
if (result && typeof result.catch === 'function') {
result.catch(error => debugLog('EPUB relayout display failed: ' + error.message));
}
} catch (e) {
debugLog('EPUB relayout failed: ' + e.message);
}
}, 60);
}
function relayoutReader(reason, preservePosition) {
debugLog('Relayout reader: ' + reason);
if (state.bookFormat === 'fb2') {
setupFb2Pagination(preservePosition);
} else if (state.bookFormat === 'epub') {
relayoutEpubReader(preservePosition);
}
}
function scheduleReaderRelayout(reason, preservePosition) {
clearTimeout(state.relayoutTimer);
state.relayoutTimer = setTimeout(() => {
state.relayoutTimer = null;
relayoutReader(reason || 'unknown', preservePosition !== false);
}, 120);
}
function initReaderResizeHandling() {
const container = $('reader-container');
if (window.ResizeObserver && container) {
state.resizeObserver = new ResizeObserver(() => scheduleReaderRelayout('container-resize', true));
state.resizeObserver.observe(container);
}
window.addEventListener('resize', () => scheduleReaderRelayout('window-resize', true));
window.addEventListener('orientationchange', () => scheduleReaderRelayout('orientation-change', true));
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', () => scheduleReaderRelayout('viewport-resize', true));
}
}
// ========== TOUCH / SWIPE ==========
const gestureTargets = new WeakSet();
function getSelectionText(selectionProvider) {
try {
return selectionProvider ? (selectionProvider().toString() || '') : '';
} catch (e) {
return '';
}
}
function shouldIgnoreGesture(event, selectionProvider) {
const target = event.target;
if (
target &&
target.closest &&
target.closest('a,button,input,textarea,select,[contenteditable="true"]')
) {
return true;
}
return getSelectionText(selectionProvider).trim().length > 0;
}
function performTapAction(clientX) {
const left = state.safeInsets.left || 0;
const right = window.innerWidth - (state.safeInsets.right || 0);
const width = Math.max(1, right - left);
const localX = clientX - left;
if (localX < width * 0.3) {
window.prevPage();
} else if (localX > width * 0.7) {
window.nextPage();
} else {
sendMessage('toggleMenu', {});
}
}
function attachReaderGestures(target, selectionProvider) {
if (!target || gestureTargets.has(target)) return;
gestureTargets.add(target);
let startX = 0;
let startY = 0;
let startTime = 0;
let lastTouchActionAt = 0;
target.addEventListener('touchstart', event => {
if (event.touches.length !== 1) return;
const touch = event.touches[0];
startX = touch.clientX;
startY = touch.clientY;
startTime = Date.now();
}, { passive: true });
target.addEventListener('touchend', event => {
if (shouldIgnoreGesture(event, selectionProvider)) return;
const touch = event.changedTouches[0];
if (!touch) return;
const dx = touch.clientX - startX;
const dy = touch.clientY - startY;
const dt = Date.now() - startTime;
const horizontal = Math.abs(dx) > Math.abs(dy);
if (dt < 500 && horizontal && Math.abs(dx) > 50) {
dx < 0 ? window.nextPage() : window.prevPage();
lastTouchActionAt = Date.now();
} else if (dt < 280 && Math.abs(dx) < 12 && Math.abs(dy) < 12) {
performTapAction(touch.clientX);
lastTouchActionAt = Date.now();
}
}, { passive: true });
target.addEventListener('click', event => {
if (Date.now() - lastTouchActionAt < 450) return;
if (shouldIgnoreGesture(event, selectionProvider)) return;
performTapAction(event.clientX);
});
}
function initReaderGestures() {
attachReaderGestures(document, () => window.getSelection());
}
function attachEpubContentGestures(contents) {
if (!contents || !contents.document) return;
attachReaderGestures(contents.document, () => contents.window && contents.window.getSelection());
}
// ========== EPUB LOGIC ==========
function loadEpubFromBase64(base64Data, lastCfi, cachedLocations) {
setLoadingText('Подготавливаю EPUB...');
try {
const arrayBuffer = base64ToArrayBuffer(base64Data);
if (typeof JSZip === 'undefined') { showError('Не удалось открыть EPUB: модуль ZIP не загружен.'); return; }
if (typeof ePub === 'undefined') { showError('Не удалось открыть EPUB: модуль чтения не загружен.'); return; }
state.book = ePub(arrayBuffer); // Создание объекта книги из данных
state.lastCfi = null;
els.fb2Content.style.display = 'none';
els.bookContent.style.display = 'block';
// Рендеринг (отрисовка) книги в контейнер
state.rendition = state.book.renderTo('book-content', {
width: '100%',
height: '100%',
spread: 'none', // Без двухстраничного режима
flow: 'paginated' // Режим постраничного отображения
});
// Настройка стилей внутри фрейма книги
state.rendition.themes.default({
'body': {
'font-family': 'serif !important',
'font-size': '18px !important',
'line-height': '1.68 !important',
'padding': '28px 24px !important',
'background-color': '#faf8ef !important',
'color': '#333 !important',
'hyphens': 'auto !important',
'overflow-wrap': 'break-word !important',
'-webkit-user-select': 'text !important',
'user-select': 'text !important',
'-webkit-touch-callout': 'default !important'
},
'p': { 'text-indent': '1.35em', 'margin-bottom': '0.75em' }
});
state.book.ready.then(() => {
// Убираем экран загрузки СРАЗУ, как только книга готова к отрисовке первой страницы
els.loading.style.display = 'none';
// Обработка оглавления
const toc = state.book.navigation.toc || [];
const flattenToc = (items, depth) => (items || []).flatMap(ch => [
{ label: ch.label.trim(), href: ch.href, depth: depth || 0 },
...flattenToc(ch.subitems || [], (depth || 0) + 1)
]);
state.toc = flattenToc(toc, 0);
sendMessage('chaptersLoaded', { chapters: state.toc });
// ПРОВЕРКА КЭША: Если мы уже передали сохраненные локации
if (cachedLocations) {
debugLog("Загрузка из кэша...");
state.book.locations.load(cachedLocations);
state.totalPages = state.book.locations.length();
state.lastCfi = null;
sendMessage('bookReady', { totalPages: state.totalPages });
} else {
// Используем setTimeout, чтобы не блокировать поток отрисовки
const dynamicSize = calculateOptimalLocationSize();
// Запускаем генерацию с динамическим размером
setTimeout(() => {
state.book.locations.generate(dynamicSize).then(() => {
state.totalPages = state.book.locations.length();
const locationsToSave = state.book.locations.save();
state.lastCfi = null;
// Отправляем в C#, чтобы сохранить на будущее
sendMessage('saveLocations', { locations: locationsToSave });
sendMessage('bookReady', { totalPages: state.totalPages });
});
}, 100);
}
});
// Событие при смене страницы
state.rendition.on('relocated', location => {
if (!location || !location.start) return;
const newCfi = location.start.cfi;
// Получаем процент прогресса
const progress = state.book.locations.percentageFromCfi(newCfi) || 0;
// Обновляем lastCfi
state.lastCfi = newCfi;
state.currentCfi = newCfi;
const generatedTotal = Math.max(1, state.book.locations.length() || state.totalPages || 1);
const locationIndex = state.book.locations.locationFromCfi(newCfi);
const generatedPage = Number.isFinite(locationIndex)
? locationIndex + 1
: Math.round(progress * Math.max(0, generatedTotal - 1)) + 1;
state.currentPage = Math.max(1, Math.min(generatedPage, generatedTotal));
state.totalPages = generatedTotal;
const chapterPage = location.start.displayed ? location.start.displayed.page : 1;
const chapterTotal = location.start.displayed ? location.start.displayed.total : 1;
const chapterName = chapterForHref(location.start.href || '');
// Отправка прогресса в приложение
// currentPage теперь показывает процент (округлённый до целого)
sendMessage('progressUpdate', {
progress: progress,
cfi: newCfi,
currentPage: state.currentPage,
totalPages: state.totalPages,
chapterCurrentPage: chapterPage,
chapterTotalPages: chapterTotal,
chapter: chapterName
});
});
state.rendition.on('selected', (cfiRange, contents) => {
let selectedText = '';
try {
selectedText = contents && contents.window
? contents.window.getSelection().toString()
: '';
} catch (e) {
selectedText = '';
}
state.lastSelection = {
cfi: cfiRange,
text: selectedText
};
});
state.rendition.on('rendered', (_section, view) => {
if (view && view.contents) {
attachEpubContentGestures(view.contents);
}
});
// Отображение книги на сохраненной позиции или в начале
const displayTarget = (lastCfi && lastCfi !== 'null' && lastCfi !== 'undefined') ? lastCfi : undefined;
state.rendition.display(displayTarget);
state.isBookLoaded = true;
} catch (e) { showError('EPUB load error: ' + e.message); }
}
// ========== FB2 LOGIC ==========
function loadFb2FromBase64(base64Data, lastPosition) {
setLoadingText('Подготавливаю FB2...');
try {
const arrayBuffer = base64ToArrayBuffer(base64Data);
const bytes = new Uint8Array(arrayBuffer);
let xmlText = new TextDecoder('utf-8').decode(bytes);
// Проверка кодировки в заголовке XML (если не UTF-8, перекодируем)
const encMatch = xmlText.match(/encoding=["\']([^"\']+)["\']/i);
if (encMatch && encMatch[1].toLowerCase() !== 'utf-8') {
xmlText = new TextDecoder(encMatch[1]).decode(bytes);
}
state.lastCfi = null;
els.bookContent.style.display = 'none';
els.fb2Content.style.display = 'block';
// Парсинг строки в XML-документ
const doc = new DOMParser().parseFromString(xmlText, 'text/xml');
if (doc.querySelector('parsererror')) { showError('Не удалось открыть FB2: ошибка разбора XML.'); return; }
const fb2Html = parseFb2Document(doc); // Преобразование FB2 XML в HTML
els.fb2Content.innerHTML = fb2Html.html;
els.loading.style.display = 'none';
// Использование анимационных кадров для замера размеров после вставки в DOM
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setupFb2Pagination(false, () => { // Нарезка на колонки
state.isBookLoaded = true;
state.bookFormat = 'fb2';
if (lastPosition) goToFb2Position(parseFloat(lastPosition));
sendMessage('chaptersLoaded', { chapters: fb2Html.chapters });
sendMessage('bookReady', { totalPages: state.totalPages });
updateFb2Progress();
});
});
});
} catch (e) { showError('FB2 error: ' + e.message); }
}
// ========== FB2 PARSER (Преобразование тегов) ==========
function localTag(el) { // Утилита для получения имени тега без пространств имен (типа fb2:)
return el.tagName ? el.tagName.toLowerCase().replace(/.*:/, '') : '';
}
function parseFb2Document(doc) {
const chapters = [];
const parts = ['<div id="fb2-inner" style="font-size:18px;font-family:serif;line-height:1.68;-webkit-user-select:text;user-select:text;-webkit-touch-callout:default;">'];
let bodies = doc.querySelectorAll('body'); // FB2 может иметь несколько body (основной текст, сноски)
let chapterIndex = 0;
for (const body of bodies) {
for (const child of body.children) {
if (localTag(child) === 'section') { // Секции — это главы
const result = parseFb2Section(child, chapterIndex);
parts.push(result.html);
chapters.push(...result.chapters);
chapterIndex += Math.max(result.chapters.length, 1);
}
}
}
parts.push('</div>');
return { html: parts.join(''), chapters };
}
// Обработчики конкретных тегов FB2
const sectionTagHandlers = {
title(child, idx, chapters) { // Заголовки глав
const text = (child.textContent || '').trim();
chapters.push({ label: text, href: idx.toString() });
return `<h2 class="fb2-title" data-chapter="${idx}" style="text-align:center;margin:1em 0 .5em">${escapeHtml(text)}</h2>`;
},
p(child) { // Абзацы
return `<p style="text-indent:1.5em;margin-bottom:.3em">${getInlineHtml(child)}</p>`;
},
'empty-line'() { return '<br/>'; },
subtitle(child) { return `<h3 style="text-align:center;margin:.8em 0">${escapeHtml(child.textContent || '')}</h3>`; },
epigraph(child) { return `<blockquote style="font-style:italic;margin:1em 2em">${parseInnerParagraphs(child)}</blockquote>`; },
poem(child) { return `<div style="margin:1em 2em">${parsePoem(child)}</div>`; },
cite(child) { return `<blockquote style="margin:1em 2em;padding-left:1em;border-left:3px solid #ccc">${parseInnerParagraphs(child)}</blockquote>`; },
};
function parseFb2Section(section, startIndex) {
const chapters = [];
const parts = [`<div class="fb2-section" data-section="${startIndex}">`];
for (const child of section.children) {
const tag = localTag(child);
if (tag === 'section') { // Рекурсия для вложенных секций
const sub = parseFb2Section(child, startIndex + chapters.length);
parts.push(sub.html);
chapters.push(...sub.chapters);
} else if (sectionTagHandlers[tag]) {
parts.push(sectionTagHandlers[tag](child, startIndex + chapters.length, chapters));
}
}
parts.push('</div>');
return { html: parts.join(''), chapters };
}
function getInlineHtml(el) { // Обработка форматирования внутри строки (жирный, курсив)
const parts = [];
for (const node of el.childNodes) {
if (node.nodeType === 3) parts.push(escapeHtml(node.textContent)); // Текст
else if (node.nodeType === 1) { // Тег
const tag = localTag(node);
const inner = getInlineHtml(node);
if (tag === 'strong' || tag === 'bold') parts.push(`<strong>${inner}</strong>`);
else if (tag === 'emphasis' || tag === 'em') parts.push(`<em>${inner}</em>`);
else if (tag === 'strikethrough') parts.push(`<s>${inner}</s>`);
else parts.push(inner);
}
}
return parts.join('');
}
function parseInnerParagraphs(el) { // Парсинг группы абзацев (для цитат/эпиграфов)
const parts = [];
for (const child of el.children) {
const tag = localTag(child);
if (tag === 'p') parts.push(`<p>${getInlineHtml(child)}</p>`);
else if (tag === 'text-author') parts.push(`<p style="text-align:right;font-style:italic">— ${escapeHtml(child.textContent || '')}</p>`);
}
return parts.join('');
}
function parsePoem(el) { // Парсинг стихов (строфы и строки)
const parts = [];
for (const child of el.children) {
const tag = localTag(child);
if (tag === 'stanza') {
parts.push('<div style="margin-bottom:1em">');
for (const v of child.children) {
if (localTag(v) === 'v') parts.push(`<p style="text-indent:0">${escapeHtml(v.textContent || '')}</p>`);
}
parts.push('</div>');
} else if (tag === 'title') {
parts.push(`<h4>${escapeHtml(child.textContent || '')}</h4>`);
}
}
return parts.join('');
}
// ========== FB2 PAGINATION (Имитация страниц через CSS Columns) ==========
function setupFb2Pagination(preservePosition, afterLayout) {
const container = els.fb2Content;
const inner = $('fb2-inner');
if (!container || !inner) return;
const w = container.clientWidth;
const h = container.clientHeight;
if (w <= 0 || h <= 0) return;
const progressToKeep = preservePosition ? currentFb2Progress() : 0;
// Основная магия: CSS превращает длинный текст в ряд колонок шириной с экран
Object.assign(inner.style, {
columnWidth: w + 'px',
columnGap: '0px',
columnFill: 'auto',
height: h + 'px',
overflow: 'hidden',
});
requestAnimationFrame(() => {
// Общая ширина контента делить на ширину экрана = количество страниц
state.fb2TotalPages = Math.max(1, Math.ceil(inner.scrollWidth / w));
state.totalPages = state.fb2TotalPages;
state.fb2CurrentPage = Math.round(progressToKeep * (state.fb2TotalPages - 1));
showFb2Page(state.fb2CurrentPage);
if (preservePosition && state.isBookLoaded) {
updateFb2Progress();
}
debugLog('FB2 pages: ' + state.fb2TotalPages);
if (typeof afterLayout === 'function') {
afterLayout();
}
});
}
function showFb2Page(idx) { // Переход на конкретную страницу FB2
idx = Math.max(0, Math.min(idx, state.fb2TotalPages - 1));
state.fb2CurrentPage = idx;
const inner = $('fb2-inner');
if (inner) {
// Сдвигаем контент влево, чтобы показать нужную "колонку"
inner.style.transform = `translateX(-${idx * els.fb2Content.clientWidth}px)`;
}
}
function updateFb2Progress() { // Оповещение приложения о прогрессе в FB2
const total = state.fb2TotalPages;
const progress = total > 1 ? state.fb2CurrentPage / (total - 1) : 0;
sendMessage('progressUpdate', {
progress,
cfi: progress.toString(),
currentPage: state.fb2CurrentPage + 1,
totalPages: total,
chapter: getCurrentFb2Chapter()
});
}
function getFb2ChapterAtOffset(offset) {
const inner = $('fb2-inner');
if (!inner) return '';
let chapter = '';
const titles = inner.querySelectorAll('.fb2-title');
for (let i = 0; i < titles.length; i++) {
if (titles[i].offsetLeft <= offset) chapter = titles[i].textContent;
else break;
}
return chapter;
}
function getCurrentFb2Chapter() { // Поиск заголовка, который сейчас виден на экране
const container = els.fb2Content;
if (!container) return '';
const currentOffset = state.fb2CurrentPage * container.clientWidth + 1;
return getFb2ChapterAtOffset(currentOffset);
}
function goToFb2Position(progress) { // Переход по проценту (0.0 - 1.0)
showFb2Page(Math.round(progress * (state.fb2TotalPages - 1)));
}
function sendBookSearchResults() {
sendMessage('bookSearchResults', {
query: state.search.query,
total: state.search.results.length,
currentIndex: state.search.currentIndex,
searching: state.search.searching
});
}
function makeSearchExcerpt(text, matchIndex, queryLength) {
const source = (text || '').replace(/\s+/g, ' ').trim();
if (!source) return '';
const start = Math.max(0, matchIndex - 48);
const end = Math.min(source.length, matchIndex + queryLength + 72);
const prefix = start > 0 ? '…' : '';
const suffix = end < source.length ? '…' : '';
return prefix + source.slice(start, end) + suffix;
}
function normalizeBookHref(href) {
return (href || '')
.split('#')[0]
.replace(/\\/g, '/')
.replace(/^(\.\.\/)+/, '')
.replace(/^\//, '');
}
function hrefsReferToSameSection(left, right) {
const a = normalizeBookHref(left);
const b = normalizeBookHref(right);
if (!a || !b) return false;
return a === b || a.endsWith('/' + b) || b.endsWith('/' + a);
}
function chapterForHref(href) {
if (!href) return '';
const normalized = normalizeBookHref(href);
const chapter = state.toc.find(item => hrefsReferToSameSection(href, item.href));
return chapter ? chapter.label : normalized;
}
function clearEpubSearchHighlights() {
if (!state.rendition || !state.rendition.annotations) return;
state.search.results.forEach(result => {
if (result.cfi) {
try {
state.rendition.annotations.remove(result.cfi, 'highlight');
} catch (e) {
debugLog('Search highlight remove failed: ' + e.message);
}
}
});
}
function highlightEpubSearchResults(results) {
if (!state.rendition || !state.rendition.annotations) return;
results.forEach(result => {
if (result.cfi) {
try {
state.rendition.annotations.highlight(result.cfi, {}, null, 'book-search-highlight');
} catch (e) {
debugLog('Search highlight add failed: ' + e.message);
}
}
});
}
async function searchEpub(query, generation) {
const spine = (state.book && state.book.spine && (state.book.spine.spineItems || state.book.spine.items)) || [];
const results = [];
for (const section of spine) {
if (generation !== state.search.generation) return [];
try {
if (typeof section.load === 'function') {
await section.load(state.book.load.bind(state.book));
}
const matches = typeof section.find === 'function' ? section.find(query) : [];
matches.forEach(match => {
results.push({
cfi: match.cfi,
excerpt: match.excerpt || query,
href: section.href || '',
chapter: chapterForHref(section.href || '')
});
});
} catch (e) {
debugLog('EPUB search section failed: ' + e.message);
} finally {
if (typeof section.unload === 'function') {
section.unload();
}
}
}
return results;
}
function searchFb2(query) {
const inner = $('fb2-inner');
if (!inner) return [];
const normalizedQuery = query.toLocaleLowerCase();
const totalTextLength = Math.max(1, inner.textContent.length);
const pageWidth = Math.max(1, els.fb2Content.clientWidth);
const totalPages = Math.max(1, state.fb2TotalPages);
const nodes = Array.from(inner.querySelectorAll('p,h1,h2,h3,h4,blockquote,li'));
const results = [];
let textOffset = 0;
nodes.forEach(node => {
const text = node.textContent || '';
const normalizedText = text.toLocaleLowerCase();
let matchIndex = normalizedText.indexOf(normalizedQuery);
while (matchIndex >= 0) {
const absoluteOffset = textOffset + matchIndex;
const pageByText = Math.floor((absoluteOffset / totalTextLength) * (totalPages - 1));
const pageByElement = node.offsetLeft > 0 ? Math.floor(node.offsetLeft / pageWidth) : pageByText;
const page = Math.max(0, Math.min(totalPages - 1, pageByElement));
const matchOffset = node.offsetLeft > 0 ? node.offsetLeft : page * pageWidth;
results.push({
page,
excerpt: makeSearchExcerpt(text, matchIndex, query.length),
chapter: getFb2ChapterAtOffset(matchOffset)
});
matchIndex = normalizedText.indexOf(normalizedQuery, matchIndex + normalizedQuery.length);
}
textOffset += text.length + 1;
});
return results;
}
async function searchBookInternal(rawQuery) {
const query = (rawQuery || '').trim();
clearEpubSearchHighlights();
const generation = state.search.generation + 1;
state.search = {
query,
results: [],
currentIndex: -1,
generation,
searching: query.length > 0
};
sendBookSearchResults();
if (!query) {
return;
}
const results = state.bookFormat === 'epub'
? await searchEpub(query, generation)
: searchFb2(query);
if (generation !== state.search.generation) {
return;
}
state.search.results = results;
state.search.searching = false;
state.search.currentIndex = results.length > 0 ? 0 : -1;
if (state.bookFormat === 'epub') {
highlightEpubSearchResults(results);
}
if (results.length > 0) {
showBookSearchResult(0);
} else {
sendBookSearchResults();
}
}
function showBookSearchResult(index) {
const results = state.search.results;
if (!results.length) {
sendBookSearchResults();
return;
}
const nextIndex = ((index % results.length) + results.length) % results.length;
state.search.currentIndex = nextIndex;
const result = results[nextIndex];
if (state.bookFormat === 'epub' && state.rendition && result.cfi) {
state.rendition.display(result.cfi);
} else if (state.bookFormat === 'fb2') {
showFb2Page(result.page || 0);
updateFb2Progress();
}
sendBookSearchResults();
}
// ========== PUBLIC API (Методы, доступные извне, например из C#) ==========
window.searchBook = function (query) {
searchBookInternal(query);
return 'started';
};
window.nextBookSearchResult = function () {
showBookSearchResult(state.search.currentIndex + 1);
};
window.previousBookSearchResult = function () {
showBookSearchResult(state.search.currentIndex - 1);
};
window.clearBookSearch = function () {
clearEpubSearchHighlights();
state.search = {
query: '',
results: [],
currentIndex: -1,
generation: state.search.generation + 1,
searching: false
};
sendBookSearchResults();
};
window.loadBookFromBase64 = function (base64Data, format, lastPosition, cachedLocations) {
window.clearBookSearch();
state.lastSelection = null;
state.isBookLoaded = false;
state.bookFormat = format;
if (format === 'epub') {
// Передаем кэш в основную функцию загрузки
loadEpubFromBase64(base64Data, lastPosition, cachedLocations);
}
else if (format === 'fb2') {
loadFb2FromBase64(base64Data, lastPosition);
}
else showError('Unsupported format: ' + format);
};
window.nextPage = function () { // Листать вперед
if (state.bookFormat === 'epub' && state.rendition) {
sendMessage('readerNavigation', {});
state.rendition.next();
}
else if (state.bookFormat === 'fb2' && state.fb2CurrentPage < state.fb2TotalPages - 1) {
showFb2Page(state.fb2CurrentPage + 1);
updateFb2Progress();
sendMessage('readerNavigation', {});
}
};
window.prevPage = function () { // Листать назад
if (state.bookFormat === 'epub' && state.rendition) {
sendMessage('readerNavigation', {});
state.rendition.prev();
}
else if (state.bookFormat === 'fb2' && state.fb2CurrentPage > 0) {
showFb2Page(state.fb2CurrentPage - 1);
updateFb2Progress();
sendMessage('readerNavigation', {});
}
};
window.goToProgress = function (value) {
const progress = Math.max(0, Math.min(1, Number(value) || 0));
if (state.bookFormat === 'epub' && state.rendition && state.book && state.book.locations.length() > 0) {
const target = state.book.locations.cfiFromPercentage(progress);
if (target) state.rendition.display(target);
}
else if (state.bookFormat === 'fb2') {
const targetPage = Math.round(progress * Math.max(0, state.fb2TotalPages - 1));
showFb2Page(targetPage);
updateFb2Progress();
}
};
function applyEpubReaderStyles(palette) {
if (!state.rendition) {
return;
}
state.rendition.themes.default({
'body': {
'font-family': state.currentFontFamily + ' !important',
'font-size': state.currentFontSize + 'px !important',
'line-height': '1.68 !important',
'padding': '28px 24px !important',
'background-color': palette.background + ' !important',
'color': palette.color + ' !important',
'hyphens': 'auto !important',
'overflow-wrap': 'break-word !important',
'-webkit-user-select': 'text !important',
'user-select': 'text !important',
'-webkit-touch-callout': 'default !important'
},
'p': { 'text-indent': '1.35em', 'margin-bottom': '0.75em' }
});
state.rendition.themes.fontSize(state.currentFontSize + 'px');
state.rendition.themes.font(state.currentFontFamily);
}
window.setFontSize = function (size) { // Изменение размера шрифта
state.currentFontSize = size;
if (state.bookFormat === 'epub' && state.rendition) {
applyEpubReaderStyles(getThemePalette(state.currentTheme));
scheduleReaderRelayout('font-size', true);
}
else if (state.bookFormat === 'fb2') {
const inner = $('fb2-inner');
if (inner) {
inner.style.fontSize = size + 'px';
scheduleReaderRelayout('font-size', true);
}
}
};
window.setFontFamily = function (family) { // Изменение гарнитуры шрифта
state.currentFontFamily = family;
if (state.bookFormat === 'epub' && state.rendition) {
applyEpubReaderStyles(getThemePalette(state.currentTheme));
scheduleReaderRelayout('font-family', true);
}
else if (state.bookFormat === 'fb2') {
const inner = $('fb2-inner');
if (inner) {
inner.style.fontFamily = family;
scheduleReaderRelayout('font-family', true);
}
}
};
function getThemePalette(theme) {
switch (theme) {
case 'dark':
return { background: '#181411', color: '#F4E7D8' };
case 'light':
return { background: '#FFFDF8', color: '#2B1B15' };
default:
return { background: '#FAF1E2', color: '#33231B' };
}
}
function applyReaderTheme() {
const palette = getThemePalette(state.currentTheme);
document.body.style.backgroundColor = palette.background;
els.bookContent.style.backgroundColor = palette.background;
els.fb2Content.style.backgroundColor = palette.background;
applyEpubReaderStyles(palette);
const inner = $('fb2-inner');
if (inner) {
inner.style.backgroundColor = palette.background;
inner.style.color = palette.color;
}
}
function applyReaderBrightness() {
const brightness = Math.max(0.7, Math.min(1.2, state.brightness / 100));
els.bookContent.style.filter = `brightness(${brightness})`;
els.fb2Content.style.filter = `brightness(${brightness})`;
}
window.setReaderTheme = function (theme) {
state.currentTheme = theme || 'light';
applyReaderTheme();
};
window.setBrightness = function (value) {
state.brightness = value || 100;
applyReaderBrightness();
};
window.goToChapter = function (href) { // Переход к главе из оглавления
if (state.bookFormat === 'epub' && state.rendition) state.rendition.display(href);
else if (state.bookFormat === 'fb2') {
const inner = $('fb2-inner');
const el = inner
? Array.from(inner.querySelectorAll('[data-chapter]')).find(item => item.getAttribute('data-chapter') === href)
: null;
if (el) {
showFb2Page(Math.floor(el.offsetLeft / els.fb2Content.clientWidth));
updateFb2Progress();
}
}
};
window.goToPosition = function (position) {
if (!position) return;
if (state.bookFormat === 'epub' && state.rendition) {
state.rendition.display(position);
} else if (state.bookFormat === 'fb2') {
const progress = parseFloat(position);
if (!Number.isNaN(progress)) {
goToFb2Position(progress);
updateFb2Progress();
}
}
};
window.getProgress = function () { // Запрос текущего состояния прогресса (в JSON)
if (state.bookFormat === 'epub' && state.book && state.currentCfi) {
try {
return JSON.stringify({
progress: state.book.locations.percentageFromCfi(state.currentCfi) || 0,
cfi: state.currentCfi,
currentPage: state.currentPage,
totalPages: state.totalPages
});
} catch (e) { return '{}'; }
} else if (state.bookFormat === 'fb2') {
const p = state.fb2TotalPages > 1 ? state.fb2CurrentPage / (state.fb2TotalPages - 1) : 0;
return JSON.stringify({
progress: p, cfi: p.toString(),
currentPage: state.fb2CurrentPage + 1, totalPages: state.fb2TotalPages
});
}
return '{}';
};
window.getSelectionSnapshot = function () {
if (state.bookFormat === 'epub' && state.book) {
const selection = state.lastSelection || {};
const cfi = selection.cfi || state.currentCfi || state.lastCfi || '';
let progress = 0;
try {
progress = cfi ? (state.book.locations.percentageFromCfi(cfi) || 0) : 0;
} catch (e) {
progress = 0;
}
return JSON.stringify({
selectedText: selection.text || '',
progress,
cfi,
currentPage: state.currentPage || Math.round(progress * 100),
totalPages: state.totalPages || 100
});
}
if (state.bookFormat === 'fb2') {
const p = state.fb2TotalPages > 1 ? state.fb2CurrentPage / (state.fb2TotalPages - 1) : 0;
let selectedText = '';
try {
selectedText = window.getSelection ? window.getSelection().toString() : '';
} catch (e) {
selectedText = '';
}
return JSON.stringify({
selectedText,
progress: p,
cfi: p.toString(),
currentPage: state.fb2CurrentPage + 1,
totalPages: state.fb2TotalPages,
chapter: getCurrentFb2Chapter()
});
}
return '{}';
};
// ========== ИНИЦИАЛИЗАЦИЯ ==========
initReaderGestures();
initReaderResizeHandling();
setLoadingText('Ожидаю файл книги...'); // Сообщаем, что готовы принимать файл
sendMessage('readerReady', {}); // Уведомляем нативное приложение: "Я загрузился!"
})();
</script>
</body>
</html>