From 8c489aa3c0a9da9151ac0dc1bc56004bf09b1958 Mon Sep 17 00:00:00 2001 From: jenken827 Date: Sat, 12 Sep 2026 20:46:45 +0800 Subject: [PATCH 1/4] feat(reader): page-level notes fallback for formats without text selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PDF (and other formats without usable text selection) could not take notes: the selection popover's highlight/note buttons are format-gated (disabled: isPdf), and scanned PDFs have no text layer at all. Adds a page-level note fallback, reusing the existing highlight-with-note storage (zero schema/sync changes): - NotebookPanel header gains an add-note button (all formats; opens the inline editor anchored to the current position) - ReaderView.handleAddPageNote captures tab.currentCfi (PDF: page-level fake CFI that resolves back to the page; EPUB: current location CFI), current page number and chapter title - The editor shows page context instead of quoted text when the note has no selected text; saving an empty page-level note is disabled (it would be an invisible row) - List rendering falls back to a page-note placeholder when highlight.text is empty (NotebookPanel + NotesPage) - Clicking the note navigates via the existing navigateToCfi path — PDF fake CFIs resolve back to the recorded page The PDF selection popover gate stays as-is (per product decision); this button is the fallback entry. --- .../app/src/components/notes/NotesPage.tsx | 42 +++++++--- .../src/components/reader/NotebookPanel.tsx | 79 +++++++++++++++---- .../app/src/components/reader/ReaderView.tsx | 17 ++++ packages/core/src/i18n/locales/en/reader.json | 4 + packages/core/src/i18n/locales/es/reader.json | 4 + packages/core/src/i18n/locales/fr/reader.json | 4 + packages/core/src/i18n/locales/ja/reader.json | 4 + packages/core/src/i18n/locales/ko/reader.json | 4 + .../core/src/i18n/locales/zh-TW/reader.json | 4 + packages/core/src/i18n/locales/zh/reader.json | 4 + packages/core/src/stores/notebook-store.ts | 4 +- 11 files changed, 143 insertions(+), 27 deletions(-) diff --git a/packages/app/src/components/notes/NotesPage.tsx b/packages/app/src/components/notes/NotesPage.tsx index 4fbd54b49..7ab2d5fc3 100644 --- a/packages/app/src/components/notes/NotesPage.tsx +++ b/packages/app/src/components/notes/NotesPage.tsx @@ -654,12 +654,21 @@ function NoteDetailCard({
{/* Quoted highlight text */} -

- "{highlight.text}" -

