feat(annotator): add page number for annotations, closes #3082 (#3405)

* feat(annotator): add page number in annotation

* feat: add page number for annotations, closes #3082
This commit is contained in:
Huang Xin
2026-02-28 15:08:56 +08:00
committed by GitHub
parent b50bc0b854
commit c533da498d
48 changed files with 777 additions and 57 deletions
@@ -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);
});
});
});
@@ -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',
+3 -2
View File
@@ -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
},
},
},
+3
View File
@@ -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 {
+1
View File
@@ -41,6 +41,7 @@ export interface DBBookNote {
id: string;
type: string;
cfi: string;
page?: number;
text?: string;
style?: string;
color?: string;
+6
View File
@@ -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: (
+4
View File
@@ -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,
+1
View File
@@ -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,