forked from akai/readest
* feat(annotator): add page number in annotation * feat: add page number for annotations, closes #3082
This commit is contained in:
@@ -1104,5 +1104,8 @@
|
||||
"Highlights synced to Readwise": "تمت مزامنة التمييزات مع Readwise",
|
||||
"Readwise sync failed: no internet connection": "فشلت مزامنة Readwise: لا يوجد اتصال بالإنترنت",
|
||||
"Readwise sync failed: {{error}}": "فشلت مزامنة Readwise: {{error}}",
|
||||
"System Screen Brightness": "سطوع شاشة النظام"
|
||||
"System Screen Brightness": "سطوع شاشة النظام",
|
||||
"Page:": "صفحة:",
|
||||
"Page: {{number}}": "صفحة: {{number}}",
|
||||
"Annotation page number": "رقم صفحة التعליق"
|
||||
}
|
||||
|
||||
@@ -1056,5 +1056,8 @@
|
||||
"Highlights synced to Readwise": "হাইলাইটগুলি Readwise এ সিঙ্ক করা হয়েছে",
|
||||
"Readwise sync failed: no internet connection": "Readwise সিঙ্ক ব্যর্থ হয়েছে: ইন্টারনেট সংযোগ নেই",
|
||||
"Readwise sync failed: {{error}}": "Readwise সিঙ্ক ব্যর্থ হয়েছে: {{error}}",
|
||||
"System Screen Brightness": "সিস্টেম স্ক্রিন উজ্জ্বলতা"
|
||||
"System Screen Brightness": "সিস্টেম স্ক্রিন উজ্জ্বলতা",
|
||||
"Page:": "পৃষ্ঠা:",
|
||||
"Page: {{number}}": "পৃষ্ঠা: {{number}}",
|
||||
"Annotation page number": "টীকা পৃষ্ঠা নম্বর"
|
||||
}
|
||||
|
||||
@@ -1044,5 +1044,8 @@
|
||||
"Highlights synced to Readwise": "བཀོད་མཆན་ Readwise ལ་མཉམ་བྱུང་བྱས་ཟིན།",
|
||||
"Readwise sync failed: no internet connection": "Readwise མཉམ་བྱུང་མ་ཐུབ། དྲ་རྒྱའི་མཐུད་ལམ་མི་འདུག",
|
||||
"Readwise sync failed: {{error}}": "Readwise མཉམ་བྱུང་མ་ཐུབ། {{error}}",
|
||||
"System Screen Brightness": "མ་ལག་ཤེལ་སྒོའི་གསལ་ཚད།"
|
||||
"System Screen Brightness": "མ་ལག་ཤེལ་སྒོའི་གསལ་ཚད།",
|
||||
"Page:": "ཤོག་ལྷེ།:",
|
||||
"Page: {{number}}": "ཤོག་ལྷེ།: {{number}}",
|
||||
"Annotation page number": "མཆན་འགྲེལ་ཤོག་ཨང་།"
|
||||
}
|
||||
|
||||
@@ -1056,5 +1056,8 @@
|
||||
"Highlights synced to Readwise": "Highlights mit Readwise synchronisiert",
|
||||
"Readwise sync failed: no internet connection": "Readwise-Synchronisierung fehlgeschlagen: Keine Internetverbindung",
|
||||
"Readwise sync failed: {{error}}": "Readwise-Synchronisierung fehlgeschlagen: {{error}}",
|
||||
"System Screen Brightness": "System-Bildschirmhelligkeit"
|
||||
"System Screen Brightness": "System-Bildschirmhelligkeit",
|
||||
"Page:": "Seite:",
|
||||
"Page: {{number}}": "Seite: {{number}}",
|
||||
"Annotation page number": "Seitenzahl der Anmerkung"
|
||||
}
|
||||
|
||||
@@ -1056,5 +1056,8 @@
|
||||
"Highlights synced to Readwise": "Οι επισημάνσεις συγχρονίστηκαν στο Readwise",
|
||||
"Readwise sync failed: no internet connection": "Ο συγχρονισμός Readwise απέτυχε: δεν υπάρχει σύνδεση στο διαδίκτυο",
|
||||
"Readwise sync failed: {{error}}": "Ο συγχρονισμός Readwise απέτυχε: {{error}}",
|
||||
"System Screen Brightness": "Φωτεινότητα οθόνης συστήματος"
|
||||
"System Screen Brightness": "Φωτεινότητα οθόνης συστήματος",
|
||||
"Page:": "Σελίδα:",
|
||||
"Page: {{number}}": "Σελίδα: {{number}}",
|
||||
"Annotation page number": "Αριθμός σελίδας σχολίου"
|
||||
}
|
||||
|
||||
@@ -1068,5 +1068,8 @@
|
||||
"Highlights synced to Readwise": "Resaltados sincronizados con Readwise",
|
||||
"Readwise sync failed: no internet connection": "Error en la sincronización con Readwise: sin conexión a internet",
|
||||
"Readwise sync failed: {{error}}": "Error en la sincronización con Readwise: {{error}}",
|
||||
"System Screen Brightness": "Brillo de pantalla del sistema"
|
||||
"System Screen Brightness": "Brillo de pantalla del sistema",
|
||||
"Page:": "Página:",
|
||||
"Page: {{number}}": "Página: {{number}}",
|
||||
"Annotation page number": "Número de página de la anotación"
|
||||
}
|
||||
|
||||
@@ -1056,5 +1056,8 @@
|
||||
"Highlights synced to Readwise": "هایلایتها با Readwise همگامسازی شدند",
|
||||
"Readwise sync failed: no internet connection": "همگامسازی Readwise ناموفق بود: اتصال اینترنت برقرار نیست",
|
||||
"Readwise sync failed: {{error}}": "همگامسازی Readwise ناموفق بود: {{error}}",
|
||||
"System Screen Brightness": "روشنایی صفحه نمایش سیستم"
|
||||
"System Screen Brightness": "روشنایی صفحه نمایش سیستم",
|
||||
"Page:": "صفحه:",
|
||||
"Page: {{number}}": "صفحه: {{number}}",
|
||||
"Annotation page number": "شماره صفحه یادداشت"
|
||||
}
|
||||
|
||||
@@ -1068,5 +1068,8 @@
|
||||
"Highlights synced to Readwise": "Surlignages synchronisés avec Readwise",
|
||||
"Readwise sync failed: no internet connection": "Échec de la synchronisation Readwise : pas de connexion internet",
|
||||
"Readwise sync failed: {{error}}": "Échec de la synchronisation Readwise : {{error}}",
|
||||
"System Screen Brightness": "Luminosité de l'écran système"
|
||||
"System Screen Brightness": "Luminosité de l'écran système",
|
||||
"Page:": "Page :",
|
||||
"Page: {{number}}": "Page : {{number}}",
|
||||
"Annotation page number": "Numéro de page de l'annotation"
|
||||
}
|
||||
|
||||
@@ -1068,5 +1068,8 @@
|
||||
"Highlights synced to Readwise": "ההדגשות סונכרנו ל-Readwise",
|
||||
"Readwise sync failed: no internet connection": "סנכרון Readwise נכשל: אין חיבור לאינטרנט",
|
||||
"Readwise sync failed: {{error}}": "סנכרון Readwise נכשל: {{error}}",
|
||||
"System Screen Brightness": "בהירות מסך המערכת"
|
||||
"System Screen Brightness": "בהירות מסך המערכת",
|
||||
"Page:": "עמוד:",
|
||||
"Page: {{number}}": "עמוד: {{number}}",
|
||||
"Annotation page number": "מספר עמוד הערה"
|
||||
}
|
||||
|
||||
@@ -1056,5 +1056,8 @@
|
||||
"Highlights synced to Readwise": "हाइलाइट्स Readwise में सिंक हो गए",
|
||||
"Readwise sync failed: no internet connection": "Readwise सिंक विफल रहा: कोई इंटरनेट कनेक्शन नहीं",
|
||||
"Readwise sync failed: {{error}}": "Readwise सिंक विफल रहा: {{error}}",
|
||||
"System Screen Brightness": "सिस्टम स्क्रीन चमक"
|
||||
"System Screen Brightness": "सिस्टम स्क्रीन चमक",
|
||||
"Page:": "पृष्ठ:",
|
||||
"Page: {{number}}": "पृष्ठ: {{number}}",
|
||||
"Annotation page number": "व्याख्या पृष्ठ संख्या"
|
||||
}
|
||||
|
||||
@@ -1044,5 +1044,8 @@
|
||||
"Highlights synced to Readwise": "Sorotan disinkronkan ke Readwise",
|
||||
"Readwise sync failed: no internet connection": "Sinkronisasi Readwise gagal: tidak ada koneksi internet",
|
||||
"Readwise sync failed: {{error}}": "Sinkronisasi Readwise gagal: {{error}}",
|
||||
"System Screen Brightness": "Kecerahan Layar Sistem"
|
||||
"System Screen Brightness": "Kecerahan Layar Sistem",
|
||||
"Page:": "Halaman:",
|
||||
"Page: {{number}}": "Halaman: {{number}}",
|
||||
"Annotation page number": "Nomor halaman anotasi"
|
||||
}
|
||||
|
||||
@@ -1068,5 +1068,8 @@
|
||||
"Highlights synced to Readwise": "Evidenziazioni sincronizzate su Readwise",
|
||||
"Readwise sync failed: no internet connection": "Sincronizzazione Readwise fallita: nessuna connessione internet",
|
||||
"Readwise sync failed: {{error}}": "Sincronizzazione Readwise fallita: {{error}}",
|
||||
"System Screen Brightness": "Luminosità dello schermo del sistema"
|
||||
"System Screen Brightness": "Luminosità dello schermo del sistema",
|
||||
"Page:": "Pagina:",
|
||||
"Page: {{number}}": "Pagina: {{number}}",
|
||||
"Annotation page number": "Numero di pagina dell'annotazione"
|
||||
}
|
||||
|
||||
@@ -1044,5 +1044,8 @@
|
||||
"Highlights synced to Readwise": "ハイライトが Readwise に同期されました",
|
||||
"Readwise sync failed: no internet connection": "Readwise 同期失敗:インターネット接続がありません",
|
||||
"Readwise sync failed: {{error}}": "Readwise 同期失敗:{{error}}",
|
||||
"System Screen Brightness": "システムの画面の明るさ"
|
||||
"System Screen Brightness": "システムの画面の明るさ",
|
||||
"Page:": "ページ:",
|
||||
"Page: {{number}}": "ページ: {{number}}",
|
||||
"Annotation page number": "注釈のページ番号"
|
||||
}
|
||||
|
||||
@@ -1044,5 +1044,8 @@
|
||||
"Highlights synced to Readwise": "하이라이트가 Readwise에 동기화되었습니다",
|
||||
"Readwise sync failed: no internet connection": "Readwise 동기화 실패: 인터넷 연결 없음",
|
||||
"Readwise sync failed: {{error}}": "Readwise 동기화 실패: {{error}}",
|
||||
"System Screen Brightness": "시스템 화면 밝기"
|
||||
"System Screen Brightness": "시스템 화면 밝기",
|
||||
"Page:": "페이지:",
|
||||
"Page: {{number}}": "페이지: {{number}}",
|
||||
"Annotation page number": "주석 페이지 번호"
|
||||
}
|
||||
|
||||
@@ -1044,5 +1044,8 @@
|
||||
"Highlights synced to Readwise": "Sorotan disinkronkan ke Readwise",
|
||||
"Readwise sync failed: no internet connection": "Penyinkronan Readwise gagal: tiada sambungan internet",
|
||||
"Readwise sync failed: {{error}}": "Penyinkronan Readwise gagal: {{error}}",
|
||||
"System Screen Brightness": "Kecerahan Skrin Sistem"
|
||||
"System Screen Brightness": "Kecerahan Skrin Sistem",
|
||||
"Page:": "Halaman:",
|
||||
"Page: {{number}}": "Halaman: {{number}}",
|
||||
"Annotation page number": "Nombor halaman anotasi"
|
||||
}
|
||||
|
||||
@@ -1056,5 +1056,8 @@
|
||||
"Highlights synced to Readwise": "Markeringen gesynchroniseerd met Readwise",
|
||||
"Readwise sync failed: no internet connection": "Readwise-synchronisatie mislukt: geen internetverbinding",
|
||||
"Readwise sync failed: {{error}}": "Readwise-synchronisatie mislukt: {{error}}",
|
||||
"System Screen Brightness": "Systeemhelderheid van het scherm"
|
||||
"System Screen Brightness": "Systeemhelderheid van het scherm",
|
||||
"Page:": "Pagina:",
|
||||
"Page: {{number}}": "Pagina: {{number}}",
|
||||
"Annotation page number": "Annotatiepaginanummer"
|
||||
}
|
||||
|
||||
@@ -1080,5 +1080,8 @@
|
||||
"Highlights synced to Readwise": "Wyróżnienia zsynchronizowane z Readwise",
|
||||
"Readwise sync failed: no internet connection": "Synchronizacja Readwise nie powiodła się: brak połączenia z Internetem",
|
||||
"Readwise sync failed: {{error}}": "Synchronizacja Readwise nie powiodła się: {{error}}",
|
||||
"System Screen Brightness": "Jasność ekranu systemowego"
|
||||
"System Screen Brightness": "Jasność ekranu systemowego",
|
||||
"Page:": "Strona:",
|
||||
"Page: {{number}}": "Strona: {{number}}",
|
||||
"Annotation page number": "Numer strony adnotacji"
|
||||
}
|
||||
|
||||
@@ -1068,5 +1068,8 @@
|
||||
"Highlights synced to Readwise": "Destaques sincronizados com o Readwise",
|
||||
"Readwise sync failed: no internet connection": "Sincronização com o Readwise falhou: sem conexão com a internet",
|
||||
"Readwise sync failed: {{error}}": "Sincronização com o Readwise falhou: {{error}}",
|
||||
"System Screen Brightness": "Brilho da tela do sistema"
|
||||
"System Screen Brightness": "Brilho da tela do sistema",
|
||||
"Page:": "Página:",
|
||||
"Page: {{number}}": "Página: {{number}}",
|
||||
"Annotation page number": "Número de página da anotação"
|
||||
}
|
||||
|
||||
@@ -1080,5 +1080,8 @@
|
||||
"Highlights synced to Readwise": "Выделения синхронизированы с Readwise",
|
||||
"Readwise sync failed: no internet connection": "Синхронизация Readwise не удалась: нет интернет-соединения",
|
||||
"Readwise sync failed: {{error}}": "Синхронизация Readwise не удалась: {{error}}",
|
||||
"System Screen Brightness": "Систেমная яркость экрана"
|
||||
"System Screen Brightness": "Систেমная яркость экрана",
|
||||
"Page:": "Страница:",
|
||||
"Page: {{number}}": "Страница: {{number}}",
|
||||
"Annotation page number": "Номер страницы аннотации"
|
||||
}
|
||||
|
||||
@@ -1056,5 +1056,8 @@
|
||||
"Highlights synced to Readwise": "විශේෂ අවස්ථා Readwise සමඟ සමමුහුර්ත විය",
|
||||
"Readwise sync failed: no internet connection": "Readwise සමමුහුර්තකරණය අසාර්ථක විය: අන්තර්ජාල සම්බන්ධතාවයක් නොමැත",
|
||||
"Readwise sync failed: {{error}}": "Readwise සමමුහුර්තකරණය අසාර්ථක විය: {{error}}",
|
||||
"System Screen Brightness": "පද්ධති තිර දීප්තිය"
|
||||
"System Screen Brightness": "පද්ධති තිර දීප්තිය",
|
||||
"Page:": "පිටුව:",
|
||||
"Page: {{number}}": "පිටුව: {{number}}",
|
||||
"Annotation page number": "විවරණ පිටු අංකය"
|
||||
}
|
||||
|
||||
@@ -1056,5 +1056,8 @@
|
||||
"Highlights synced to Readwise": "Markeringar synkroniserade till Readwise",
|
||||
"Readwise sync failed: no internet connection": "Readwise-synkronisering misslyckades: ingen internetanslutning",
|
||||
"Readwise sync failed: {{error}}": "Readwise-synkronisering misslyckades: {{error}}",
|
||||
"System Screen Brightness": "Systemets skärmljusstyrka"
|
||||
"System Screen Brightness": "Systemets skärmljusstyrka",
|
||||
"Page:": "Sida:",
|
||||
"Page: {{number}}": "Sida: {{number}}",
|
||||
"Annotation page number": "Annoteringssidnummer"
|
||||
}
|
||||
|
||||
@@ -1056,5 +1056,8 @@
|
||||
"Highlights synced to Readwise": "சிறப்பம்சங்கள் Readwise உடன் ஒத்திசைக்கப்பட்டன",
|
||||
"Readwise sync failed: no internet connection": "Readwise ஒத்திசைவு தோல்வியடைந்தது: இணைய இணைப்பு இல்லை",
|
||||
"Readwise sync failed: {{error}}": "Readwise ஒத்திசைவு தோல்வியடைந்தது: {{error}}",
|
||||
"System Screen Brightness": "கணினித் திரை பிரகாசம்"
|
||||
"System Screen Brightness": "கணினித் திரை பிரகாசம்",
|
||||
"Page:": "பக்கம்:",
|
||||
"Page: {{number}}": "பக்கம்: {{number}}",
|
||||
"Annotation page number": "சிறுகுறிப்புப் பக்க எண்"
|
||||
}
|
||||
|
||||
@@ -1044,5 +1044,8 @@
|
||||
"Highlights synced to Readwise": "ซิงค์ไฮไลต์ไปยัง Readwise แล้ว",
|
||||
"Readwise sync failed: no internet connection": "การซิงค์ Readwise ล้มเหลว: ไม่มีการเชื่อมต่ออินเทอร์เน็ต",
|
||||
"Readwise sync failed: {{error}}": "การซิงค์ Readwise ล้มเหลว: {{error}}",
|
||||
"System Screen Brightness": "ความสว่างหน้าจอระบบ"
|
||||
"System Screen Brightness": "ความสว่างหน้าจอระบบ",
|
||||
"Page:": "หน้า:",
|
||||
"Page: {{number}}": "หน้า: {{number}}",
|
||||
"Annotation page number": "หมายเลขหน้าคำอธิบายประกอบ"
|
||||
}
|
||||
|
||||
@@ -1056,5 +1056,8 @@
|
||||
"Highlights synced to Readwise": "Vurgular Readwise ile senkronize edildi",
|
||||
"Readwise sync failed: no internet connection": "Readwise senkronizasyonu başarısız: internet bağlantısı yok",
|
||||
"Readwise sync failed: {{error}}": "Readwise senkronizasyonu başarısız: {{error}}",
|
||||
"System Screen Brightness": "Sistem Ekran Parlaklığı"
|
||||
"System Screen Brightness": "Sistem Ekran Parlaklığı",
|
||||
"Page:": "Sayfa:",
|
||||
"Page: {{number}}": "Sayfa: {{number}}",
|
||||
"Annotation page number": "Açıklama sayfa numarası"
|
||||
}
|
||||
|
||||
@@ -1080,5 +1080,8 @@
|
||||
"Highlights synced to Readwise": "Виділення синхронізовано з Readwise",
|
||||
"Readwise sync failed: no internet connection": "Синхронізація Readwise не вдалася: немає інтернет-з'єднання",
|
||||
"Readwise sync failed: {{error}}": "Синхронізація Readwise не вдалася: {{error}}",
|
||||
"System Screen Brightness": "Системна яскравість екрана"
|
||||
"System Screen Brightness": "Системна яскравість екрана",
|
||||
"Page:": "Сторінка:",
|
||||
"Page: {{number}}": "Сторінка: {{number}}",
|
||||
"Annotation page number": "Номер сторінки анотації"
|
||||
}
|
||||
|
||||
@@ -1044,5 +1044,8 @@
|
||||
"Highlights synced to Readwise": "Các phần đánh dấu đã được đồng bộ với Readwise",
|
||||
"Readwise sync failed: no internet connection": "Đồng bộ Readwise thất bại: không có kết nối internet",
|
||||
"Readwise sync failed: {{error}}": "Đồng bộ Readwise thất bại: {{error}}",
|
||||
"System Screen Brightness": "Độ sáng màn hình hệ thống"
|
||||
"System Screen Brightness": "Độ sáng màn hình hệ thống",
|
||||
"Page:": "Trang:",
|
||||
"Page: {{number}}": "Trang: {{number}}",
|
||||
"Annotation page number": "Số trang chú thích"
|
||||
}
|
||||
|
||||
@@ -1044,5 +1044,8 @@
|
||||
"Highlights synced to Readwise": "高亮内容已同步到 Readwise",
|
||||
"Readwise sync failed: no internet connection": "Readwise 同步失败:无网络连接",
|
||||
"Readwise sync failed: {{error}}": "Readwise 同步失败:{{error}}",
|
||||
"System Screen Brightness": "系统屏幕亮度"
|
||||
"System Screen Brightness": "系统屏幕亮度",
|
||||
"Page:": "页面:",
|
||||
"Page: {{number}}": "页面: {{number}}",
|
||||
"Annotation page number": "批注页码"
|
||||
}
|
||||
|
||||
@@ -1044,5 +1044,8 @@
|
||||
"Highlights synced to Readwise": "高亮內容已同步到 Readwise",
|
||||
"Readwise sync failed: no internet connection": "Readwise 同步失敗:無網路連線",
|
||||
"Readwise sync failed: {{error}}": "Readwise 同步失敗:{{error}}",
|
||||
"System Screen Brightness": "系統螢幕亮度"
|
||||
"System Screen Brightness": "系統螢幕亮度",
|
||||
"Page:": "頁面:",
|
||||
"Page: {{number}}": "頁面: {{number}}",
|
||||
"Annotation page number": "註解頁碼"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import { describe, it, expect, beforeAll, vi } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { DocumentLoader } from '@/libs/document';
|
||||
import type { BookDoc } from '@/libs/document';
|
||||
import type { FoliateView } from '@/types/view';
|
||||
import { wrappedFoliateView } from '@/types/view';
|
||||
|
||||
// jsdom's Blob doesn't implement arrayBuffer(), polyfill it via FileReader
|
||||
if (typeof Blob.prototype.arrayBuffer !== 'function') {
|
||||
Blob.prototype.arrayBuffer = function () {
|
||||
return new Promise((res, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => res(reader.result as ArrayBuffer);
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsArrayBuffer(this);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// Register a stub paginator custom element so View.open() doesn't fail in jsdom
|
||||
if (!customElements.get('foliate-paginator')) {
|
||||
customElements.define(
|
||||
'foliate-paginator',
|
||||
class extends HTMLElement {
|
||||
override setAttribute() {}
|
||||
override addEventListener() {}
|
||||
open() {}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Mock the paginator module import so View doesn't try to load the real one
|
||||
vi.mock('foliate-js/paginator.js', () => ({}));
|
||||
|
||||
let book: BookDoc;
|
||||
let view: FoliateView;
|
||||
let totalSections: number;
|
||||
|
||||
const loadEPUB = async () => {
|
||||
const epubPath = resolve(__dirname, '../fixtures/sample-alice.epub');
|
||||
const buffer = readFileSync(epubPath);
|
||||
const file = new File([buffer], 'sample-alice.epub', { type: 'application/epub+zip' });
|
||||
const loader = new DocumentLoader(file);
|
||||
const { book } = await loader.open();
|
||||
return book;
|
||||
};
|
||||
|
||||
describe('getCFIProgress with real EPUB', () => {
|
||||
beforeAll(async () => {
|
||||
book = await loadEPUB();
|
||||
totalSections = book.sections!.length;
|
||||
|
||||
// Import View and create the element, wrapping it like the app does
|
||||
await import('foliate-js/view.js');
|
||||
const rawView = document.createElement('foliate-view') as FoliateView;
|
||||
view = wrappedFoliateView(rawView);
|
||||
await view.open(book);
|
||||
}, 30000);
|
||||
|
||||
it('should load the EPUB with sections', () => {
|
||||
expect(book.sections!.length).toBeGreaterThan(0);
|
||||
expect(view.book).toBe(book);
|
||||
});
|
||||
|
||||
it('should return a valid progress object with all fields', async () => {
|
||||
const sectionIndex = book.sections!.findIndex((s) => s.linear !== 'no' && s.createDocument);
|
||||
const doc = await book.sections![sectionIndex]!.createDocument();
|
||||
const body = doc.body ?? doc.documentElement;
|
||||
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
||||
const firstTextNode = walker.nextNode();
|
||||
expect(firstTextNode).not.toBeNull();
|
||||
|
||||
const range = doc.createRange();
|
||||
range.setStart(firstTextNode!, 0);
|
||||
range.setEnd(firstTextNode!, 0);
|
||||
|
||||
const cfi = view.getCFI(sectionIndex, range);
|
||||
const result = await view.getCFIProgress(cfi);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
// fraction is the overall book progress
|
||||
expect(result!.fraction).toBeGreaterThanOrEqual(0);
|
||||
expect(result!.fraction).toBeLessThanOrEqual(1);
|
||||
// section info
|
||||
expect(result!.section.current).toBe(sectionIndex);
|
||||
expect(result!.section.total).toBe(totalSections);
|
||||
// location info
|
||||
expect(result!.location.current).toBeGreaterThanOrEqual(0);
|
||||
expect(result!.location.next).toBeGreaterThanOrEqual(result!.location.current);
|
||||
expect(result!.location.total).toBeGreaterThan(0);
|
||||
// time info
|
||||
expect(result!.time.section).toBeGreaterThanOrEqual(0);
|
||||
expect(result!.time.total).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should return the smallest fraction for a CFI at the start of a section', async () => {
|
||||
const sectionIndex = book.sections!.findIndex((s) => s.linear !== 'no' && s.createDocument);
|
||||
expect(sectionIndex).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const doc = await book.sections![sectionIndex]!.createDocument();
|
||||
const body = doc.body ?? doc.documentElement;
|
||||
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
||||
const firstTextNode = walker.nextNode();
|
||||
expect(firstTextNode).not.toBeNull();
|
||||
|
||||
const range = doc.createRange();
|
||||
range.setStart(firstTextNode!, 0);
|
||||
range.setEnd(firstTextNode!, 0);
|
||||
|
||||
const cfi = view.getCFI(sectionIndex, range);
|
||||
const result = await view.getCFIProgress(cfi);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.section.current).toBe(sectionIndex);
|
||||
// At the start of a section, fraction should equal the section's start fraction
|
||||
// (i.e. only prior sections' sizes contribute)
|
||||
expect(result!.fraction).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should return a larger fraction for a CFI at the end of a section', async () => {
|
||||
const sectionIndex = book.sections!.findIndex((s) => s.linear !== 'no' && s.createDocument);
|
||||
const doc = await book.sections![sectionIndex]!.createDocument();
|
||||
const body = doc.body ?? doc.documentElement;
|
||||
|
||||
// Get start CFI
|
||||
const walkerStart = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
||||
const firstTextNode = walkerStart.nextNode()!;
|
||||
const startRange = doc.createRange();
|
||||
startRange.setStart(firstTextNode, 0);
|
||||
startRange.setEnd(firstTextNode, 0);
|
||||
const startCfi = view.getCFI(sectionIndex, startRange);
|
||||
const startResult = await view.getCFIProgress(startCfi);
|
||||
|
||||
// Get end CFI
|
||||
const walkerEnd = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
||||
let lastTextNode: Node | null = null;
|
||||
for (let node = walkerEnd.nextNode(); node; node = walkerEnd.nextNode()) {
|
||||
lastTextNode = node;
|
||||
}
|
||||
expect(lastTextNode).not.toBeNull();
|
||||
const endRange = doc.createRange();
|
||||
endRange.setStart(lastTextNode!, lastTextNode!.textContent!.length);
|
||||
endRange.setEnd(lastTextNode!, lastTextNode!.textContent!.length);
|
||||
const endCfi = view.getCFI(sectionIndex, endRange);
|
||||
const endResult = await view.getCFIProgress(endCfi);
|
||||
|
||||
expect(startResult).not.toBeNull();
|
||||
expect(endResult).not.toBeNull();
|
||||
expect(endResult!.section.current).toBe(sectionIndex);
|
||||
expect(endResult!.fraction).toBeGreaterThan(startResult!.fraction);
|
||||
});
|
||||
|
||||
it('should return a fraction between start and end for a midpoint CFI', async () => {
|
||||
const sectionIndex = book.sections!.findIndex((s) => s.linear !== 'no' && s.size > 1000);
|
||||
expect(sectionIndex).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const doc = await book.sections![sectionIndex]!.createDocument();
|
||||
const body = doc.body ?? doc.documentElement;
|
||||
|
||||
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
||||
const textNodes: Node[] = [];
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
textNodes.push(node);
|
||||
}
|
||||
expect(textNodes.length).toBeGreaterThan(0);
|
||||
|
||||
const midNodeIndex = Math.floor(textNodes.length / 2);
|
||||
const midNode = textNodes[midNodeIndex]!;
|
||||
const midOffset = Math.floor((midNode.textContent?.length ?? 0) / 2);
|
||||
|
||||
const range = doc.createRange();
|
||||
range.setStart(midNode, midOffset);
|
||||
range.setEnd(midNode, midOffset);
|
||||
|
||||
const cfi = view.getCFI(sectionIndex, range);
|
||||
const result = await view.getCFIProgress(cfi);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.section.current).toBe(sectionIndex);
|
||||
expect(result!.fraction).toBeGreaterThan(0);
|
||||
expect(result!.fraction).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it('should produce consistent results for the same CFI', async () => {
|
||||
const sectionIndex = book.sections!.findIndex((s) => s.linear !== 'no' && s.size > 500);
|
||||
const doc = await book.sections![sectionIndex]!.createDocument();
|
||||
const body = doc.body ?? doc.documentElement;
|
||||
|
||||
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
||||
const node = walker.nextNode();
|
||||
expect(node).not.toBeNull();
|
||||
|
||||
const range = doc.createRange();
|
||||
const offset = Math.min(3, node!.textContent!.length);
|
||||
range.setStart(node!, offset);
|
||||
range.setEnd(node!, offset);
|
||||
|
||||
const cfi = view.getCFI(sectionIndex, range);
|
||||
|
||||
const result1 = await view.getCFIProgress(cfi);
|
||||
const result2 = await view.getCFIProgress(cfi);
|
||||
|
||||
expect(result1).not.toBeNull();
|
||||
expect(result2).not.toBeNull();
|
||||
expect(result1!.fraction).toBe(result2!.fraction);
|
||||
expect(result1!.section.current).toBe(result2!.section.current);
|
||||
expect(result1!.location.current).toBe(result2!.location.current);
|
||||
});
|
||||
|
||||
it('should return increasing fractions for successive CFIs in the same section', async () => {
|
||||
const sectionIndex = book.sections!.findIndex((s) => s.linear !== 'no' && s.size > 2000);
|
||||
expect(sectionIndex).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const doc = await book.sections![sectionIndex]!.createDocument();
|
||||
const body = doc.body ?? doc.documentElement;
|
||||
|
||||
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
||||
const textNodes: Node[] = [];
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
textNodes.push(node);
|
||||
}
|
||||
|
||||
const positions = [0, Math.floor(textNodes.length / 4), Math.floor((textNodes.length * 3) / 4)];
|
||||
const fractions: number[] = [];
|
||||
|
||||
for (const pos of positions) {
|
||||
const node = textNodes[pos]!;
|
||||
const range = doc.createRange();
|
||||
range.setStart(node, 0);
|
||||
range.setEnd(node, 0);
|
||||
const cfi = view.getCFI(sectionIndex, range);
|
||||
const result = await view.getCFIProgress(cfi);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.section.current).toBe(sectionIndex);
|
||||
fractions.push(result!.fraction);
|
||||
}
|
||||
|
||||
for (let i = 1; i < fractions.length; i++) {
|
||||
expect(fractions[i]).toBeGreaterThan(fractions[i - 1]!);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return increasing fractions across different sections', async () => {
|
||||
const linearSections = book
|
||||
.sections!.map((s, i) => ({ s, i }))
|
||||
.filter(({ s }) => s.linear !== 'no' && s.size > 0);
|
||||
|
||||
expect(linearSections.length).toBeGreaterThan(1);
|
||||
|
||||
const fractions: number[] = [];
|
||||
|
||||
for (const { s, i } of linearSections.slice(0, 3)) {
|
||||
const doc = await s.createDocument();
|
||||
const body = doc.body ?? doc.documentElement;
|
||||
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
||||
const firstTextNode = walker.nextNode();
|
||||
if (!firstTextNode) continue;
|
||||
|
||||
const range = doc.createRange();
|
||||
range.setStart(firstTextNode, 0);
|
||||
range.setEnd(firstTextNode, 0);
|
||||
|
||||
const cfi = view.getCFI(i, range);
|
||||
const result = await view.getCFIProgress(cfi);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.section.current).toBe(i);
|
||||
expect(result!.fraction).toBeGreaterThanOrEqual(0);
|
||||
expect(result!.fraction).toBeLessThanOrEqual(1);
|
||||
fractions.push(result!.fraction);
|
||||
}
|
||||
|
||||
// Fractions across sections should be increasing
|
||||
for (let i = 1; i < fractions.length; i++) {
|
||||
expect(fractions[i]).toBeGreaterThan(fractions[i - 1]!);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return null for an invalid CFI', async () => {
|
||||
const result = await view.getCFIProgress('epubcfi(/99/999!/0)');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return progress for real CFIs', async () => {
|
||||
const expectedData = [
|
||||
{ cfi: 'epubcfi(/99/999!/0)', fraction: null },
|
||||
{ cfi: 'epubcfi(/6/4!/4/2/6,/1:0,/1:13)', fraction: 0.009, page: 1 },
|
||||
{ cfi: 'epubcfi(/6/8!/4/2/2[chapter_458]/4/18,/1:0,/1:681)', fraction: 0.045, page: 5 },
|
||||
{ cfi: 'epubcfi(/6/10!/4/2/2[chapter_460]/2,/3:1,/3:18)', fraction: 0.098, page: 11 },
|
||||
{ cfi: 'epubcfi(/6/10!/4/2/2[chapter_460]/4/70,/1:0,/1:261)', fraction: 0.161, page: 19 },
|
||||
{ cfi: 'epubcfi(/6/12!/4/2/2[chapter_462]/4/22,/1:0,/1:156)', fraction: 0.179, page: 21 },
|
||||
{ cfi: 'epubcfi(/6/18!/4/2/2[chapter_468]/4/6,/1:0,/1:65)', fraction: 0.411, page: 46 },
|
||||
{ cfi: 'epubcfi(/6/18!/4/2/2[chapter_468]/4/94,/1:1,/1:30)', fraction: 0.446, page: 51 },
|
||||
{ cfi: 'epubcfi(/6/18!/4/2/2[chapter_468]/4/134,/1:0,/1:58)', fraction: 0.473, page: 54 },
|
||||
{ cfi: 'epubcfi(/6/18!/4/2/2[chapter_468]/4/170,/1:0,/1:124)', fraction: 0.491, page: 55 },
|
||||
{ cfi: 'epubcfi(/6/26!/4/2/2[chapter_476]/4/120,/1:0,/1:117)', fraction: 0.804, page: 91 },
|
||||
{ cfi: 'epubcfi(/6/30!/4/2/2[chapter_480]/4,/82/1:0,/86/1:26)', fraction: 0.955, page: 107 },
|
||||
{ cfi: 'epubcfi(/6/30!/4/2/2[chapter_480]/4/134,/1:1,/1:118)', fraction: 0.955, page: 108 },
|
||||
{ cfi: 'epubcfi(/6/30!/4/2/2[chapter_480]/4/134,/1:119,/1:358)', fraction: 0.955, page: 108 },
|
||||
];
|
||||
|
||||
for (const { cfi, fraction, page } of expectedData) {
|
||||
const result = await view.getCFIProgress(cfi);
|
||||
if (fraction === null) {
|
||||
expect(result).toBeNull();
|
||||
} else {
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.fraction).toBeCloseTo(fraction, 1);
|
||||
expect(result!.location.current).toBe(page! - 1);
|
||||
expect(Math.abs(result!.fraction - fraction)).toBeLessThan(0.006);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { PageProgress } from 'foliate-js/progress.js';
|
||||
|
||||
const createHTML = (html: string): Document => {
|
||||
const parser = new DOMParser();
|
||||
return parser.parseFromString(`<!DOCTYPE html><html><body>${html}</body></html>`, 'text/html');
|
||||
};
|
||||
|
||||
const makeBook = (sections: { html: string }[]) => ({
|
||||
sections: sections.map(({ html }) => ({
|
||||
createDocument: () => Promise.resolve(createHTML(html)),
|
||||
})),
|
||||
});
|
||||
|
||||
describe('PageProgress', () => {
|
||||
describe('getProgress', () => {
|
||||
it('should return fraction 0 when anchor targets the start of a section', async () => {
|
||||
const book = makeBook([{ html: '<p>Hello world</p>' }]);
|
||||
const resolveNavigation = () => ({
|
||||
index: 0,
|
||||
anchor: (doc: Document) => {
|
||||
const text = doc.body.querySelector('p')!.firstChild!;
|
||||
const range = doc.createRange();
|
||||
range.setStart(text, 0);
|
||||
range.setEnd(text, 0);
|
||||
return range;
|
||||
},
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('cfi-start');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.fraction).toBe(0);
|
||||
expect(result!.index).toBe(0);
|
||||
});
|
||||
|
||||
it('should return fraction 1 when anchor targets the end of a section', async () => {
|
||||
const book = makeBook([{ html: '<p>Hello world</p>' }]);
|
||||
const resolveNavigation = () => ({
|
||||
index: 0,
|
||||
anchor: (doc: Document) => {
|
||||
const text = doc.body.querySelector('p')!.firstChild!;
|
||||
const range = doc.createRange();
|
||||
range.setStart(text, text.textContent!.length);
|
||||
range.setEnd(text, text.textContent!.length);
|
||||
return range;
|
||||
},
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('cfi-end');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.fraction).toBe(1);
|
||||
expect(result!.index).toBe(0);
|
||||
});
|
||||
|
||||
it('should return correct fraction for a midpoint in a section', async () => {
|
||||
const book = makeBook([{ html: '<p>abcdefghij</p>' }]);
|
||||
const resolveNavigation = () => ({
|
||||
index: 0,
|
||||
anchor: (doc: Document) => {
|
||||
const text = doc.body.querySelector('p')!.firstChild!;
|
||||
const range = doc.createRange();
|
||||
range.setStart(text, 5);
|
||||
range.setEnd(text, 5);
|
||||
return range;
|
||||
},
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('cfi-mid');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.fraction).toBe(0.5);
|
||||
expect(result!.index).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle multiple text nodes across elements', async () => {
|
||||
const book = makeBook([{ html: '<p>aaaa</p><p>bbbb</p><p>cccc</p>' }]);
|
||||
const resolveNavigation = () => ({
|
||||
index: 0,
|
||||
anchor: (doc: Document) => {
|
||||
// target start of second <p>, which is at offset 4 of 12 total chars
|
||||
const text = doc.body.querySelectorAll('p')[1]!.firstChild!;
|
||||
const range = doc.createRange();
|
||||
range.setStart(text, 0);
|
||||
range.setEnd(text, 0);
|
||||
return range;
|
||||
},
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('cfi-multi');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.fraction).toBeCloseTo(4 / 12);
|
||||
expect(result!.index).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle anchor returning an Element instead of a Range', async () => {
|
||||
const book = makeBook([{ html: '<p>first</p><p id="target">second</p><p>third</p>' }]);
|
||||
const resolveNavigation = () => ({
|
||||
index: 0,
|
||||
anchor: (doc: Document) => doc.getElementById('target')!,
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('cfi-element');
|
||||
expect(result).not.toBeNull();
|
||||
// "first" is 5 chars, "second" starts at offset 5 of 16 total
|
||||
expect(result!.fraction).toBeCloseTo(5 / 16);
|
||||
expect(result!.index).toBe(0);
|
||||
});
|
||||
|
||||
it('should return correct index for non-zero section', async () => {
|
||||
const book = makeBook([
|
||||
{ html: '<p>chapter one</p>' },
|
||||
{ html: '<p>chapter two content</p>' },
|
||||
]);
|
||||
const resolveNavigation = () => ({
|
||||
index: 1,
|
||||
anchor: (doc: Document) => {
|
||||
const text = doc.body.querySelector('p')!.firstChild!;
|
||||
const range = doc.createRange();
|
||||
range.setStart(text, 0);
|
||||
range.setEnd(text, 0);
|
||||
return range;
|
||||
},
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('cfi-section1');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.fraction).toBe(0);
|
||||
expect(result!.index).toBe(1);
|
||||
});
|
||||
|
||||
it('should return null when resolveNavigation returns no index', async () => {
|
||||
const book = makeBook([{ html: '<p>text</p>' }]);
|
||||
const resolveNavigation = (): { index: undefined; anchor: undefined } => ({
|
||||
index: undefined,
|
||||
anchor: undefined,
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('bad-cfi');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when resolveNavigation returns no anchor', async () => {
|
||||
const book = makeBook([{ html: '<p>text</p>' }]);
|
||||
const resolveNavigation = (): { index: number; anchor: undefined } => ({
|
||||
index: 0,
|
||||
anchor: undefined,
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('no-anchor');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when section has no createDocument', async () => {
|
||||
const book = { sections: [{}] };
|
||||
const resolveNavigation = () => ({
|
||||
index: 0,
|
||||
anchor: () => null,
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('no-doc');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return fraction 0 for an empty document', async () => {
|
||||
const book = makeBook([{ html: '' }]);
|
||||
const resolveNavigation = () => ({
|
||||
index: 0,
|
||||
anchor: (doc: Document) => {
|
||||
const range = doc.createRange();
|
||||
range.selectNodeContents(doc.body);
|
||||
return range;
|
||||
},
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('cfi-empty');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.fraction).toBe(0);
|
||||
});
|
||||
|
||||
it('should return null and log error when resolveNavigation throws', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const book = makeBook([{ html: '<p>text</p>' }]);
|
||||
const resolveNavigation = () => {
|
||||
throw new Error('bad cfi');
|
||||
};
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('invalid');
|
||||
expect(result).toBeNull();
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle nested elements correctly', async () => {
|
||||
const book = makeBook([{ html: '<div><span>ab</span><em>cd</em></div><p>ef</p>' }]);
|
||||
const resolveNavigation = () => ({
|
||||
index: 0,
|
||||
anchor: (doc: Document) => {
|
||||
// target the <em> text node "cd" at offset 0, which is at char 2 of 6
|
||||
const text = doc.body.querySelector('em')!.firstChild!;
|
||||
const range = doc.createRange();
|
||||
range.setStart(text, 0);
|
||||
range.setEnd(text, 2);
|
||||
return range;
|
||||
},
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('cfi-nested');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.fraction).toBeCloseTo(2 / 6);
|
||||
});
|
||||
});
|
||||
});
|
||||
Binary file not shown.
@@ -47,6 +47,7 @@ const BookmarkToggler: React.FC<BookmarkTogglerProps> = ({ bookKey }) => {
|
||||
cfi,
|
||||
text: truncatedText ? truncatedText : `${getCurrentPage(bookData.book!, progress)}`,
|
||||
note: '',
|
||||
page: progress.page,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
@@ -57,6 +58,7 @@ const BookmarkToggler: React.FC<BookmarkTogglerProps> = ({ bookKey }) => {
|
||||
existingBookmark.deletedAt = null;
|
||||
existingBookmark.updatedAt = Date.now();
|
||||
existingBookmark.text = bookmark.text;
|
||||
existingBookmark.page = bookmark.page;
|
||||
} else {
|
||||
bookmarks.push(bookmark);
|
||||
}
|
||||
|
||||
@@ -421,6 +421,31 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const updateBooknotesPage = async () => {
|
||||
const config = getConfig(bookKey);
|
||||
const view = getView(bookKey);
|
||||
if (!config || !view) return;
|
||||
const { booknotes: annotations = [] } = config;
|
||||
annotations.sort((a, b) => {
|
||||
return CFI.compare(a.cfi, b.cfi);
|
||||
});
|
||||
for (const annotation of annotations) {
|
||||
if (annotation.deletedAt || annotation.page || !annotation.cfi) continue;
|
||||
const progress = await view.getCFIProgress(annotation.cfi);
|
||||
if (progress) {
|
||||
annotation.page = progress.location.current + 1;
|
||||
}
|
||||
}
|
||||
const updatedConfig = updateBooknotes(bookKey, annotations);
|
||||
if (updatedConfig) {
|
||||
saveConfig(envConfig, bookKey, updatedConfig, settings);
|
||||
}
|
||||
};
|
||||
setTimeout(updateBooknotesPage, 3000);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleQuickAction = () => {
|
||||
const action = viewSettings.annotationQuickAction;
|
||||
if (appService?.isAndroidApp && !androidTouchEndRef.current) return;
|
||||
@@ -585,6 +610,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
cfi,
|
||||
text: selection.text,
|
||||
note: '',
|
||||
page: progress.page,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
@@ -625,6 +651,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
color,
|
||||
text: selection.text,
|
||||
note: '',
|
||||
page: progress.page,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
@@ -64,7 +64,7 @@ const ExportMarkdownDialog: React.FC<ExportMarkdownDialogProps> = ({
|
||||
{% if annotation.note %}
|
||||
**${_('Note:')}** {{ annotation.note }}
|
||||
{% endif %}
|
||||
*${_('Time:')} {{ annotation.timestamp | date('%Y-%m-%d %H:%M') }}*
|
||||
*${_('Page:')} {{ annotation.page }} · ${_('Time:')} {{ annotation.timestamp | date('%Y-%m-%d %H:%M') }}*
|
||||
{% endfor %}
|
||||
|
||||
---
|
||||
@@ -122,10 +122,12 @@ const ExportMarkdownDialog: React.FC<ExportMarkdownDialogProps> = ({
|
||||
chapters: sortedGroups.map((group) => ({
|
||||
title: group.label || _('Untitled'),
|
||||
annotations: group.booknotes.map((note) => ({
|
||||
...note,
|
||||
text: note.text || '',
|
||||
note: note.note || '',
|
||||
style: note.style,
|
||||
color: note.color,
|
||||
page: note.page,
|
||||
timestamp: note.updatedAt,
|
||||
})),
|
||||
})),
|
||||
@@ -182,11 +184,19 @@ const ExportMarkdownDialog: React.FC<ExportMarkdownDialogProps> = ({
|
||||
lines.push(`**${_('Note')}**: ${note.note}`);
|
||||
}
|
||||
|
||||
// Add timestamp
|
||||
let pageStr = '';
|
||||
if (exportConfig.includePageNumber && note.page) {
|
||||
pageStr = `${_('Page: {{number}}', { number: note.page })}`;
|
||||
}
|
||||
let timestampStr = '';
|
||||
if (exportConfig.includeTimestamp && note.updatedAt) {
|
||||
const timestamp = new Date(note.updatedAt).toLocaleString();
|
||||
timestampStr = `${_('Time:')} ${timestamp}`;
|
||||
}
|
||||
if (pageStr || timestampStr) {
|
||||
lines.push('');
|
||||
lines.push(`*${_('Time:')} ${timestamp}*`);
|
||||
const infoStr = pageStr ? `${pageStr} · ${timestampStr}`.trim() : timestampStr;
|
||||
lines.push(`*${infoStr}*`);
|
||||
}
|
||||
|
||||
lines.push(exportConfig.noteSeparator);
|
||||
@@ -322,6 +332,17 @@ const ExportMarkdownDialog: React.FC<ExportMarkdownDialogProps> = ({
|
||||
<span className='text-sm'>{_('Notes')}</span>
|
||||
</label>
|
||||
|
||||
<label className='flex cursor-pointer items-center gap-2'>
|
||||
<input
|
||||
type='checkbox'
|
||||
checked={exportConfig.includePageNumber}
|
||||
onChange={() => handleToggle('includePageNumber')}
|
||||
className='checkbox checkbox-sm'
|
||||
disabled={exportConfig.useCustomTemplate}
|
||||
/>
|
||||
<span className='text-sm'>{_('Page Number')}</span>
|
||||
</label>
|
||||
|
||||
<label className='flex cursor-pointer items-center gap-2'>
|
||||
<input
|
||||
type='checkbox'
|
||||
@@ -461,6 +482,10 @@ const ExportMarkdownDialog: React.FC<ExportMarkdownDialogProps> = ({
|
||||
<code className='bg-base-300 rounded px-1'>annotation.color</code> -{' '}
|
||||
{_('Annotation color')}: yellow | red | green | blue | violet
|
||||
</li>
|
||||
<li className='ml-8'>
|
||||
<code className='bg-base-300 rounded px-1'>annotation.page</code> -{' '}
|
||||
{_('Annotation page number')}
|
||||
</li>
|
||||
<li className='ml-8'>
|
||||
<code className='bg-base-300 rounded px-1'>annotation.timestamp</code> -{' '}
|
||||
{_('Annotation time')}
|
||||
|
||||
@@ -40,7 +40,7 @@ const Notebook: React.FC = ({}) => {
|
||||
useNotebookStore();
|
||||
const { notebookNewAnnotation, notebookEditAnnotation, setNotebookPin } = useNotebookStore();
|
||||
const { getBookData, getConfig, saveConfig, updateBooknotes } = useBookDataStore();
|
||||
const { getView, getViewSettings } = useReaderStore();
|
||||
const { getView, getProgress, getViewSettings } = useReaderStore();
|
||||
const { getNotebookWidth, setNotebookWidth, setNotebookVisible, toggleNotebookPin } =
|
||||
useNotebookStore();
|
||||
const { setNotebookNewAnnotation, setNotebookEditAnnotation, setNotebookActiveTab } =
|
||||
@@ -129,6 +129,7 @@ const Notebook: React.FC = ({}) => {
|
||||
if (!sideBarBookKey) return;
|
||||
const view = getView(sideBarBookKey);
|
||||
const config = getConfig(sideBarBookKey)!;
|
||||
const progress = getProgress(sideBarBookKey)!;
|
||||
|
||||
const cfi = view?.getCFI(selection.index, selection.range);
|
||||
if (!cfi) return;
|
||||
@@ -139,6 +140,7 @@ const Notebook: React.FC = ({}) => {
|
||||
type: 'annotation',
|
||||
cfi,
|
||||
note,
|
||||
page: progress.page,
|
||||
text: selection.text,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
@@ -156,6 +158,7 @@ const Notebook: React.FC = ({}) => {
|
||||
if (!sideBarBookKey) return;
|
||||
const view = getView(sideBarBookKey);
|
||||
const config = getConfig(sideBarBookKey)!;
|
||||
const progress = getProgress(sideBarBookKey)!;
|
||||
const { booknotes: annotations = [] } = config;
|
||||
const existingIndex = annotations.findIndex((item) => item.id === note.id);
|
||||
if (existingIndex === -1) return;
|
||||
@@ -164,6 +167,7 @@ const Notebook: React.FC = ({}) => {
|
||||
} else {
|
||||
note.updatedAt = Date.now();
|
||||
}
|
||||
note.page = progress.page;
|
||||
annotations[existingIndex] = note;
|
||||
view?.addAnnotation({ ...note, value: `${NOTE_PREFIX}${note.cfi}` }, true);
|
||||
const updatedConfig = updateBooknotes(sideBarBookKey, annotations);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import clsx from 'clsx';
|
||||
import dayjs from 'dayjs';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { MdEdit, MdDelete } from 'react-icons/md';
|
||||
|
||||
import { marked } from 'marked';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
@@ -39,6 +40,7 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item, onClick }) =
|
||||
const [editorDraft, setEditorDraft] = useState(text || '');
|
||||
const [inlineEditMode, setInlineEditMode] = useState(false);
|
||||
const separatorWidth = useResponsiveSize(3);
|
||||
const size18 = useResponsiveSize(18);
|
||||
|
||||
const progress = getProgress(bookKey);
|
||||
const { isCurrent, viewRef } = useScrollToItem(cfi, progress);
|
||||
@@ -130,6 +132,8 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item, onClick }) =
|
||||
);
|
||||
}
|
||||
|
||||
const isEditable = item.note || item.type === 'bookmark';
|
||||
|
||||
return (
|
||||
<li
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role
|
||||
@@ -212,8 +216,10 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item, onClick }) =
|
||||
className={clsx(
|
||||
'max-h-0 overflow-hidden p-0',
|
||||
'transition-[max-height] duration-300 ease-in-out',
|
||||
'group-hover:max-h-8 group-hover:overflow-visible',
|
||||
'group-focus-within:max-h-8 group-focus-within:overflow-visible',
|
||||
'group-focus-within:overflow-visible group-hover:overflow-visible',
|
||||
isEditable
|
||||
? 'group-focus-within:max-h-12 group-hover:max-h-12'
|
||||
: 'group-focus-within:max-h-8 group-hover:max-h-8',
|
||||
)}
|
||||
style={
|
||||
{
|
||||
@@ -223,30 +229,41 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item, onClick }) =
|
||||
// This is needed to prevent the parent onClick from being triggered
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className='flex cursor-default items-center justify-between'>
|
||||
<div className='flex items-center'>
|
||||
<span className='text-sm text-gray-500 sm:text-xs'>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex cursor-default items-center justify-between py-2',
|
||||
isEditable && 'flex-col',
|
||||
)}
|
||||
>
|
||||
<div className='flex w-full items-center gap-1 truncate'>
|
||||
<span className='truncate text-sm text-gray-500 sm:text-xs'>
|
||||
{item.page ? _('p {{page}}' + ' · ', { page: item.page }) : ''}
|
||||
</span>
|
||||
<span className='truncate text-sm text-gray-500 sm:text-xs'>
|
||||
{dayjs(item.createdAt).fromNow()}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-end space-x-3 p-2' dir='ltr'>
|
||||
{(item.note || item.type === 'bookmark') && (
|
||||
<TextButton
|
||||
<div
|
||||
className={clsx('flex items-center justify-end gap-3', isEditable && 'w-full')}
|
||||
dir='ltr'
|
||||
>
|
||||
{isEditable && (
|
||||
<button
|
||||
onClick={item.type === 'bookmark' ? editBookmark : editNote.bind(null, item)}
|
||||
variant='primary'
|
||||
className='opacity-0 transition duration-300 ease-in-out group-focus-within:opacity-100 group-hover:opacity-100'
|
||||
className='btn btn-ghost btn-xs p-0 text-blue-500 opacity-0 transition duration-300 ease-in-out hover:bg-transparent group-focus-within:opacity-100 group-hover:opacity-100'
|
||||
aria-label={_('Edit')}
|
||||
>
|
||||
{_('Edit')}
|
||||
</TextButton>
|
||||
<MdEdit size={size18} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<TextButton
|
||||
<button
|
||||
onClick={deleteNote.bind(null, item)}
|
||||
variant='danger'
|
||||
className='opacity-0 transition duration-300 ease-in-out group-focus-within:opacity-100 group-hover:opacity-100'
|
||||
className='btn btn-ghost btn-xs p-0 text-red-500 opacity-0 transition duration-300 ease-in-out hover:bg-transparent group-focus-within:opacity-100 group-hover:opacity-100'
|
||||
aria-label={_('Delete')}
|
||||
>
|
||||
{_('Delete')}
|
||||
</TextButton>
|
||||
<MdDelete size={size18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import React from 'react';
|
||||
import { Insets } from '@/types/misc';
|
||||
import { TOCItem } from '@/libs/document';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useBooknotesNav } from '../../hooks/useBooknotesNav';
|
||||
import ContentNavBar from './ContentNavBar';
|
||||
|
||||
@@ -25,8 +26,9 @@ const BooknotesNav: React.FC<BooknotesNavProps> = ({ bookKey, gridInsets, toc })
|
||||
handleNext,
|
||||
} = useBooknotesNav(bookKey, toc);
|
||||
const _ = useTranslation();
|
||||
const { hoveredBookKey } = useReaderStore();
|
||||
|
||||
if (!showBooknotesNav) {
|
||||
if (!showBooknotesNav || hoveredBookKey === bookKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ const ContentNavBar: React.FC<ContentNavBarProps> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
className='results-nav-bar pointer-events-none absolute inset-0 z-10 flex flex-col items-center justify-end'
|
||||
className='results-nav-bar pointer-events-none absolute inset-0 z-20 flex flex-col items-center justify-end'
|
||||
style={{
|
||||
top: gridInsets.top,
|
||||
right: gridInsets.right,
|
||||
|
||||
@@ -3,6 +3,7 @@ import React from 'react';
|
||||
import { Insets } from '@/types/misc';
|
||||
import { BookSearchMatch, BookSearchResult } from '@/types/book';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSearchNav } from '../../hooks/useSearchNav';
|
||||
import ContentNavBar from './ContentNavBar';
|
||||
|
||||
@@ -25,8 +26,9 @@ const SearchResultsNav: React.FC<SearchResultsNavProps> = ({ bookKey, gridInsets
|
||||
handleNextResult,
|
||||
} = useSearchNav(bookKey);
|
||||
const _ = useTranslation();
|
||||
const { hoveredBookKey } = useReaderStore();
|
||||
|
||||
if (!showSearchNav) {
|
||||
if (!showSearchNav || hoveredBookKey === bookKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export const useInstantAnnotation = ({ bookKey, getAnnotationText }: UseInstantA
|
||||
const { envConfig } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
const { getConfig, saveConfig, updateBooknotes } = useBookDataStore();
|
||||
const { getView, getViewsById, getViewSettings } = useReaderStore();
|
||||
const { getView, getViewsById, getViewSettings, getProgress } = useReaderStore();
|
||||
|
||||
const startPointRef = useRef<Point | null>(null);
|
||||
const startDocRef = useRef<Document | null>(null);
|
||||
@@ -261,6 +261,7 @@ export const useInstantAnnotation = ({ bookKey, getAnnotationText }: UseInstantA
|
||||
views.forEach((v) => v?.addAnnotation(annotation));
|
||||
|
||||
const config = getConfig(bookKey)!;
|
||||
const progress = getProgress(bookKey)!;
|
||||
const { booknotes: annotations = [] } = config;
|
||||
const existingIndex = annotations.findIndex(
|
||||
(a) => a.cfi === cfi && a.type === 'annotation' && a.style && !a.deletedAt,
|
||||
@@ -270,10 +271,11 @@ export const useInstantAnnotation = ({ bookKey, getAnnotationText }: UseInstantA
|
||||
annotations[existingIndex] = {
|
||||
...annotations[existingIndex]!,
|
||||
...annotation,
|
||||
page: progress.page,
|
||||
id: annotations[existingIndex]!.id,
|
||||
};
|
||||
} else {
|
||||
annotations.push(annotation);
|
||||
annotations.push({ ...annotation, page: progress.page });
|
||||
}
|
||||
|
||||
const updatedConfig = updateBooknotes(bookKey, annotations);
|
||||
|
||||
@@ -307,6 +307,7 @@ export const DEFAULT_NOTE_EXPORT_CONFIG: NoteExportConfig = {
|
||||
includeChapterTitles: true,
|
||||
includeQuotes: true,
|
||||
includeNotes: true,
|
||||
includePageNumber: true,
|
||||
includeTimestamp: false,
|
||||
includeChapterSeparator: false,
|
||||
noteSeparator: '\n\n',
|
||||
|
||||
@@ -298,8 +298,8 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
const viewState = state.viewStates[key];
|
||||
if (!viewState || !bookData) return state;
|
||||
|
||||
const pagePressInfo = bookData.isFixedLayout ? section : pageinfo;
|
||||
const progress: [number, number] = [pagePressInfo.current + 1, pagePressInfo.total];
|
||||
const pageInfo = bookData.isFixedLayout ? section : pageinfo;
|
||||
const progress: [number, number] = [pageInfo.current + 1, pageInfo.total];
|
||||
|
||||
// calculate progress percentage
|
||||
const progressPercentage = Math.round((progress[0] / progress[1]) * 100);
|
||||
@@ -365,6 +365,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
pageinfo,
|
||||
timeinfo,
|
||||
range,
|
||||
page: pageInfo.current + 1, // 1-based page number
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -85,6 +85,7 @@ export interface BookNote {
|
||||
id: string;
|
||||
type: BookNoteType;
|
||||
cfi: string;
|
||||
page?: number;
|
||||
text?: string;
|
||||
style?: HighlightStyle;
|
||||
color?: HighlightColor;
|
||||
@@ -242,6 +243,7 @@ export interface NoteExportConfig {
|
||||
includeChapterTitles: boolean;
|
||||
includeQuotes: boolean;
|
||||
includeNotes: boolean;
|
||||
includePageNumber: boolean;
|
||||
includeTimestamp: boolean;
|
||||
includeChapterSeparator: boolean;
|
||||
noteSeparator: string;
|
||||
@@ -306,6 +308,7 @@ export interface BookProgress {
|
||||
pageinfo: PageInfo;
|
||||
timeinfo: TimeInfo;
|
||||
range: Range;
|
||||
page: number;
|
||||
}
|
||||
|
||||
export interface BookSearchConfig {
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface DBBookNote {
|
||||
id: string;
|
||||
type: string;
|
||||
cfi: string;
|
||||
page?: number;
|
||||
text?: string;
|
||||
style?: string;
|
||||
color?: string;
|
||||
|
||||
@@ -22,6 +22,12 @@ export interface FoliateView extends HTMLElement {
|
||||
goLeft: () => void;
|
||||
goRight: () => void;
|
||||
getCFI: (index: number, range: Range) => string;
|
||||
getCFIProgress: (cfi: string) => Promise<{
|
||||
fraction: number;
|
||||
section: { current: number; total: number };
|
||||
location: { current: number; next: number; total: number };
|
||||
time: { section: number; total: number };
|
||||
} | null>;
|
||||
resolveCFI: (cfi: string) => { index: number; anchor: RangeAnchor };
|
||||
resolveNavigation: (cfiOrHrefOrIndex: string | number) => { index: number; anchor?: RangeAnchor };
|
||||
addAnnotation: (
|
||||
|
||||
@@ -147,6 +147,7 @@ export const transformBookNoteToDB = (bookNote: unknown, userId: string): DBBook
|
||||
id,
|
||||
type,
|
||||
cfi,
|
||||
page,
|
||||
text,
|
||||
style,
|
||||
color,
|
||||
@@ -163,6 +164,7 @@ export const transformBookNoteToDB = (bookNote: unknown, userId: string): DBBook
|
||||
id,
|
||||
type,
|
||||
cfi,
|
||||
page,
|
||||
text: sanitizeString(text),
|
||||
style,
|
||||
color,
|
||||
@@ -181,6 +183,7 @@ export const transformBookNoteFromDB = (dbBookNote: DBBookNote): BookNote => {
|
||||
id,
|
||||
type,
|
||||
cfi,
|
||||
page,
|
||||
text,
|
||||
style,
|
||||
color,
|
||||
@@ -196,6 +199,7 @@ export const transformBookNoteFromDB = (dbBookNote: DBBookNote): BookNote => {
|
||||
id,
|
||||
type: type as BookNoteType,
|
||||
cfi,
|
||||
page,
|
||||
text,
|
||||
style: style as HighlightStyle,
|
||||
color: color as HighlightColor,
|
||||
|
||||
@@ -63,6 +63,7 @@ CREATE TABLE public.book_notes (
|
||||
style text NULL,
|
||||
color text NULL,
|
||||
note text NULL,
|
||||
page integer NULL,
|
||||
created_at timestamp with time zone NULL DEFAULT now(),
|
||||
updated_at timestamp with time zone NULL DEFAULT now(),
|
||||
deleted_at timestamp with time zone NULL,
|
||||
|
||||
+1
-1
Submodule packages/foliate-js updated: b025e611fd...53e4dab893
Reference in New Issue
Block a user