+ {highlight.text ? ( +

+ "{highlight.text}" +

+ ) : ( +

+ {t("reader.notebook.pageNoteBadge")} +

+ )} {/* Note content */} {isEditing ? ( @@ -751,12 +760,21 @@ function HighlightDetailCard({ highlight, onDelete, onNavigate, t }: HighlightDe />
-

- "{highlight.text}" -

+ {highlight.text ? ( +

+ "{highlight.text}" +

+ ) : ( +

+ {t("reader.notebook.pageNoteBadge")} +

+ )}
diff --git a/packages/app/src/components/reader/NotebookPanel.tsx b/packages/app/src/components/reader/NotebookPanel.tsx index a57cc99ce..cdb38a5eb 100644 --- a/packages/app/src/components/reader/NotebookPanel.tsx +++ b/packages/app/src/components/reader/NotebookPanel.tsx @@ -13,6 +13,7 @@ import { ChevronDown, ChevronRight, Edit3, + FileText, Highlighter, NotebookPen, Save, @@ -30,6 +31,7 @@ import remarkGfm from "remark-gfm"; interface NotebookPanelProps { bookId: string; onClose: () => void; + onAddPageNote?: () => void; onGoToCfi?: (cfi: string) => void; onAddAnnotation?: (cfi: string, color: string, note?: string) => void; onDeleteAnnotation?: (cfi: string) => void; @@ -38,6 +40,7 @@ interface NotebookPanelProps { export function NotebookPanel({ bookId, onClose, + onAddPageNote, onGoToCfi, onAddAnnotation, onDeleteAnnotation, @@ -202,6 +205,15 @@ export function NotebookPanel({
{t("notebook.title")}
+ - @@ -381,7 +420,13 @@ function HighlightNoteItem({ style={{ backgroundColor: HIGHLIGHT_COLOR_HEX[highlight.color] }} />
-

"{highlight.text}"

+ {highlight.text ? ( +

"{highlight.text}"

+ ) : ( +

+ {t("notebook.pageNoteBadge")} +

+ )} {highlight.note && (
{highlight.note} @@ -440,7 +485,13 @@ function HighlightItem({ highlight, onClick, onAddNote, onDelete }: HighlightIte style={{ backgroundColor: HIGHLIGHT_COLOR_HEX[highlight.color] }} />
-

"{highlight.text}"

+ {highlight.text ? ( +

"{highlight.text}"

+ ) : ( +

+ {t("notebook.pageNoteBadge")} +

+ )} {highlight.chapterTitle && (

{highlight.chapterTitle}

)} diff --git a/packages/app/src/components/reader/ReaderView.tsx b/packages/app/src/components/reader/ReaderView.tsx index ade0140b9..6dd27b2be 100644 --- a/packages/app/src/components/reader/ReaderView.tsx +++ b/packages/app/src/components/reader/ReaderView.tsx @@ -1673,6 +1673,19 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { setSelection(null); }, [selection, bookId, highlights, readerTab?.chapterTitle]); + // Handle page-level note button — anchor a note to the current position so + // formats without usable text selection (e.g. PDF) can still take notes. + const handleAddPageNote = useCallback(() => { + const cfi = readerTab?.currentCfi; + if (!cfi) return; + useNotebookStore.getState().startNewNote({ + text: "", + cfi, + chapterTitle: readerTab?.chapterTitle, + page: currentPage || undefined, + }); + }, [readerTab?.currentCfi, readerTab?.chapterTitle, currentPage]); + const handleCopy = useCallback(() => { if (selection?.text) navigator.clipboard.writeText(selection.text); setSelection(null); @@ -2870,6 +2883,7 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { {/* Notebook sidebar — LEFT side */} { foliateRef.current?.addAnnotation({ @@ -3214,6 +3228,7 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { // Separate component to use notebook store hook function NotebookSidebarWrapper({ bookId, + onAddPageNote, onGoToCfi, onAddAnnotation, onDeleteAnnotation, @@ -3223,6 +3238,7 @@ function NotebookSidebarWrapper({ onResizeEnd, }: { bookId: string; + onAddPageNote?: () => void; onGoToCfi: (cfi: string) => void; onAddAnnotation: (cfi: string, color: string, note?: string) => void; onDeleteAnnotation: (cfi: string) => void; @@ -3250,6 +3266,7 @@ function NotebookSidebarWrapper({ Date: Sat, 12 Sep 2026 20:58:41 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(reader):=20page-note=20UX=20=E2=80=94?= =?UTF-8?q?=20Plus=20icon,=20dedupe=20save=20button,=20page-number=20label?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Header button icon NotebookPen -> Plus (an add affordance, not edit) - Remove the duplicated icon+label left inside the save button by the previous patch (rendered as two 保存 buttons) - Page-level note labels show the page number parsed from the fixed-layout fake CFI ("第N页笔记"); reflowable books fall back to the generic badge (chapter title below provides context) --- .../app/src/components/notes/NotesPage.tsx | 5 ++-- .../src/components/reader/NotebookPanel.tsx | 10 +++---- packages/app/src/lib/reader/page-note.ts | 29 +++++++++++++++++++ 3 files changed, 37 insertions(+), 7 deletions(-) create mode 100644 packages/app/src/lib/reader/page-note.ts diff --git a/packages/app/src/components/notes/NotesPage.tsx b/packages/app/src/components/notes/NotesPage.tsx index 7ab2d5fc3..c15bd77ae 100644 --- a/packages/app/src/components/notes/NotesPage.tsx +++ b/packages/app/src/components/notes/NotesPage.tsx @@ -33,6 +33,7 @@ import { */ import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; +import { pageNoteLabel } from "@/lib/reader/page-note"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { toast } from "sonner"; @@ -666,7 +667,7 @@ function NoteDetailCard({ className="text-xs text-muted-foreground italic leading-relaxed cursor-pointer hover:text-primary transition-colors line-clamp-2" onClick={onNavigate} > - {t("reader.notebook.pageNoteBadge")} + {pageNoteLabel(highlight.cfi, t)}

)} @@ -772,7 +773,7 @@ function HighlightDetailCard({ highlight, onDelete, onNavigate, t }: HighlightDe className="text-sm text-muted-foreground italic leading-relaxed cursor-pointer hover:text-primary transition-colors" onClick={onNavigate} > - {t("reader.notebook.pageNoteBadge")} + {pageNoteLabel(highlight.cfi, t)}

)} diff --git a/packages/app/src/components/reader/NotebookPanel.tsx b/packages/app/src/components/reader/NotebookPanel.tsx index cdb38a5eb..43d8c25e5 100644 --- a/packages/app/src/components/reader/NotebookPanel.tsx +++ b/packages/app/src/components/reader/NotebookPanel.tsx @@ -15,6 +15,7 @@ import { Edit3, FileText, Highlighter, + Plus, NotebookPen, Save, Trash2, @@ -26,6 +27,7 @@ import { import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import ReactMarkdown from "react-markdown"; +import { pageNoteLabel } from "@/lib/reader/page-note"; import remarkGfm from "remark-gfm"; interface NotebookPanelProps { @@ -212,7 +214,7 @@ export function NotebookPanel({ disabled={!onAddPageNote} title={t("notebook.addPageNote")} > - +
@@ -424,7 +424,7 @@ function HighlightNoteItem({

"{highlight.text}"

) : (

- {t("notebook.pageNoteBadge")} + {pageNoteLabel(highlight.cfi, t)}

)} {highlight.note && ( @@ -489,7 +489,7 @@ function HighlightItem({ highlight, onClick, onAddNote, onDelete }: HighlightIte

"{highlight.text}"

) : (

- {t("notebook.pageNoteBadge")} + {pageNoteLabel(highlight.cfi, t)}

)} {highlight.chapterTitle && ( diff --git a/packages/app/src/lib/reader/page-note.ts b/packages/app/src/lib/reader/page-note.ts new file mode 100644 index 000000000..2ac4d5ba9 --- /dev/null +++ b/packages/app/src/lib/reader/page-note.ts @@ -0,0 +1,29 @@ +/** + * Page-level note helpers. + * + * Page-level notes anchor to the current position. For fixed-layout books + * (PDF/CBZ) that position is a foliate "fake" section CFI — `epubcfi(/6/N)` + * where N = (pageIndex + 1) * 2 — so the page number can be recovered from + * the CFI for list labels. Reflowable books use real position CFIs, which + * carry no page number; their label falls back to the generic badge (the + * chapter title shown beneath provides the context). + */ + +const FAKE_SECTION_CFI_RE = /^epubcfi\(\/6\/(\d+)\)$/; + +export function parseFakeCfiPage(cfi: string): number | null { + const match = FAKE_SECTION_CFI_RE.exec(cfi.trim()); + if (!match) return null; + const page = Math.round(Number(match[1]) / 2); + return page > 0 ? page : null; +} + +type LabelT = (key: string, options?: Record) => string; + +/** List label for a page-level note: "第N页笔记" when the page is known. */ +export function pageNoteLabel(cfi: string, t: LabelT): string { + const page = parseFakeCfiPage(cfi); + return page + ? t("reader.notebook.pageNoteWithPage", { page }) + : t("reader.notebook.pageNoteBadge"); +} From 35554fb04b5bdc7293a66c0475437a3c359f9860 Mon Sep 17 00:00:00 2001 From: jenken827 Date: Sat, 12 Sep 2026 21:09:17 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(reader):=20page-note=20label=20i18n=20k?= =?UTF-8?q?eys=20=E2=80=94=20flat=20namespace,=20no=20reader.=20prefix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit i18n resources merge all locale files into a single translation namespace (reader.json's content is top-level, not nested under a reader key), so t("reader.notebook.pageNoteWithPage") looked up a nonexistent path and rendered the literal key. Use notebook.pageNoteWithPage / notebook.pageNoteBadge. NotesPage was already on the helper and picked up the fix automatically. --- packages/app/src/lib/reader/page-note.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/lib/reader/page-note.ts b/packages/app/src/lib/reader/page-note.ts index 2ac4d5ba9..6cdd5aa7c 100644 --- a/packages/app/src/lib/reader/page-note.ts +++ b/packages/app/src/lib/reader/page-note.ts @@ -24,6 +24,6 @@ type LabelT = (key: string, options?: Record) => string; export function pageNoteLabel(cfi: string, t: LabelT): string { const page = parseFakeCfiPage(cfi); return page - ? t("reader.notebook.pageNoteWithPage", { page }) - : t("reader.notebook.pageNoteBadge"); + ? t("notebook.pageNoteWithPage", { page }) + : t("notebook.pageNoteBadge"); } From b338e2032f60073eb4284dbe12d041198f85daad Mon Sep 17 00:00:00 2001 From: jenken827 Date: Sat, 12 Sep 2026 21:12:00 +0800 Subject: [PATCH 4/4] fix(i18n): commit the missing pageNoteWithPage key (was left uncommitted) --- packages/core/src/i18n/locales/en/reader.json | 1 + packages/core/src/i18n/locales/es/reader.json | 1 + packages/core/src/i18n/locales/fr/reader.json | 1 + packages/core/src/i18n/locales/ja/reader.json | 1 + packages/core/src/i18n/locales/ko/reader.json | 1 + packages/core/src/i18n/locales/zh-TW/reader.json | 1 + packages/core/src/i18n/locales/zh/reader.json | 1 + 7 files changed, 7 insertions(+) diff --git a/packages/core/src/i18n/locales/en/reader.json b/packages/core/src/i18n/locales/en/reader.json index 2adf64ed0..fcb296cf2 100644 --- a/packages/core/src/i18n/locales/en/reader.json +++ b/packages/core/src/i18n/locales/en/reader.json @@ -117,6 +117,7 @@ "pageNoteBadge": "Page note", "pageNotePage": "Page {{page}}", "pageNoteNoPage": "Current position", + "pageNoteWithPage": "Note on page {{page}}", "deleteHighlightBtn": "Delete highlight" }, "editor": { diff --git a/packages/core/src/i18n/locales/es/reader.json b/packages/core/src/i18n/locales/es/reader.json index d9fb47de5..27cde9d84 100644 --- a/packages/core/src/i18n/locales/es/reader.json +++ b/packages/core/src/i18n/locales/es/reader.json @@ -113,6 +113,7 @@ "pageNoteBadge": "Nota de página", "pageNotePage": "Página {{page}}", "pageNoteNoPage": "Posición actual", + "pageNoteWithPage": "Nota en la página {{page}}", "deleteHighlightBtn": "Eliminar subrayado" }, "editor": { diff --git a/packages/core/src/i18n/locales/fr/reader.json b/packages/core/src/i18n/locales/fr/reader.json index 5ef543d74..68b21a8d4 100644 --- a/packages/core/src/i18n/locales/fr/reader.json +++ b/packages/core/src/i18n/locales/fr/reader.json @@ -113,6 +113,7 @@ "pageNoteBadge": "Note de page", "pageNotePage": "Page {{page}}", "pageNoteNoPage": "Position actuelle", + "pageNoteWithPage": "Note page {{page}}", "deleteHighlightBtn": "Supprimer le surlignage" }, "editor": { diff --git a/packages/core/src/i18n/locales/ja/reader.json b/packages/core/src/i18n/locales/ja/reader.json index f09979db4..48fc7d36a 100644 --- a/packages/core/src/i18n/locales/ja/reader.json +++ b/packages/core/src/i18n/locales/ja/reader.json @@ -113,6 +113,7 @@ "pageNoteBadge": "ページメモ", "pageNotePage": "{{page}} ページ", "pageNoteNoPage": "現在位置", + "pageNoteWithPage": "{{page}} ページのメモ", "deleteHighlightBtn": "ハイライトを削除" }, "editor": { diff --git a/packages/core/src/i18n/locales/ko/reader.json b/packages/core/src/i18n/locales/ko/reader.json index af691ccf7..9b0c5511f 100644 --- a/packages/core/src/i18n/locales/ko/reader.json +++ b/packages/core/src/i18n/locales/ko/reader.json @@ -113,6 +113,7 @@ "pageNoteBadge": "페이지 메모", "pageNotePage": "{{page}}쪽", "pageNoteNoPage": "현재 위치", + "pageNoteWithPage": "{{page}}쪽 메모", "deleteHighlightBtn": "하이라이트 삭제" }, "editor": { diff --git a/packages/core/src/i18n/locales/zh-TW/reader.json b/packages/core/src/i18n/locales/zh-TW/reader.json index 9e39cb898..68a7ea35e 100644 --- a/packages/core/src/i18n/locales/zh-TW/reader.json +++ b/packages/core/src/i18n/locales/zh-TW/reader.json @@ -113,6 +113,7 @@ "pageNoteBadge": "頁級筆記", "pageNotePage": "第 {{page}} 頁", "pageNoteNoPage": "當前位置", + "pageNoteWithPage": "第 {{page}} 頁筆記", "deleteHighlightBtn": "刪除醒目標示" }, "editor": { diff --git a/packages/core/src/i18n/locales/zh/reader.json b/packages/core/src/i18n/locales/zh/reader.json index 5d282c089..79dcbb5c0 100644 --- a/packages/core/src/i18n/locales/zh/reader.json +++ b/packages/core/src/i18n/locales/zh/reader.json @@ -117,6 +117,7 @@ "pageNoteBadge": "页级笔记", "pageNotePage": "第 {{page}} 页", "pageNoteNoPage": "当前位置", + "pageNoteWithPage": "第 {{page}} 页笔记", "deleteHighlightBtn": "删除高亮" }, "editor": {