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/components/notes/NotesPage.tsx b/packages/app/src/components/notes/NotesPage.tsx index 4fbd54b49..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"; @@ -654,12 +655,21 @@ function NoteDetailCard({
{/* Quoted highlight text */} -

- "{highlight.text}" -

+ {highlight.text ? ( +

+ "{highlight.text}" +

+ ) : ( +

+ {pageNoteLabel(highlight.cfi, t)} +

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

- "{highlight.text}" -

+ {highlight.text ? ( +

+ "{highlight.text}" +

+ ) : ( +

+ {pageNoteLabel(highlight.cfi, t)} +

+ )}
diff --git a/packages/app/src/components/reader/NotebookPanel.tsx b/packages/app/src/components/reader/NotebookPanel.tsx index a57cc99ce..43d8c25e5 100644 --- a/packages/app/src/components/reader/NotebookPanel.tsx +++ b/packages/app/src/components/reader/NotebookPanel.tsx @@ -13,7 +13,9 @@ import { ChevronDown, ChevronRight, Edit3, + FileText, Highlighter, + Plus, NotebookPen, Save, Trash2, @@ -25,11 +27,13 @@ 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 { bookId: string; onClose: () => void; + onAddPageNote?: () => void; onGoToCfi?: (cfi: string) => void; onAddAnnotation?: (cfi: string, color: string, note?: string) => void; onDeleteAnnotation?: (cfi: string) => void; @@ -38,6 +42,7 @@ interface NotebookPanelProps { export function NotebookPanel({ bookId, onClose, + onAddPageNote, onGoToCfi, onAddAnnotation, onDeleteAnnotation, @@ -202,6 +207,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}"

+ ) : ( +

+ {pageNoteLabel(highlight.cfi, t)} +

+ )} {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}"

+ ) : ( +

+ {pageNoteLabel(highlight.cfi, t)} +

+ )} {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({ 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"); +} diff --git a/packages/core/src/stores/notebook-store.ts b/packages/core/src/stores/notebook-store.ts index 9c7c6cd91..0d15deb9c 100644 --- a/packages/core/src/stores/notebook-store.ts +++ b/packages/core/src/stores/notebook-store.ts @@ -5,8 +5,10 @@ import { create } from "zustand"; import type { Highlight } from "../types"; export interface PendingNote { - /** Selected text to annotate */ + /** Selected text to annotate (empty string = page-level note) */ text: string; + /** Page number for page-level notes (fixed-layout books) */ + page?: number; /** CFI location */ cfi: string; /** Chapter title for context */