This commit is contained in:
Курнат Андрей
2026-02-17 20:37:27 +03:00
parent ee0fa14ee7
commit e2f50bf57e
6 changed files with 246 additions and 265 deletions

View File

@@ -24,6 +24,7 @@ public class Book
public string? LastCfi { get; set; } // CFI location for epub, or position for fb2 public string? LastCfi { get; set; } // CFI location for epub, or position for fb2
public string? LastChapter { get; set; } public string? LastChapter { get; set; }
public string? Locations { get; set; }
public DateTime DateAdded { get; set; } = DateTime.UtcNow; public DateTime DateAdded { get; set; } = DateTime.UtcNow;

View File

@@ -5,31 +5,31 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Book Reader</title> <title>Book Reader</title>
<style> <style>
* { * { /* Сброс отступов и установка box-sizing для всех элементов */
margin: 0; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
} }
html, body { html, body { /* Растягиваем страницу на весь экран, отключаем прокрутку и выделение текста */
width: 100%; width: 100%;
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
background-color: #faf8ef; background-color: #faf8ef; /* Цвет "сепия" для комфортного чтения */
font-family: serif; font-family: serif;
-webkit-touch-callout: none; -webkit-touch-callout: none; /* Запрет контекстного меню при долгом нажатии (iOS) */
-webkit-user-select: none; -webkit-user-select: none; /* Запрет выделения текста */
user-select: none; user-select: none;
} }
#reader-container { #reader-container { /* Основной контейнер для книги */
width: 100%; width: 100%;
height: 100%; height: 100%;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
} }
#book-content, #fb2-content { #book-content, #fb2-content { /* Контейнеры для EPUB и FB2 соответственно */
width: 100%; width: 100%;
height: 100%; height: 100%;
position: absolute; position: absolute;
@@ -37,7 +37,7 @@
left: 0; left: 0;
} }
#fb2-content { #fb2-content { /* Специфичные настройки для FB2: отступы и скрытие по умолчанию */
overflow: hidden; overflow: hidden;
padding: 20px; padding: 20px;
font-size: 18px; font-size: 18px;
@@ -45,7 +45,7 @@
display: none; display: none;
} }
#loading { #loading { /* Центрированный экран загрузки */
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@@ -56,7 +56,7 @@
gap: 15px; gap: 15px;
} }
.spinner { .spinner { /* Анимация крутящегося индикатора загрузки */
width: 40px; width: 40px;
height: 40px; height: 40px;
border: 4px solid #ddd; border: 4px solid #ddd;
@@ -65,13 +65,13 @@
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
} }
@keyframes spin { @keyframes spin { /* Описание вращения спиннера */
to { to {
transform: rotate(360deg); transform: rotate(360deg);
} }
} }
#error-display { #error-display { /* Контейнер для вывода ошибок */
display: none; display: none;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@@ -84,7 +84,7 @@
gap: 10px; gap: 10px;
} }
/* Прозрачные зоны для тачей */ /* Прозрачные зоны для управления тапами (назад, меню, вперед) */
.touch-zone { .touch-zone {
position: fixed; position: fixed;
top: 0; top: 0;
@@ -96,23 +96,22 @@
left: 0; left: 0;
width: 30%; width: 30%;
} }
/* Левая треть экрана — назад */
#touch-right { #touch-right {
right: 0; right: 0;
width: 30%; width: 30%;
} }
/* Правая треть — вперед */
#touch-center { #touch-center {
left: 30%; left: 30%;
width: 40%; width: 40%;
} }
/* Центр — открыть меню */
</style> </style>
</head> </head>
<body> <body>
<div id="reader-container"> <div id="reader-container">
<div id="book-content"></div> <div id="book-content"></div> <div id="fb2-content"></div> <div id="loading">
<div id="fb2-content"></div>
<div id="loading">
<div class="spinner"></div> <div class="spinner"></div>
<span id="loading-text">Initializing...</span> <span id="loading-text">Initializing...</span>
</div> </div>
@@ -127,14 +126,20 @@
<script src="_framework/hybridwebview.js"></script> <script src="_framework/hybridwebview.js"></script>
<script src="js/jszip.min.js"></script> <script src="js/jszip.min.js"></script>
<script src="js/epub.min.js"></script> <script src="js/epub.min.js"></script> ```
---
### JavaScript Логика
```javascript
<script> <script>
(function () { (function () { // Самовызывающаяся функция для изоляции переменных
'use strict'; 'use strict';
// ========== КЭШИРОВАННЫЕ DOM-ЭЛЕМЕНТЫ ========== // ========== КЭШИРОВАННЫЕ DOM-ЭЛЕМЕНТЫ ==========
const $ = id => document.getElementById(id); const $ = id => document.getElementById(id); // Короткий псевдоним для поиска элементов
const els = { const els = { // Объект со ссылками на элементы для быстрого доступа
loading: $('loading'), loading: $('loading'),
loadingText: $('loading-text'), loadingText: $('loading-text'),
errorDisplay: $('error-display'), errorDisplay: $('error-display'),
@@ -143,58 +148,56 @@
fb2Content: $('fb2-content'), fb2Content: $('fb2-content'),
}; };
// ========== СОСТОЯНИЕ ========== // ========== СОСТОЯНИЕ (STATE) ==========
const state = { const state = { // Глобальный объект состояния текущей книги
book: null, book: null, // Объект книги epub.js
rendition: null, rendition: null, // Объект отображения epub.js
currentCfi: null, currentCfi: null, // Текущая позиция (идентификатор) в EPUB
totalPages: 0, totalPages: 0, // Всего страниц
currentPage: 0, currentPage: 0, // Текущая страница
bookFormat: '', bookFormat: '', // epub или fb2
isBookLoaded: false, isBookLoaded: false,
fb2CurrentPage: 0, fb2CurrentPage: 0,
fb2TotalPages: 1, fb2TotalPages: 1,
toc: [] toc: [] // Оглавление
}; };
// ========== УТИЛИТЫ ========== // ========== УТИЛИТЫ ==========
function debugLog(msg) { function debugLog(msg) { // Логирование в консоль с префиксом
console.log('[Reader] ' + msg); console.log('[Reader] ' + msg);
} }
function showError(msg) { function showError(msg) { // Показ экрана ошибки пользователю
els.loading.style.display = 'none'; els.loading.style.display = 'none';
els.errorDisplay.style.display = 'flex'; els.errorDisplay.style.display = 'flex';
els.errorText.textContent = msg; els.errorText.textContent = msg;
debugLog('ERROR: ' + msg); debugLog('ERROR: ' + msg);
} }
function setLoadingText(msg) { function setLoadingText(msg) { // Обновление текста на экране загрузки
if (els.loadingText) els.loadingText.textContent = msg; if (els.loadingText) els.loadingText.textContent = msg;
debugLog(msg); debugLog(msg);
} }
// Один div для escapeHtml — переиспользуем const _escDiv = document.createElement('div'); // Буфер для очистки HTML
const _escDiv = document.createElement('div'); function escapeHtml(text) { // Защита от XSS: превращает < в &lt; и т.д.
function escapeHtml(text) {
_escDiv.textContent = text; _escDiv.textContent = text;
return _escDiv.innerHTML; return _escDiv.innerHTML;
} }
function base64ToArrayBuffer(base64) { function base64ToArrayBuffer(base64) { // Конвертация данных из строки (Base64) в бинарный массив
const bin = atob(base64); const bin = atob(base64); // Декодирование base64
const len = bin.length; const len = bin.length;
const buf = new ArrayBuffer(len); const buf = new ArrayBuffer(len);
const view = new Uint8Array(buf); const view = new Uint8Array(buf);
// Обработка блоками для больших файлов — снижает давление на GC
for (let i = 0; i < len; i++) { for (let i = 0; i < len; i++) {
view[i] = bin.charCodeAt(i); view[i] = bin.charCodeAt(i); // Заполнение массива байтами
} }
return buf; return buf;
} }
// ========== MESSAGE BRIDGE ========== // ========== MESSAGE BRIDGE (Связь с приложением) ==========
function sendMessage(action, data) { function sendMessage(action, data) { // Отправка данных в нативный код (C# / Swift / Kotlin)
const message = JSON.stringify({ action, data: data || {} }); const message = JSON.stringify({ action, data: data || {} });
try { try {
if (window.HybridWebView && typeof window.HybridWebView.SendRawMessage === 'function') { if (window.HybridWebView && typeof window.HybridWebView.SendRawMessage === 'function') {
@@ -205,70 +208,67 @@
} }
} }
// ========== TOUCH / SWIPE ========== // ========== TOUCH / SWIPE (Жесты) ==========
let swipeStartX = 0, swipeStartY = 0, swipeStartTime = 0; let swipeStartX = 0, swipeStartY = 0, swipeStartTime = 0;
const touchActions = { const touchActions = { // Действия при клике на зоны
'touch-left': () => window.prevPage(), 'touch-left': () => window.prevPage(),
'touch-right': () => window.nextPage(), 'touch-right': () => window.nextPage(),
'touch-center': () => sendMessage('toggleMenu', {}), 'touch-center': () => sendMessage('toggleMenu', {}), // Показать меню приложения
}; };
function initTouchZones() { function initTouchZones() { // Настройка слушателей событий на зоны тача
Object.keys(touchActions).forEach(id => { Object.keys(touchActions).forEach(id => {
const el = $(id); const el = $(id);
if (!el) return; if (!el) return;
el.addEventListener('click', e => { el.addEventListener('click', e => { // Обработка обычного клика
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
touchActions[id](); touchActions[id]();
}); });
el.addEventListener('touchstart', e => { el.addEventListener('touchstart', e => { // Начало касания для свайпа
const t = e.touches[0]; const t = e.touches[0];
swipeStartX = t.clientX; swipeStartX = t.clientX;
swipeStartY = t.clientY; swipeStartY = t.clientY;
swipeStartTime = Date.now(); swipeStartTime = Date.now();
}, { passive: true }); }, { passive: true });
el.addEventListener('touchend', e => { el.addEventListener('touchend', e => { // Конец касания: расчет свайпа
const t = e.changedTouches[0]; const t = e.changedTouches[0];
const dx = t.clientX - swipeStartX; const dx = t.clientX - swipeStartX; // Смещение по горизонтали
const dt = Date.now() - swipeStartTime; const dy = t.clientY - swipeStartY; // Смещение по вертикали
if (dt < 500 && Math.abs(dx) > Math.abs(t.clientY - swipeStartY) && Math.abs(dx) > 50) { const dt = Date.now() - swipeStartTime; // Время касания
// Если быстро (меньше 0.5с), горизонтально и длиннее 50px
if (dt < 500 && Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 50) {
dx < 0 ? window.nextPage() : window.prevPage(); dx < 0 ? window.nextPage() : window.prevPage();
} }
}, { passive: true }); }, { passive: true });
}); });
} }
// ========== EPUB ========== // ========== EPUB LOGIC ==========
function loadEpubFromBase64(base64Data, lastCfi) { function loadEpubFromBase64(base64Data, lastCfi, cachedLocations) {
setLoadingText('Decoding EPUB...'); setLoadingText('Decoding EPUB...');
try { try {
const arrayBuffer = base64ToArrayBuffer(base64Data); const arrayBuffer = base64ToArrayBuffer(base64Data);
debugLog('EPUB size: ' + arrayBuffer.byteLength + ' bytes');
if (typeof JSZip === 'undefined') { showError('JSZip not loaded!'); return; } if (typeof JSZip === 'undefined') { showError('JSZip not loaded!'); return; }
if (typeof ePub === 'undefined') { showError('epub.js not loaded!'); return; } if (typeof ePub === 'undefined') { showError('epub.js not loaded!'); return; }
setLoadingText('Opening EPUB...'); state.book = ePub(arrayBuffer); // Создание объекта книги из данных
// epub.js сам умеет работать с ArrayBuffer — двойная распаковка не нужна
state.book = ePub(arrayBuffer);
els.fb2Content.style.display = 'none'; els.fb2Content.style.display = 'none';
els.bookContent.style.display = 'block'; els.bookContent.style.display = 'block';
// Рендеринг (отрисовка) книги в контейнер
state.rendition = state.book.renderTo('book-content', { state.rendition = state.book.renderTo('book-content', {
width: '100%', width: '100%',
height: '100%', height: '100%',
spread: 'none', spread: 'none', // Без двухстраничного режима
flow: 'paginated' flow: 'paginated' // Режим постраничного отображения
}); });
// Настройка стилей внутри фрейма книги
state.rendition.themes.default({ state.rendition.themes.default({
'body': { 'body': {
'font-family': 'serif !important', 'font-family': 'serif !important',
@@ -281,149 +281,124 @@
'p': { 'text-indent': '1.5em', 'margin-bottom': '0.5em' } 'p': { 'text-indent': '1.5em', 'margin-bottom': '0.5em' }
}); });
state.book.ready state.book.ready.then(() => {
.then(() => { // Убираем экран загрузки СРАЗУ, как только книга готова к отрисовке первой страницы
debugLog('EPUB ready'); els.loading.style.display = 'none';
els.loading.style.display = 'none';
try { // Обработка оглавления
const toc = state.book.navigation.toc || []; const toc = state.book.navigation.toc || [];
state.toc = toc.map(ch => ({ state.toc = toc.map(ch => ({ label: ch.label.trim(), href: ch.href }));
label: (ch.label || '').trim(), sendMessage('chaptersLoaded', { chapters: state.toc });
href: ch.href || ''
}));
sendMessage('chaptersLoaded', { chapters: state.toc });
} catch (e) {
debugLog('TOC error: ' + e.message);
}
const charsPerScreen = Math.max(600, Math.floor((window.innerWidth * window.innerHeight) / 400));
return state.book.locations.generate(charsPerScreen); // ПРОВЕРКА КЭША: Если мы уже передали сохраненные локации
}) if (cachedLocations) {
.then(() => { debugLog("Загрузка из кэша...");
state.book.locations.load(cachedLocations);
state.totalPages = state.book.locations.length(); state.totalPages = state.book.locations.length();
debugLog('Pages: ' + state.totalPages);
sendMessage('bookReady', { totalPages: state.totalPages }); sendMessage('bookReady', { totalPages: state.totalPages });
state.isBookLoaded = true; } else {
}) debugLog("Кэша нет, считаем в фоне...");
.catch(e => debugLog('Book ready error: ' + e.message)); // Используем setTimeout, чтобы не блокировать поток отрисовки
setTimeout(() => {
state.book.locations.generate(2000).then(() => {
state.totalPages = state.book.locations.length();
const locationsToSave = state.book.locations.save();
state.rendition.on('relocated', location => { // Отправляем в C#, чтобы сохранить на будущее
if (!location || !location.start) return; sendMessage('saveLocations', { locations: locationsToSave });
state.currentCfi = location.start.cfi; sendMessage('bookReady', { totalPages: state.totalPages });
// 1. Достаем страницы ВНУТРИ главы });
// Если epub.js не успел посчитать (бывает на долю секунды), ставим 1 }, 100);
const chapterPage = location.start.displayed ? location.start.displayed.page : 1;
const chapterTotal = location.start.displayed ? location.start.displayed.total : 1;
// 2. Ищем красивое название главы по её href
const currentHref = location.start.href || '';
let chapterName = currentHref; // По умолчанию системное имя
// Пытаемся найти точное совпадение в сохраненном оглавлении
const foundChapter = state.toc.find(ch => currentHref.includes(ch.href));
if (foundChapter) {
chapterName = foundChapter.label;
}
try {
sendMessage('progressUpdate', {
progress: state.book.locations.percentageFromCfi(state.currentCfi) || 0,
cfi: state.currentCfi,
currentPage: location.start.location || 0, // Глобальная страница
totalPages: state.totalPages, // Всего глобальных страниц
chapterCurrentPage: chapterPage, // <--- Страница в главе!
chapterTotalPages: chapterTotal, // <--- Всего страниц в главе!
chapter: chapterName // <--- Красивое имя главы
});
} catch (e) {
debugLog('Relocated error: ' + e.message);
} }
}); });
setLoadingText('Rendering...'); // Событие при смене страницы
const displayTarget = (lastCfi && lastCfi !== 'null' && lastCfi !== 'undefined') state.rendition.on('relocated', location => {
? lastCfi : undefined; if (!location || !location.start) return;
state.currentCfi = location.start.cfi;
const chapterPage = location.start.displayed ? location.start.displayed.page : 1;
const chapterTotal = location.start.displayed ? location.start.displayed.total : 1;
const currentHref = location.start.href || '';
// Поиск человеческого названия текущей главы
let chapterName = currentHref;
const foundChapter = state.toc.find(ch => currentHref.includes(ch.href));
if (foundChapter) chapterName = foundChapter.label;
// Отправка прогресса в приложение
sendMessage('progressUpdate', {
progress: state.book.locations.percentageFromCfi(state.currentCfi) || 0,
cfi: state.currentCfi,
currentPage: location.start.location || 0,
totalPages: state.totalPages,
chapterCurrentPage: chapterPage,
chapterTotalPages: chapterTotal,
chapter: chapterName
});
});
// Отображение книги на сохраненной позиции или в начале
const displayTarget = (lastCfi && lastCfi !== 'null' && lastCfi !== 'undefined') ? lastCfi : undefined;
state.rendition.display(displayTarget); state.rendition.display(displayTarget);
} catch (e) { } catch (e) { showError('EPUB load error: ' + e.message); }
showError('EPUB load error: ' + e.message);
}
} }
// ========== FB2 ========== // ========== FB2 LOGIC ==========
function loadFb2FromBase64(base64Data, lastPosition) { function loadFb2FromBase64(base64Data, lastPosition) {
setLoadingText('Parsing FB2...'); setLoadingText('Parsing FB2...');
try { try {
const arrayBuffer = base64ToArrayBuffer(base64Data); const arrayBuffer = base64ToArrayBuffer(base64Data);
const bytes = new Uint8Array(arrayBuffer); const bytes = new Uint8Array(arrayBuffer);
let xmlText = new TextDecoder('utf-8').decode(bytes); let xmlText = new TextDecoder('utf-8').decode(bytes);
// Перекодируем если нужно // Проверка кодировки в заголовке XML (если не UTF-8, перекодируем)
const encMatch = xmlText.match(/encoding=["\']([^"\']+)["\']/i); const encMatch = xmlText.match(/encoding=["\']([^"\']+)["\']/i);
if (encMatch) { if (encMatch && encMatch[1].toLowerCase() !== 'utf-8') {
const enc = encMatch[1].toLowerCase(); xmlText = new TextDecoder(encMatch[1]).decode(bytes);
debugLog('FB2 encoding: ' + enc);
if (enc !== 'utf-8') {
try { xmlText = new TextDecoder(enc).decode(bytes); }
catch (e) { debugLog('Re-decode error: ' + e.message); }
}
} }
els.bookContent.style.display = 'none'; els.bookContent.style.display = 'none';
els.fb2Content.style.display = 'block'; els.fb2Content.style.display = 'block';
// Парсинг строки в XML-документ
const doc = new DOMParser().parseFromString(xmlText, 'text/xml'); const doc = new DOMParser().parseFromString(xmlText, 'text/xml');
if (doc.querySelector('parsererror')) { if (doc.querySelector('parsererror')) { showError('FB2 XML parse error'); return; }
showError('FB2 XML parse error');
return;
}
const fb2Html = parseFb2Document(doc); const fb2Html = parseFb2Document(doc); // Преобразование FB2 XML в HTML
els.fb2Content.innerHTML = fb2Html.html; els.fb2Content.innerHTML = fb2Html.html;
els.loading.style.display = 'none'; els.loading.style.display = 'none';
// Используем requestAnimationFrame вместо setTimeout для точного тайминга // Использование анимационных кадров для замера размеров после вставки в DOM
requestAnimationFrame(() => { requestAnimationFrame(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
setupFb2Pagination(); setupFb2Pagination(); // Нарезка на колонки
sendMessage('chaptersLoaded', { chapters: fb2Html.chapters }); sendMessage('chaptersLoaded', { chapters: fb2Html.chapters });
sendMessage('bookReady', { totalPages: state.totalPages }); sendMessage('bookReady', { totalPages: state.totalPages });
state.isBookLoaded = true; state.isBookLoaded = true;
state.bookFormat = 'fb2'; state.bookFormat = 'fb2';
if (lastPosition) goToFb2Position(parseFloat(lastPosition));
if (lastPosition && parseFloat(lastPosition) > 0) {
goToFb2Position(parseFloat(lastPosition));
}
updateFb2Progress(); updateFb2Progress();
}); });
}); });
} catch (e) { showError('FB2 error: ' + e.message); }
} catch (e) {
showError('FB2 error: ' + e.message);
}
} }
// ========== FB2 PARSER ========== // ========== FB2 PARSER (Преобразование тегов) ==========
// Вспомогательная: получить локальное имя тега без namespace function localTag(el) { // Утилита для получения имени тега без пространств имен (типа fb2:)
function localTag(el) {
return el.tagName ? el.tagName.toLowerCase().replace(/.*:/, '') : ''; return el.tagName ? el.tagName.toLowerCase().replace(/.*:/, '') : '';
} }
function parseFb2Document(doc) { function parseFb2Document(doc) {
const chapters = []; const chapters = [];
const parts = ['<div id="fb2-inner" style="font-size:18px;font-family:serif;line-height:1.6;">']; const parts = ['<div id="fb2-inner" style="font-size:18px;font-family:serif;line-height:1.6;">'];
let bodies = doc.querySelectorAll('body'); // FB2 может иметь несколько body (основной текст, сноски)
let bodies = doc.querySelectorAll('body');
if (!bodies.length) {
bodies = doc.getElementsByTagNameNS('http://www.gribuser.ru/xml/fictionbook/2.0', 'body');
}
let chapterIndex = 0; let chapterIndex = 0;
for (const body of bodies) { for (const body of bodies) {
for (const child of body.children) { for (const child of body.children) {
if (localTag(child) === 'section') { if (localTag(child) === 'section') { // Секции — это главы
const result = parseFb2Section(child, chapterIndex); const result = parseFb2Section(child, chapterIndex);
parts.push(result.html); parts.push(result.html);
chapters.push(...result.chapters); chapters.push(...result.chapters);
@@ -431,45 +406,33 @@
} }
} }
} }
parts.push('</div>'); parts.push('</div>');
if (!chapters.length) chapters.push({ label: 'Start', href: '0' });
return { html: parts.join(''), chapters }; return { html: parts.join(''), chapters };
} }
// Маппинг тегов FB2 → генераторы HTML // Обработчики конкретных тегов FB2
const sectionTagHandlers = { const sectionTagHandlers = {
title(child, idx, chapters) { title(child, idx, chapters) { // Заголовки глав
const text = (child.textContent || '').trim(); const text = (child.textContent || '').trim();
chapters.push({ label: text, href: idx.toString() }); 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>`; return `<h2 class="fb2-title" data-chapter="${idx}" style="text-align:center;margin:1em 0 .5em">${escapeHtml(text)}</h2>`;
}, },
p(child) { p(child) { // Абзацы
return `<p style="text-indent:1.5em;margin-bottom:.3em">${getInlineHtml(child)}</p>`; return `<p style="text-indent:1.5em;margin-bottom:.3em">${getInlineHtml(child)}</p>`;
}, },
'empty-line'() { return '<br/>'; }, 'empty-line'() { return '<br/>'; },
subtitle(child) { subtitle(child) { return `<h3 style="text-align:center;margin:.8em 0">${escapeHtml(child.textContent || '')}</h3>`; },
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>`; },
epigraph(child) { cite(child) { return `<blockquote style="margin:1em 2em;padding-left:1em;border-left:3px solid #ccc">${parseInnerParagraphs(child)}</blockquote>`; },
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) { function parseFb2Section(section, startIndex) {
const chapters = []; const chapters = [];
const parts = [`<div class="fb2-section" data-section="${startIndex}">`]; const parts = [`<div class="fb2-section" data-section="${startIndex}">`];
for (const child of section.children) { for (const child of section.children) {
const tag = localTag(child); const tag = localTag(child);
if (tag === 'section') { // Рекурсия для вложенных секций
if (tag === 'section') {
const sub = parseFb2Section(child, startIndex + chapters.length); const sub = parseFb2Section(child, startIndex + chapters.length);
parts.push(sub.html); parts.push(sub.html);
chapters.push(...sub.chapters); chapters.push(...sub.chapters);
@@ -477,17 +440,15 @@
parts.push(sectionTagHandlers[tag](child, startIndex + chapters.length, chapters)); parts.push(sectionTagHandlers[tag](child, startIndex + chapters.length, chapters));
} }
} }
parts.push('</div>'); parts.push('</div>');
return { html: parts.join(''), chapters }; return { html: parts.join(''), chapters };
} }
function getInlineHtml(el) { function getInlineHtml(el) { // Обработка форматирования внутри строки (жирный, курсив)
const parts = []; const parts = [];
for (const node of el.childNodes) { for (const node of el.childNodes) {
if (node.nodeType === 3) { if (node.nodeType === 3) parts.push(escapeHtml(node.textContent)); // Текст
parts.push(escapeHtml(node.textContent)); else if (node.nodeType === 1) { // Тег
} else if (node.nodeType === 1) {
const tag = localTag(node); const tag = localTag(node);
const inner = getInlineHtml(node); const inner = getInlineHtml(node);
if (tag === 'strong' || tag === 'bold') parts.push(`<strong>${inner}</strong>`); if (tag === 'strong' || tag === 'bold') parts.push(`<strong>${inner}</strong>`);
@@ -499,7 +460,7 @@
return parts.join(''); return parts.join('');
} }
function parseInnerParagraphs(el) { function parseInnerParagraphs(el) { // Парсинг группы абзацев (для цитат/эпиграфов)
const parts = []; const parts = [];
for (const child of el.children) { for (const child of el.children) {
const tag = localTag(child); const tag = localTag(child);
@@ -509,7 +470,7 @@
return parts.join(''); return parts.join('');
} }
function parsePoem(el) { function parsePoem(el) { // Парсинг стихов (строфы и строки)
const parts = []; const parts = [];
for (const child of el.children) { for (const child of el.children) {
const tag = localTag(child); const tag = localTag(child);
@@ -526,7 +487,7 @@
return parts.join(''); return parts.join('');
} }
// ========== FB2 PAGINATION ========== // ========== FB2 PAGINATION (Имитация страниц через CSS Columns) ==========
function setupFb2Pagination() { function setupFb2Pagination() {
const container = els.fb2Content; const container = els.fb2Content;
const inner = $('fb2-inner'); const inner = $('fb2-inner');
@@ -535,16 +496,17 @@
const w = container.clientWidth; const w = container.clientWidth;
const h = container.clientHeight; const h = container.clientHeight;
// Основная магия: CSS превращает длинный текст в ряд колонок шириной с экран
Object.assign(inner.style, { Object.assign(inner.style, {
columnWidth: w + 'px', columnWidth: w + 'px',
columnGap: '40px', columnGap: '40px', // Зазор между колонками
columnFill: 'auto', columnFill: 'auto',
height: h + 'px', height: h + 'px',
overflow: 'hidden', overflow: 'hidden',
}); });
// Даём браузеру отрисовать колонки
requestAnimationFrame(() => { requestAnimationFrame(() => {
// Общая ширина контента делить на ширину экрана = количество страниц
state.fb2TotalPages = Math.max(1, Math.ceil(inner.scrollWidth / w)); state.fb2TotalPages = Math.max(1, Math.ceil(inner.scrollWidth / w));
state.totalPages = state.fb2TotalPages; state.totalPages = state.fb2TotalPages;
state.fb2CurrentPage = 0; state.fb2CurrentPage = 0;
@@ -553,16 +515,17 @@
}); });
} }
function showFb2Page(idx) { function showFb2Page(idx) { // Переход на конкретную страницу FB2
idx = Math.max(0, Math.min(idx, state.fb2TotalPages - 1)); idx = Math.max(0, Math.min(idx, state.fb2TotalPages - 1));
state.fb2CurrentPage = idx; state.fb2CurrentPage = idx;
const inner = $('fb2-inner'); const inner = $('fb2-inner');
if (inner) { if (inner) {
// Сдвигаем контент влево, чтобы показать нужную "колонку"
inner.style.transform = `translateX(-${idx * els.fb2Content.clientWidth}px)`; inner.style.transform = `translateX(-${idx * els.fb2Content.clientWidth}px)`;
} }
} }
function updateFb2Progress() { function updateFb2Progress() { // Оповещение приложения о прогрессе в FB2
const total = state.fb2TotalPages; const total = state.fb2TotalPages;
const progress = total > 1 ? state.fb2CurrentPage / (total - 1) : 0; const progress = total > 1 ? state.fb2CurrentPage / (total - 1) : 0;
sendMessage('progressUpdate', { sendMessage('progressUpdate', {
@@ -574,69 +537,67 @@
}); });
} }
function getCurrentFb2Chapter() { function getCurrentFb2Chapter() { // Поиск заголовка, который сейчас виден на экране
const inner = $('fb2-inner'); const inner = $('fb2-inner');
const container = els.fb2Content; const container = els.fb2Content;
if (!inner || !container) return ''; if (!inner || !container) return '';
const maxOffset = state.fb2CurrentPage * container.clientWidth + container.clientWidth; const maxOffset = state.fb2CurrentPage * container.clientWidth + container.clientWidth;
let chapter = ''; let chapter = '';
const titles = inner.querySelectorAll('.fb2-title'); const titles = inner.querySelectorAll('.fb2-title');
for (let i = 0; i < titles.length; i++) { for (let i = 0; i < titles.length; i++) {
if (titles[i].offsetLeft <= maxOffset) chapter = titles[i].textContent; if (titles[i].offsetLeft <= maxOffset) chapter = titles[i].textContent;
else break; // Заголовки идут по порядку — можно прервать else break;
} }
return chapter; return chapter;
} }
function goToFb2Position(progress) { function goToFb2Position(progress) { // Переход по проценту (0.0 - 1.0)
showFb2Page(Math.round(progress * (state.fb2TotalPages - 1))); showFb2Page(Math.round(progress * (state.fb2TotalPages - 1)));
} }
// ========== PUBLIC API ========== // ========== PUBLIC API (Методы, доступные извне, например из C#) ==========
window.loadBookFromBase64 = function (base64Data, format, lastPosition) { window.loadBookFromBase64 = function (base64Data, format, lastPosition, cachedLocations) {
debugLog(`loadBookFromBase64: format=${format}, len=${base64Data ? base64Data.length : 0}`);
state.bookFormat = format; state.bookFormat = format;
if (format === 'epub') {
if (format === 'epub') loadEpubFromBase64(base64Data, lastPosition); // Передаем кэш в основную функцию загрузки
else if (format === 'fb2') loadFb2FromBase64(base64Data, lastPosition); loadEpubFromBase64(base64Data, lastPosition, cachedLocations);
}
else if (format === 'fb2') {
loadFb2FromBase64(base64Data, lastPosition);
}
else showError('Unsupported format: ' + format); else showError('Unsupported format: ' + format);
}; };
window.nextPage = function () { window.nextPage = function () { // Листать вперед
if (state.bookFormat === 'epub' && state.rendition) { if (state.bookFormat === 'epub' && state.rendition) state.rendition.next();
state.rendition.next(); else if (state.bookFormat === 'fb2' && state.fb2CurrentPage < state.fb2TotalPages - 1) {
} else if (state.bookFormat === 'fb2' && state.fb2CurrentPage < state.fb2TotalPages - 1) {
showFb2Page(state.fb2CurrentPage + 1); showFb2Page(state.fb2CurrentPage + 1);
updateFb2Progress(); updateFb2Progress();
} }
}; };
window.prevPage = function () { window.prevPage = function () { // Листать назад
if (state.bookFormat === 'epub' && state.rendition) { if (state.bookFormat === 'epub' && state.rendition) state.rendition.prev();
state.rendition.prev(); else if (state.bookFormat === 'fb2' && state.fb2CurrentPage > 0) {
} else if (state.bookFormat === 'fb2' && state.fb2CurrentPage > 0) {
showFb2Page(state.fb2CurrentPage - 1); showFb2Page(state.fb2CurrentPage - 1);
updateFb2Progress(); updateFb2Progress();
} }
}; };
window.setFontSize = function (size) { window.setFontSize = function (size) { // Изменение размера шрифта
if (state.bookFormat === 'epub' && state.rendition) { if (state.bookFormat === 'epub' && state.rendition) state.rendition.themes.fontSize(size + 'px');
state.rendition.themes.fontSize(size + 'px'); else if (state.bookFormat === 'fb2') {
} else if (state.bookFormat === 'fb2') {
const inner = $('fb2-inner'); const inner = $('fb2-inner');
if (inner) { if (inner) {
inner.style.fontSize = size + 'px'; inner.style.fontSize = size + 'px';
requestAnimationFrame(setupFb2Pagination); requestAnimationFrame(setupFb2Pagination); // Пересчитать страницы!
} }
} }
}; };
window.setFontFamily = function (family) { window.setFontFamily = function (family) { // Изменение гарнитуры шрифта
if (state.bookFormat === 'epub' && state.rendition) { if (state.bookFormat === 'epub' && state.rendition) state.rendition.themes.font(family);
state.rendition.themes.font(family); else if (state.bookFormat === 'fb2') {
} else if (state.bookFormat === 'fb2') {
const inner = $('fb2-inner'); const inner = $('fb2-inner');
if (inner) { if (inner) {
inner.style.fontFamily = family; inner.style.fontFamily = family;
@@ -645,23 +606,18 @@
} }
}; };
window.goToChapter = function (href) { window.goToChapter = function (href) { // Переход к главе из оглавления
if (state.bookFormat === 'epub' && state.rendition) { if (state.bookFormat === 'epub' && state.rendition) state.rendition.display(href);
state.rendition.display(href); else if (state.bookFormat === 'fb2') {
} else if (state.bookFormat === 'fb2') { const el = $('fb2-inner').querySelector(`[data-chapter="${href}"]`);
const inner = $('fb2-inner'); if (el) {
const container = els.fb2Content; showFb2Page(Math.floor(el.offsetLeft / els.fb2Content.clientWidth));
if (inner && container) { updateFb2Progress();
const el = inner.querySelector(`[data-chapter="${href}"]`);
if (el) {
showFb2Page(Math.floor(el.offsetLeft / container.clientWidth));
updateFb2Progress();
}
} }
} }
}; };
window.getProgress = function () { window.getProgress = function () { // Запрос текущего состояния прогресса (в JSON)
if (state.bookFormat === 'epub' && state.book && state.currentCfi) { if (state.bookFormat === 'epub' && state.book && state.currentCfi) {
try { try {
return JSON.stringify({ return JSON.stringify({
@@ -674,19 +630,17 @@
} else if (state.bookFormat === 'fb2') { } else if (state.bookFormat === 'fb2') {
const p = state.fb2TotalPages > 1 ? state.fb2CurrentPage / (state.fb2TotalPages - 1) : 0; const p = state.fb2TotalPages > 1 ? state.fb2CurrentPage / (state.fb2TotalPages - 1) : 0;
return JSON.stringify({ return JSON.stringify({
progress: p, progress: p, cfi: p.toString(),
cfi: p.toString(), currentPage: state.fb2CurrentPage + 1, totalPages: state.fb2TotalPages
currentPage: state.fb2CurrentPage + 1,
totalPages: state.fb2TotalPages
}); });
} }
return '{}'; return '{}';
}; };
// ========== INIT ========== // ========== ИНИЦИАЛИЗАЦИЯ ==========
initTouchZones(); initTouchZones(); // Включаем зоны кликов
setLoadingText('Waiting for book...'); setLoadingText('Waiting for book...'); // Сообщаем, что готовы принимать файл
sendMessage('readerReady', {}); sendMessage('readerReady', {}); // Уведомляем нативное приложение: "Я загрузился!"
})(); })();
</script> </script>

View File

@@ -1,4 +1,6 @@
using BookReader.Models; using BookReader.Models;
using System.Net;
using static Android.Provider.CallLog;
namespace BookReader.Services; namespace BookReader.Services;

View File

@@ -39,13 +39,26 @@ public partial class ReaderViewModel : BaseViewModel
[ObservableProperty] [ObservableProperty]
private int _chapterTotalPages = 1; private int _chapterTotalPages = 1;
[ObservableProperty]
private int _currentPage = 1;
[ObservableProperty]
private int _totalPages = 1;
// Это свойство будет обновляться автоматически при изменении любого из полей выше // Это свойство будет обновляться автоматически при изменении любого из полей выше
public string ChapterProgressText => $"{ChapterCurrentPage} из {ChapterTotalPages}"; public string ChapterProgressText => $"{ChapterCurrentPage} из {ChapterTotalPages}";
// Это свойство будет обновляться автоматически при изменении любого из полей выше
public string ProgressText => $"{CurrentPage} из {TotalPages}";
// Чтобы ChapterProgressText уведомлял интерфейс, добавим частичные методы (особенность Toolkit) // Чтобы ChapterProgressText уведомлял интерфейс, добавим частичные методы (особенность Toolkit)
partial void OnChapterCurrentPageChanged(int value) => OnPropertyChanged(nameof(ChapterProgressText)); partial void OnChapterCurrentPageChanged(int value) => OnPropertyChanged(nameof(ChapterProgressText));
partial void OnChapterTotalPagesChanged(int value) => OnPropertyChanged(nameof(ChapterProgressText)); partial void OnChapterTotalPagesChanged(int value) => OnPropertyChanged(nameof(ChapterProgressText));
// Чтобы ProgressText уведомлял интерфейс, добавим частичные методы (особенность Toolkit)
partial void OnCurrentPageChanged(int value) => OnPropertyChanged(nameof(ProgressText));
partial void OnTotalPagesChanged(int value) => OnPropertyChanged(nameof(ProgressText));
public List<string> AvailableFonts { get; } = new() public List<string> AvailableFonts { get; } = new()
{ {
"serif", "serif",
@@ -130,6 +143,14 @@ public partial class ReaderViewModel : BaseViewModel
IsMenuVisible = false; IsMenuVisible = false;
} }
public async Task SaveLocationsAsync(string locations)
{
if (Book == null) return;
Book.Locations = locations;
// Сохраняем в базу данных
await _databaseService.UpdateBookAsync(Book);
}
public async Task SaveProgressAsync(double progress, string? cfi, string? chapter, int currentPage, int totalPages) public async Task SaveProgressAsync(double progress, string? cfi, string? chapter, int currentPage, int totalPages)
{ {
if (Book == null) return; if (Book == null) return;
@@ -172,6 +193,11 @@ public partial class ReaderViewModel : BaseViewModel
return Book?.LastCfi; return Book?.LastCfi;
} }
public string? GetLocations()
{
return Book?.Locations;
}
private static string EscapeJs(string value) private static string EscapeJs(string value)
{ {
return value.Replace("\\", "\\\\").Replace("'", "\\'").Replace("\n", "\\n").Replace("\r", "\\r"); return value.Replace("\\", "\\\\").Replace("'", "\\'").Replace("\n", "\\n").Replace("\r", "\\r");

View File

@@ -23,7 +23,7 @@
<!-- Всплывающее меню --> <!-- Всплывающее меню -->
<Grid IsVisible="{Binding IsMenuVisible}" <Grid IsVisible="{Binding IsMenuVisible}"
BackgroundColor="#88000000" BackgroundColor="Transparent"
InputTransparent="False"> InputTransparent="False">
<Grid.GestureRecognizers> <Grid.GestureRecognizers>
<TapGestureRecognizer Command="{Binding HideMenuCommand}" /> <TapGestureRecognizer Command="{Binding HideMenuCommand}" />
@@ -33,10 +33,9 @@
VerticalOptions="Start" VerticalOptions="Start"
HorizontalOptions="FillAndExpand" HorizontalOptions="FillAndExpand"
Padding="20" Padding="20"
HasShadow="True"
BorderColor="Transparent"> BorderColor="Transparent">
<VerticalStackLayout> <VerticalStackLayout>
<Label Text="{Binding ChapterProgressText}" <Label Text="{Binding ProgressText}"
TextColor="White" TextColor="White"
FontSize="12" FontSize="12"
HorizontalTextAlignment="Center" /> HorizontalTextAlignment="Center" />
@@ -50,14 +49,11 @@
Clicked="OnBackToLibrary" /> Clicked="OnBackToLibrary" />
</VerticalStackLayout> </VerticalStackLayout>
</Frame> </Frame>
<!--Menu Panel--> <!--Нижняя панель-->
<Frame VerticalOptions="Center" <Frame VerticalOptions="End"
HorizontalOptions="Center" HorizontalOptions="FillAndExpand"
WidthRequest="320"
BackgroundColor="#2C2C2C" BackgroundColor="#2C2C2C"
CornerRadius="16"
Padding="20" Padding="20"
HasShadow="True"
BorderColor="Transparent"> BorderColor="Transparent">
<Frame.GestureRecognizers> <Frame.GestureRecognizers>
<TapGestureRecognizer Tapped="OnMenuPanelTapped" /> <TapGestureRecognizer Tapped="OnMenuPanelTapped" />
@@ -159,7 +155,7 @@
Padding="10,2" Padding="10,2"
BorderColor="Transparent" BorderColor="Transparent"
HasShadow="False"> HasShadow="False">
<Label Text="{Binding ChapterProgressText}" <Label Text="{Binding ProgressText}"
TextColor="White" TextColor="White"
FontSize="12" FontSize="12"
HorizontalTextAlignment="Center" /> HorizontalTextAlignment="Center" />

View File

@@ -61,13 +61,11 @@ public partial class ReaderPage : ContentPage
var book = _viewModel.Book; var book = _viewModel.Book;
if (book == null) if (book == null)
{ {
System.Diagnostics.Debug.WriteLine("[Reader] Book is null");
return; return;
} }
if (_isBookLoaded) if (_isBookLoaded)
{ {
System.Diagnostics.Debug.WriteLine("[Reader] Already loaded");
return; return;
} }
@@ -77,16 +75,13 @@ public partial class ReaderPage : ContentPage
return; return;
} }
System.Diagnostics.Debug.WriteLine($"[Reader] Loading: {book.Title} ({book.Format})");
System.Diagnostics.Debug.WriteLine($"[Reader] Path: {book.FilePath}");
// Читаем файл и конвертируем в Base64 // Читаем файл и конвертируем в Base64
var fileBytes = await File.ReadAllBytesAsync(book.FilePath); var fileBytes = await File.ReadAllBytesAsync(book.FilePath);
var base64 = Convert.ToBase64String(fileBytes); var base64 = Convert.ToBase64String(fileBytes);
var format = book.Format.ToLowerInvariant(); var format = book.Format.ToLowerInvariant();
var lastCfi = _viewModel.GetLastCfi() ?? ""; var lastCfi = _viewModel.GetLastCfi() ?? "";
var locations=_viewModel.GetLocations() ?? "";
System.Diagnostics.Debug.WriteLine($"[Reader] File: {fileBytes.Length} bytes, Base64: {base64.Length} chars");
// Отправляем данные чанками чтобы не превысить лимит JS строки // Отправляем данные чанками чтобы не превысить лимит JS строки
const int chunkSize = 400_000; const int chunkSize = 400_000;
@@ -94,25 +89,23 @@ public partial class ReaderPage : ContentPage
if (base64.Length > chunkSize) if (base64.Length > chunkSize)
{ {
var chunks = SplitString(base64, chunkSize); var chunks = SplitString(base64, chunkSize);
System.Diagnostics.Debug.WriteLine($"[Reader] Sending {chunks.Count} chunks");
await EvalJsAsync("window._bkChunks = [];"); await EvalJsAsync("window._bkChunks = [];");
for (int i = 0; i < chunks.Count; i++) for (int i = 0; i < chunks.Count; i++)
{ {
await EvalJsAsync($"window._bkChunks.push('{chunks[i]}');"); await EvalJsAsync($"window._bkChunks.push('{chunks[i]}');");
System.Diagnostics.Debug.WriteLine($"[Reader] Chunk {i + 1}/{chunks.Count}");
} }
await EvalJsAsync( await EvalJsAsync(
$"window.loadBookFromBase64(window._bkChunks.join(''), '{format}', '{EscapeJs(lastCfi)}');" $"window.loadBookFromBase64(window._bkChunks.join(''), '{format}', '{EscapeJs(lastCfi)}','{EscapeJs(locations)}');"
); );
await EvalJsAsync("delete window._bkChunks;"); await EvalJsAsync("delete window._bkChunks;");
} }
else else
{ {
await EvalJsAsync( await EvalJsAsync(
$"window.loadBookFromBase64('{base64}', '{format}', '{EscapeJs(lastCfi)}');" $"window.loadBookFromBase64('{base64}', '{format}', '{EscapeJs(lastCfi)}','{EscapeJs(locations)}');"
); );
} }
@@ -143,9 +136,6 @@ public partial class ReaderPage : ContentPage
try try
{ {
var result = await EvalJsWithResultAsync("window.getProgress()"); var result = await EvalJsWithResultAsync("window.getProgress()");
System.Diagnostics.Debug.WriteLine($"[Reader] Progress raw: {result}");
if (string.IsNullOrEmpty(result) || result == "null" || result == "{}" || result == "undefined") if (string.IsNullOrEmpty(result) || result == "null" || result == "{}" || result == "undefined")
return; return;
@@ -183,7 +173,7 @@ public partial class ReaderPage : ContentPage
switch (action) switch (action)
{ {
case "readerReady": case "readerReady":
System.Diagnostics.Debug.WriteLine("[Reader] JS is ready! Loading book...");
// Вызываем загрузку книги ТОЛЬКО после того, как JS подтвердил готовность // Вызываем загрузку книги ТОЛЬКО после того, как JS подтвердил готовность
_ = MainThread.InvokeOnMainThreadAsync(LoadBookIntoWebView); _ = MainThread.InvokeOnMainThreadAsync(LoadBookIntoWebView);
break; break;
@@ -211,6 +201,8 @@ public partial class ReaderPage : ContentPage
MainThread.BeginInvokeOnMainThread(() => { MainThread.BeginInvokeOnMainThread(() => {
_viewModel.ChapterCurrentPage = chapterPage; _viewModel.ChapterCurrentPage = chapterPage;
_viewModel.ChapterTotalPages = chapterTotal; _viewModel.ChapterTotalPages = chapterTotal;
_viewModel.TotalPages = totalPages;
_viewModel.CurrentPage = currentPage;
}); });
await _viewModel.SaveProgressAsync(progress, cfi, chapter, currentPage, totalPages); await _viewModel.SaveProgressAsync(progress, cfi, chapter, currentPage, totalPages);
} }
@@ -233,11 +225,21 @@ public partial class ReaderPage : ContentPage
break; break;
case "bookReady": case "bookReady":
MainThread.BeginInvokeOnMainThread(async () => if (data != null)
{ {
await EvalJsAsync($"window.setFontSize({_viewModel.FontSize})"); MainThread.BeginInvokeOnMainThread(async () =>
await EvalJsAsync($"window.setFontFamily('{EscapeJs(_viewModel.FontFamily)}')"); {
}); await EvalJsAsync($"window.setFontSize({_viewModel.FontSize})");
await EvalJsAsync($"window.setFontFamily('{EscapeJs(_viewModel.FontFamily)}')");
});
}
break;
case "saveLocations":
// Извлекаем строку локаций из данных
string locations = data["locations"]?.ToString();
// Сохраняем в базу данных
await _viewModel.SaveLocationsAsync(locations);
break; break;
} }
} }