edit
This commit is contained in:
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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: превращает < в < и т.д.
|
||||||
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(),
|
|
||||||
href: ch.href || ''
|
|
||||||
}));
|
|
||||||
sendMessage('chaptersLoaded', { chapters: state.toc });
|
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();
|
||||||
|
|
||||||
|
// Отправляем в C#, чтобы сохранить на будущее
|
||||||
|
sendMessage('saveLocations', { locations: locationsToSave });
|
||||||
|
sendMessage('bookReady', { totalPages: state.totalPages });
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Событие при смене страницы
|
||||||
state.rendition.on('relocated', location => {
|
state.rendition.on('relocated', location => {
|
||||||
if (!location || !location.start) return;
|
if (!location || !location.start) return;
|
||||||
state.currentCfi = location.start.cfi;
|
state.currentCfi = location.start.cfi;
|
||||||
// 1. Достаем страницы ВНУТРИ главы
|
|
||||||
// Если epub.js не успел посчитать (бывает на долю секунды), ставим 1
|
|
||||||
const chapterPage = location.start.displayed ? location.start.displayed.page : 1;
|
const chapterPage = location.start.displayed ? location.start.displayed.page : 1;
|
||||||
const chapterTotal = location.start.displayed ? location.start.displayed.total : 1;
|
const chapterTotal = location.start.displayed ? location.start.displayed.total : 1;
|
||||||
|
|
||||||
// 2. Ищем красивое название главы по её href
|
|
||||||
const currentHref = location.start.href || '';
|
const currentHref = location.start.href || '';
|
||||||
let chapterName = currentHref; // По умолчанию системное имя
|
|
||||||
|
|
||||||
// Пытаемся найти точное совпадение в сохраненном оглавлении
|
// Поиск человеческого названия текущей главы
|
||||||
|
let chapterName = currentHref;
|
||||||
const foundChapter = state.toc.find(ch => currentHref.includes(ch.href));
|
const foundChapter = state.toc.find(ch => currentHref.includes(ch.href));
|
||||||
if (foundChapter) {
|
if (foundChapter) chapterName = foundChapter.label;
|
||||||
chapterName = foundChapter.label;
|
|
||||||
}
|
// Отправка прогресса в приложение
|
||||||
try {
|
|
||||||
sendMessage('progressUpdate', {
|
sendMessage('progressUpdate', {
|
||||||
progress: state.book.locations.percentageFromCfi(state.currentCfi) || 0,
|
progress: state.book.locations.percentageFromCfi(state.currentCfi) || 0,
|
||||||
cfi: state.currentCfi,
|
cfi: state.currentCfi,
|
||||||
currentPage: location.start.location || 0, // Глобальная страница
|
currentPage: location.start.location || 0,
|
||||||
totalPages: state.totalPages, // Всего глобальных страниц
|
totalPages: state.totalPages,
|
||||||
chapterCurrentPage: chapterPage, // <--- Страница в главе!
|
chapterCurrentPage: chapterPage,
|
||||||
chapterTotalPages: chapterTotal, // <--- Всего страниц в главе!
|
chapterTotalPages: chapterTotal,
|
||||||
chapter: chapterName // <--- Красивое имя главы
|
chapter: chapterName
|
||||||
});
|
});
|
||||||
} catch (e) {
|
|
||||||
debugLog('Relocated error: ' + e.message);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setLoadingText('Rendering...');
|
// Отображение книги на сохраненной позиции или в начале
|
||||||
const displayTarget = (lastCfi && lastCfi !== 'null' && lastCfi !== 'undefined')
|
const displayTarget = (lastCfi && lastCfi !== 'null' && lastCfi !== 'undefined') ? 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');
|
|
||||||
const container = els.fb2Content;
|
|
||||||
if (inner && container) {
|
|
||||||
const el = inner.querySelector(`[data-chapter="${href}"]`);
|
|
||||||
if (el) {
|
if (el) {
|
||||||
showFb2Page(Math.floor(el.offsetLeft / container.clientWidth));
|
showFb2Page(Math.floor(el.offsetLeft / els.fb2Content.clientWidth));
|
||||||
updateFb2Progress();
|
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>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using BookReader.Models;
|
using BookReader.Models;
|
||||||
|
using System.Net;
|
||||||
|
using static Android.Provider.CallLog;
|
||||||
|
|
||||||
namespace BookReader.Services;
|
namespace BookReader.Services;
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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":
|
||||||
|
if (data != null)
|
||||||
|
{
|
||||||
MainThread.BeginInvokeOnMainThread(async () =>
|
MainThread.BeginInvokeOnMainThread(async () =>
|
||||||
{
|
{
|
||||||
await EvalJsAsync($"window.setFontSize({_viewModel.FontSize})");
|
await EvalJsAsync($"window.setFontSize({_viewModel.FontSize})");
|
||||||
await EvalJsAsync($"window.setFontFamily('{EscapeJs(_viewModel.FontFamily)}')");
|
await EvalJsAsync($"window.setFontFamily('{EscapeJs(_viewModel.FontFamily)}')");
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "saveLocations":
|
||||||
|
// Извлекаем строку локаций из данных
|
||||||
|
string locations = data["locations"]?.ToString();
|
||||||
|
// Сохраняем в базу данных
|
||||||
|
await _viewModel.SaveLocationsAsync(locations);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user