From 8c489aa3c0a9da9151ac0dc1bc56004bf09b1958 Mon Sep 17 00:00:00 2001 From: jenken827 Date: Sat, 12 Sep 2026 20:46:45 +0800 Subject: [PATCH 1/5] 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/5] =?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/5] =?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/5] 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": { From 71c16e477b283976c936ce3c9f5fe2e30df66325 Mon Sep 17 00:00:00 2001 From: ForSourceCodeAnalysis Date: Fri, 25 Sep 2026 16:44:16 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix(notes):=20=E7=A7=BB=E5=8A=A8=E7=AB=AF?= =?UTF-8?q?=E9=A1=B5=E7=BA=A7=E7=AC=94=E8=AE=B0=E6=98=BE=E7=A4=BA=E9=A1=B5?= =?UTF-8?q?=E7=A0=81=E6=A0=87=E9=A2=98=EF=BC=8C=E5=B9=B6=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E3=80=8C=E6=B7=BB=E5=8A=A0=E9=A1=B5=E7=BA=A7=E7=AC=94=E8=AE=B0?= =?UTF-8?q?=E3=80=8D=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:固定版式(PDF/CBZ)不支持文本选择,回落到「页级笔记」(text 为空、锚定 foliate 假 section CFI)。 页码标签的推导逻辑只写在桌面端(packages/app/src/lib/reader/page-note.ts),移动端从未实现 —— 移动端页级笔记只剩一对空引号,分组还会落进「未知章节」。 - core:新增 reader/page-note.ts,parseFakeCfiPage / pageNoteLabel 移入 core 两端共用 · CFI 语义:假 section `epubcfi(/6/N)`,N = 页码 × 2(见 foliate-js/pdf.js 的 fakePageCfi) - app(桌面):lib/reader/page-note.ts 改为转发壳(与 font-themes / pagination / keyboard 等同款约定), 两个调用点保持不变 - app-expo(移动端): · notes/NoteCard:无引用文本时用页码标签替代,并补上原本缺失的章节标题行 · notes/HighlightCard、reader/ReaderNoteViewModal、components/reader/SelectionPopover:同样处理 · NotesView:没有章节标题的页级笔记按页码标签分组,不再全落「未知章节」 · 新增 reader/PageNoteModal,并在 ReaderScreen 笔记本弹窗头部加「+」入口 (对齐桌面 NotebookPanel 的 notebook.addPageNote):锚定当前页创建页级笔记,内容为空不可保存 · ReaderScreen 笔记本弹窗列表:页级笔记的空行改为页码标签 - 复用已有 i18n 键(notebook.pageNoteWithPage / pageNoteBadge / addPageNote),未新增翻译 注意:未跑 lint / typecheck / 单测(由用户自行验证)。 --- .../components/reader/SelectionPopover.tsx | 4 +- packages/app-expo/src/screens/NotesView.tsx | 8 +- .../app-expo/src/screens/ReaderScreen.tsx | 87 +++++++++- .../src/screens/notes/HighlightCard.tsx | 10 +- .../app-expo/src/screens/notes/NoteCard.tsx | 17 +- .../src/screens/notes/notes-styles.ts | 6 + .../src/screens/reader/PageNoteModal.tsx | 150 ++++++++++++++++++ .../screens/reader/ReaderNoteViewModal.tsx | 15 +- .../reader/styles/reader-sheet-styles.ts | 8 + packages/app/src/lib/reader/page-note.ts | 30 +--- packages/core/src/reader/index.ts | 3 + packages/core/src/reader/page-note.ts | 31 ++++ 12 files changed, 324 insertions(+), 45 deletions(-) create mode 100644 packages/app-expo/src/screens/reader/PageNoteModal.tsx create mode 100644 packages/core/src/reader/page-note.ts diff --git a/packages/app-expo/src/components/reader/SelectionPopover.tsx b/packages/app-expo/src/components/reader/SelectionPopover.tsx index daa748117..f7639510e 100644 --- a/packages/app-expo/src/components/reader/SelectionPopover.tsx +++ b/packages/app-expo/src/components/reader/SelectionPopover.tsx @@ -12,6 +12,7 @@ import { RichTextEditor } from "@/components/ui/RichTextEditor"; import type { SelectionEvent } from "@/hooks/use-reader-bridge"; import { radius, spacing, useColors, withOpacity } from "@/styles/theme"; import type { ThemeColors } from "@/styles/theme"; +import { pageNoteLabel } from "@readany/core/reader"; import { HIGHLIGHT_COLORS, HIGHLIGHT_COLOR_HEX } from "@readany/core/types"; import type { HighlightColor } from "@readany/core/types"; import * as Clipboard from "expo-clipboard"; @@ -296,7 +297,8 @@ export function SelectionPopover({ - {selection.text} + {/* 页级笔记(固定版式无选中文本):用「第N页笔记」这类位置标签替代空引用 */} + {selection.text || pageNoteLabel(selection.cfi, t)} (); for (const h of currentList) { - const chapter = h.chapterTitle || t("notes.unknownChapter", "未知章节"); + // 页级笔记(引用文本为空、固定版式)没有章节标题时用页码标签分组,别全落进「未知章节」 + const chapter = + h.chapterTitle || + (!h.text ? pageNoteLabel(h.cfi, t) : "") || + t("notes.unknownChapter", "未知章节"); const arr = chapterMap.get(chapter) || []; arr.push(h); chapterMap.set(chapter, arr); diff --git a/packages/app-expo/src/screens/ReaderScreen.tsx b/packages/app-expo/src/screens/ReaderScreen.tsx index 3eb75832a..f6965e220 100644 --- a/packages/app-expo/src/screens/ReaderScreen.tsx +++ b/packages/app-expo/src/screens/ReaderScreen.tsx @@ -14,6 +14,7 @@ import { HeadphonesIcon, LanguagesIcon, NotebookPenIcon, + PlusIcon, SearchIcon, XIcon, } from "@/components/ui/Icon"; @@ -39,7 +40,7 @@ import { readingContextService } from "@readany/core/ai/reading-context-service" import { runWithDbRetry } from "@readany/core/db/write-retry"; import { useChapterTranslation } from "@readany/core/hooks"; import { useReadingSession } from "@readany/core/hooks/use-reading-session"; -import { createSelectionNoteMutation } from "@readany/core/reader"; +import { createSelectionNoteMutation, pageNoteLabel } from "@readany/core/reader"; import { getPlatformService } from "@readany/core/services"; import { getCSSFontFace, useFontStore } from "@readany/core/stores"; import type { HighlightColor, ReadSettings, TOCItem } from "@readany/core/types"; @@ -74,6 +75,7 @@ import { SafeAreaView } from "react-native-safe-area-context"; import { WebView } from "react-native-webview"; // ── Extracted modules ── +import { PageNoteModal } from "./reader/PageNoteModal"; import { ReaderNoteViewModal } from "./reader/ReaderNoteViewModal"; const REFLOWABLE_CHARACTERS_PER_LOCATION = 1500; @@ -224,6 +226,9 @@ export function ReaderScreen({ route, navigation }: Props) { const [showSettings, setShowSettings] = useState(false); const [showSearch, setShowSearch] = useState(false); const [showNotebook, setShowNotebook] = useState(false); + // 页级笔记(固定版式无选中文本时的新建入口,对应桌面 NotebookPanel 的「+」) + const [showPageNote, setShowPageNote] = useState(false); + const [pageNoteContent, setPageNoteContent] = useState(""); const [showTranslation, setShowTranslation] = useState(false); const [translationText, setTranslationText] = useState(""); const [showTTS, setShowTTS] = useState(false); @@ -1048,6 +1053,39 @@ export function ReaderScreen({ route, navigation }: Props) { setSelection(null); }, []); + // 页级笔记:锚定「当前位置」,给固定版式(PDF/CBZ)这种没法选中文本的格式用。 + // 与桌面端 NotebookPanel 的「+」完全同语义:落库时 text 为空,页码由 CFI 反推。 + const handleSavePageNote = useCallback(() => { + if (!currentCfi || !bookId) return; + const mutation = createSelectionNoteMutation({ + bookId, + cfi: currentCfi, + text: "", + note: pageNoteContent, + chapterTitle: currentChapter || undefined, + defaultColor: readSettings.defaultHighlightColor ?? "yellow", + }); + if (mutation.kind === "create") { + addHighlight(mutation.highlight); + bridge.addAnnotation({ + value: currentCfi, + type: "highlight", + color: mutation.highlight.color, + note: mutation.highlight.note, + }); + } + setPageNoteContent(""); + setShowPageNote(false); + }, [ + currentCfi, + bookId, + pageNoteContent, + currentChapter, + readSettings.defaultHighlightColor, + addHighlight, + bridge, + ]); + useEffect(() => { setGoToCfiFn(() => bridge.goToCFI); return () => setGoToCfiFn(null); @@ -2043,9 +2081,23 @@ export function ReaderScreen({ route, navigation }: Props) { > {t("reader.notebook", "笔记本")} - setShowNotebook(false)}> - - + + {/* 「+」= 在当前页新建页级笔记(固定版式 PDF/CBZ 无选中文本时的入口; + 桌面端同名按钮位于 NotebookPanel 头部同一位置) */} + { + setPageNoteContent(""); + setShowPageNote(true); + }} + > + + + setShowNotebook(false)}> + + + {highlights.length > 0 ? ( @@ -2071,9 +2123,16 @@ export function ReaderScreen({ route, navigation }: Props) { ]} /> - - {h.text} - + {h.text ? ( + + {h.text} + + ) : ( + /* 页级笔记(text 为空):用「第N页笔记」这类位置标签代替空白引用 */ + + {pageNoteLabel(h.cfi, t)} + + )} {h.note && {h.note}} @@ -2090,6 +2149,20 @@ export function ReaderScreen({ route, navigation }: Props) { + {/* ─── Page-level Note Modal(固定版式「页级笔记」新建入口) ─── */} + { + setShowPageNote(false); + setPageNoteContent(""); + }} + onSave={handleSavePageNote} + /> + {/* ─── Note View Modal ─── */} void; }) { const colors = useColors(); + const { t } = useTranslation(); const s = makeStyles(colors); return ( @@ -24,7 +27,12 @@ export function HighlightCard({ /> - "{highlight.text}" + {highlight.text ? ( + "{highlight.text}" + ) : ( + /* 页级笔记(固定版式无选中文本):用「第N页笔记」这类位置标签替代引用 */ + {pageNoteLabel(highlight.cfi, t)} + )} {highlight.chapterTitle && {highlight.chapterTitle}} diff --git a/packages/app-expo/src/screens/notes/NoteCard.tsx b/packages/app-expo/src/screens/notes/NoteCard.tsx index c447469e2..071ae89bb 100644 --- a/packages/app-expo/src/screens/notes/NoteCard.tsx +++ b/packages/app-expo/src/screens/notes/NoteCard.tsx @@ -3,6 +3,7 @@ import { CheckIcon, EditIcon, Trash2Icon, XIcon } from "@/components/ui/Icon"; import { RichTextEditor } from "@/components/ui/RichTextEditor"; import { useColors } from "@/styles/theme"; import type { HighlightWithBook } from "@readany/core/db/database"; +import { pageNoteLabel } from "@readany/core/reader"; import { HIGHLIGHT_COLOR_HEX } from "@readany/core/types"; import type { TFunction } from "i18next"; import { Text, TouchableOpacity, View } from "react-native"; @@ -43,9 +44,19 @@ export function NoteCard({ { backgroundColor: HIGHLIGHT_COLOR_HEX[highlight.color] || colors.amber }, ]} /> - - "{highlight.text}" - + + {highlight.text ? ( + + "{highlight.text}" + + ) : ( + /* 页级笔记(固定版式无选中文本):用「第N页笔记」这类位置标签替代引用 */ + + {pageNoteLabel(highlight.cfi, t)} + + )} + {highlight.chapterTitle && {highlight.chapterTitle}} + {isEditing ? ( diff --git a/packages/app-expo/src/screens/notes/notes-styles.ts b/packages/app-expo/src/screens/notes/notes-styles.ts index 7c254d675..6372c812e 100644 --- a/packages/app-expo/src/screens/notes/notes-styles.ts +++ b/packages/app-expo/src/screens/notes/notes-styles.ts @@ -207,6 +207,10 @@ export const makeStyles = (colors: ThemeColors) => noteCardTop: { flexDirection: "row", alignItems: "flex-start", gap: 8 }, colorDot: { width: 10, height: 10, borderRadius: 5, marginTop: 4 }, noteQuote: { flex: 1, fontSize: fontSize.sm, color: colors.foreground, lineHeight: 20 }, + noteQuoteWrap: { flex: 1 }, + /** 页级笔记没有引用文本,这里显示「第N页笔记」这类位置标签 */ + noteQuoteEmpty: { color: colors.mutedForeground, fontStyle: "italic" }, + noteChapter: { fontSize: fontSize.xs, color: colors.mutedForeground, marginTop: 4 }, noteBody: { marginTop: 8, backgroundColor: colors.muted, @@ -274,6 +278,8 @@ export const makeStyles = (colors: ThemeColors) => }, highlightBody: { flex: 1, minWidth: 0 }, highlightText: { fontSize: fontSize.sm, color: colors.foreground, lineHeight: 20 }, + /** 页级笔记没有引用文本,这里显示「第N页笔记」这类位置标签 */ + highlightLabel: { fontSize: fontSize.sm, color: colors.mutedForeground, fontStyle: "italic", lineHeight: 20 }, highlightChapter: { fontSize: fontSize.xs, color: colors.mutedForeground, marginTop: 4 }, highlightDeleteBtn: { padding: 6, borderRadius: radius.sm }, exportOverlay: { flex: 1 }, diff --git a/packages/app-expo/src/screens/reader/PageNoteModal.tsx b/packages/app-expo/src/screens/reader/PageNoteModal.tsx new file mode 100644 index 000000000..ef93e2f91 --- /dev/null +++ b/packages/app-expo/src/screens/reader/PageNoteModal.tsx @@ -0,0 +1,150 @@ +/** + * PageNoteModal — 在「当前位置」新建页级笔记。 + * + * 对应桌面端 NotebookPanel 头部的「+」按钮(notebook.addPageNote): + * 固定版式(PDF/CBZ)没有可选中文本,选中→高亮→笔记那条链路用不了, + * 所以提供这个锚定当前页的入口:落库时 text 为空(即页级笔记),页码由 CFI 反推。 + */ +import { XIcon } from "@/components/ui/Icon"; +import { RichTextEditor } from "@/components/ui/RichTextEditor"; +import { fontSize, fontWeight, radius, spacing, useColors } from "@/styles/theme"; +import { pageNoteLabel } from "@readany/core/reader"; +import { KeyboardAvoidingView, Modal, Platform, StyleSheet, Text, TouchableOpacity, View } from "react-native"; +import { useTranslation } from "react-i18next"; + +interface Props { + visible: boolean; + /** 当前位置的 CFI(页级笔记的锚点) */ + cfi: string; + chapterTitle?: string; + content: string; + onContentChange: (content: string) => void; + onCancel: () => void; + onSave: () => void; +} + +export function PageNoteModal({ + visible, + cfi, + chapterTitle, + content, + onContentChange, + onCancel, + onSave, +}: Props) { + const colors = useColors(); + const { t } = useTranslation(); + const s = makeStyles(colors); + // 与桌面一致:页级笔记必须写点内容才能保存(否则只是一条空记录) + const canSave = content.trim().length > 0; + + return ( + + + + + + {t("notebook.addPageNote", "添加页级笔记")} + + + + + + {/* 位置预览:页级笔记没有引用文本,用页码标签(与列表标题同一套文案) */} + + {pageNoteLabel(cfi, t)} + + {chapterTitle ? ( + + {chapterTitle} + + ) : null} + + + + + + + + {t("common.cancel", "取消")} + + + {t("common.save", "保存")} + + + + + + ); +} + +function makeStyles(colors: ReturnType) { + return StyleSheet.create({ + overlay: { flex: 1, justifyContent: "flex-end", backgroundColor: "rgba(0,0,0,0.4)" }, + sheet: { + backgroundColor: colors.card, + borderTopLeftRadius: radius.lg, + borderTopRightRadius: radius.lg, + padding: spacing.md, + paddingBottom: spacing.lg, + }, + header: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + marginBottom: spacing.sm, + }, + title: { fontSize: fontSize.md, fontWeight: fontWeight.semibold, color: colors.foreground }, + closeBtn: { + width: 32, + height: 32, + borderRadius: 16, + alignItems: "center", + justifyContent: "center", + backgroundColor: colors.muted, + }, + preview: { + fontSize: fontSize.sm, + color: colors.mutedForeground, + fontStyle: "italic", + paddingHorizontal: 8, + }, + chapter: { fontSize: fontSize.xs, color: colors.mutedForeground, paddingHorizontal: 8, marginTop: 2 }, + editorContainer: { + height: 180, + marginTop: spacing.sm, + borderRadius: radius.lg, + borderWidth: 1, + borderColor: colors.border, + overflow: "hidden", + }, + actions: { flexDirection: "row", justifyContent: "flex-end", gap: 8, marginTop: spacing.md }, + cancelBtn: { + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: radius.md, + backgroundColor: colors.muted, + }, + cancelText: { fontSize: fontSize.sm, color: colors.foreground }, + saveBtn: { + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: radius.md, + backgroundColor: colors.primary, + }, + saveBtnDisabled: { opacity: 0.5 }, + saveText: { fontSize: fontSize.sm, color: colors.primaryForeground, fontWeight: fontWeight.medium }, + }); +} diff --git a/packages/app-expo/src/screens/reader/ReaderNoteViewModal.tsx b/packages/app-expo/src/screens/reader/ReaderNoteViewModal.tsx index 061386b4d..762b6774e 100644 --- a/packages/app-expo/src/screens/reader/ReaderNoteViewModal.tsx +++ b/packages/app-expo/src/screens/reader/ReaderNoteViewModal.tsx @@ -7,7 +7,7 @@ import { useResponsiveLayout } from "@/hooks/use-responsive-layout"; import { RichTextEditor } from "@/components/ui/RichTextEditor"; import { useAnnotationStore } from "@/stores"; import { useColors } from "@/styles/theme"; -import { createSelectionNoteMutation } from "@readany/core/reader"; +import { createSelectionNoteMutation, pageNoteLabel } from "@readany/core/reader"; import { KeyboardAvoidingView, Modal, Platform, ScrollView, StyleSheet, Text, TouchableOpacity, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useTranslation } from "react-i18next"; @@ -83,9 +83,16 @@ export function ReaderNoteViewModal({ {highlight && ( <> - - "{highlight.text}" - + {highlight.text ? ( + + "{highlight.text}" + + ) : ( + /* 页级笔记(固定版式无选中文本):用「第N页笔记」这类位置标签替代引用 */ + + {pageNoteLabel(highlight.cfi, t)} + + )} {editing ? ( <> diff --git a/packages/app-expo/src/screens/reader/styles/reader-sheet-styles.ts b/packages/app-expo/src/screens/reader/styles/reader-sheet-styles.ts index c00ad09bb..ed10290b8 100644 --- a/packages/app-expo/src/screens/reader/styles/reader-sheet-styles.ts +++ b/packages/app-expo/src/screens/reader/styles/reader-sheet-styles.ts @@ -118,9 +118,17 @@ export const makeSheetStyles = (colors: ThemeColors) => paddingVertical: 8, borderBottomWidth: 0.5, borderBottomColor: colors.border, }, + /** 笔记本弹窗头部右侧的操作区(页级笔记「+」与关闭) */ + sheetHeaderActions: { flexDirection: "row" as const, alignItems: "center" as const, gap: 14 }, + sheetHeaderActionDisabled: { opacity: 0.4 }, highlightColorDot: { width: 8, height: 8, borderRadius: 4, marginTop: 4 }, highlightContent: { flex: 1 }, highlightText: { fontSize: fontSize.sm, color: colors.foreground, lineHeight: 18 }, + /** 页级笔记没有引用文本,用页码标签替代(与桌面 NotebookPanel 一致) */ + highlightLabel: { + fontSize: fontSize.sm, color: colors.mutedForeground, + fontStyle: "italic" as const, lineHeight: 18, + }, highlightNote: { fontSize: fontSize.xs, color: colors.mutedForeground, marginTop: 4 }, bookmarkItem: { flexDirection: "row" as const, diff --git a/packages/app/src/lib/reader/page-note.ts b/packages/app/src/lib/reader/page-note.ts index 6cdd5aa7c..1d39944ad 100644 --- a/packages/app/src/lib/reader/page-note.ts +++ b/packages/app/src/lib/reader/page-note.ts @@ -1,29 +1,5 @@ /** - * 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). + * 页码标签逻辑已移到 @readany/core/reader(桌面端与移动端共用), + * 这里保留转发壳,与 lib/reader 下其它模块(font-themes / pagination / keyboard …)保持一致。 */ - -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("notebook.pageNoteWithPage", { page }) - : t("notebook.pageNoteBadge"); -} +export { pageNoteLabel, parseFakeCfiPage } from "@readany/core/reader"; diff --git a/packages/core/src/reader/index.ts b/packages/core/src/reader/index.ts index 1b3d2cc48..d22240a8d 100644 --- a/packages/core/src/reader/index.ts +++ b/packages/core/src/reader/index.ts @@ -33,3 +33,6 @@ export { compareCfiPosition, sortAnnotationsByPosition, } from "./annotation-order"; + +// Page-level note labels (fixed-layout books) — shared by desktop & mobile +export { pageNoteLabel, parseFakeCfiPage } from "./page-note"; diff --git a/packages/core/src/reader/page-note.ts b/packages/core/src/reader/page-note.ts new file mode 100644 index 000000000..eaf888f7f --- /dev/null +++ b/packages/core/src/reader/page-note.ts @@ -0,0 +1,31 @@ +/** + * Page-level note helpers(桌面端与移动端共用)。 + * + * 页级笔记(page-level note)锚定在「当前位置」,用于固定版式(PDF/CBZ)等无法选中文本的格式。 + * 这些格式的位置是 foliate 的「假 section」CFI —— `epubcfi(/6/N)`,N = 页码 * 2 + * (见 packages/foliate-js/pdf.js 的 fakePageCfi),所以页码可以从 CFI 反推,用作列表标题。 + * 可重排格式(EPUB)用的是真实位置 CFI,不含页码,标题回落到通用徽标 + * (章节标题在下方单独显示,提供上下文)。 + * + * 注意:这段逻辑原先只存在于桌面端(packages/app/src/lib/reader/page-note.ts), + * 导致移动端页级笔记展示不出页码标题 —— 现移到 core,两端共用。 + */ + +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("notebook.pageNoteWithPage", { page }) + : t("notebook.pageNoteBadge"); +}