Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 31 additions & 12 deletions packages/app/src/components/notes/NotesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -654,12 +655,21 @@ function NoteDetailCard({
<div className="group rounded-lg border border-border/40 bg-card transition-colors hover:border-border/70">
<div className="p-3">
{/* Quoted highlight text */}
<p
className="text-xs text-muted-foreground/80 leading-relaxed cursor-pointer hover:text-primary transition-colors line-clamp-2"
onClick={onNavigate}
>
"{highlight.text}"
</p>
{highlight.text ? (
<p
className="text-xs text-muted-foreground/80 leading-relaxed cursor-pointer hover:text-primary transition-colors line-clamp-2"
onClick={onNavigate}
>
"{highlight.text}"
</p>
) : (
<p
className="text-xs text-muted-foreground italic leading-relaxed cursor-pointer hover:text-primary transition-colors line-clamp-2"
onClick={onNavigate}
>
{pageNoteLabel(highlight.cfi, t)}
</p>
)}

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

<div className="pl-4 pr-3 py-3">
<p
className="text-sm text-foreground/90 leading-relaxed cursor-pointer hover:text-primary transition-colors"
onClick={onNavigate}
>
"{highlight.text}"
</p>
{highlight.text ? (
<p
className="text-sm text-foreground/90 leading-relaxed cursor-pointer hover:text-primary transition-colors"
onClick={onNavigate}
>
"{highlight.text}"
</p>
) : (
<p
className="text-sm text-muted-foreground italic leading-relaxed cursor-pointer hover:text-primary transition-colors"
onClick={onNavigate}
>
{pageNoteLabel(highlight.cfi, t)}
</p>
)}

<div className="mt-2 flex items-center justify-between">
<span className="text-[11px] text-muted-foreground/60">
Expand Down
79 changes: 65 additions & 14 deletions packages/app/src/components/reader/NotebookPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import {
ChevronDown,
ChevronRight,
Edit3,
FileText,
Highlighter,
Plus,
NotebookPen,
Save,
Trash2,
Expand All @@ -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;
Expand All @@ -38,6 +42,7 @@ interface NotebookPanelProps {
export function NotebookPanel({
bookId,
onClose,
onAddPageNote,
onGoToCfi,
onAddAnnotation,
onDeleteAnnotation,
Expand Down Expand Up @@ -202,6 +207,15 @@ export function NotebookPanel({
<div className="flex h-10 shrink-0 items-center justify-between border-b border-border/40 px-3">
<span className="text-xs font-medium text-foreground">{t("notebook.title")}</span>
<div className="flex items-center gap-1">
<button
type="button"
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={onAddPageNote}
disabled={!onAddPageNote}
title={t("notebook.addPageNote")}
>
<Plus className="h-4 w-4" />
</button>
<ExportDropdown onExport={handleExport} disabled={bookHighlights.length === 0} />
<button
type="button"
Expand All @@ -218,19 +232,38 @@ export function NotebookPanel({
{/* Note Editor - shown when creating/editing */}
{isEditing && (
<div className="border-b border-border/40 p-3">
{/* Selected text preview */}
<div className="mb-3 rounded-md bg-muted/50 p-2">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
<Highlighter className="h-3 w-3" />
<span>{t("notebook.selectedText")}</span>
{/* Context preview: selected text, or page info for page-level notes */}
{editingText ? (
<div className="mb-3 rounded-md bg-muted/50 p-2">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
<Highlighter className="h-3 w-3" />
<span>{t("notebook.selectedText")}</span>
</div>
<p className="text-sm text-foreground line-clamp-3">"{editingText}"</p>
{(pendingNote?.chapterTitle || editingHighlight?.chapterTitle) && (
<p className="mt-1 text-xs text-muted-foreground">
{pendingNote?.chapterTitle || editingHighlight?.chapterTitle}
</p>
)}
</div>
<p className="text-sm text-foreground line-clamp-3">"{editingText}"</p>
{(pendingNote?.chapterTitle || editingHighlight?.chapterTitle) && (
<p className="mt-1 text-xs text-muted-foreground">
{pendingNote?.chapterTitle || editingHighlight?.chapterTitle}
) : (
<div className="mb-3 rounded-md bg-muted/50 p-2">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
<FileText className="h-3 w-3" />
<span>{t("notebook.pageNoteBadge")}</span>
</div>
<p className="text-sm text-foreground">
{pendingNote?.page
? t("notebook.pageNotePage", { page: pendingNote.page })
: t("notebook.pageNoteNoPage")}
</p>
)}
</div>
{(pendingNote?.chapterTitle || editingHighlight?.chapterTitle) && (
<p className="mt-1 text-xs text-muted-foreground">
{pendingNote?.chapterTitle || editingHighlight?.chapterTitle}
</p>
)}
</div>
)}

{/* Note input */}
<MarkdownEditor
Expand Down Expand Up @@ -260,7 +293,13 @@ export function NotebookPanel({
<Button variant="ghost" size="sm" onClick={handleCancel}>
{t("common.cancel")}
</Button>
<Button size="sm" onClick={handleSave}>
{/* Page-level notes must carry content — an empty note would be
an invisible row in the list. */}
<Button
size="sm"
onClick={handleSave}
disabled={Boolean(pendingNote && !pendingNote.text && !noteContent.trim())}
>
<Save className="h-3.5 w-3.5 mr-1" />
{t("common.save")}
</Button>
Expand Down Expand Up @@ -381,7 +420,13 @@ function HighlightNoteItem({
style={{ backgroundColor: HIGHLIGHT_COLOR_HEX[highlight.color] }}
/>
<div className="flex-1 min-w-0">
<p className="text-sm text-foreground line-clamp-2">"{highlight.text}"</p>
{highlight.text ? (
<p className="text-sm text-foreground line-clamp-2">"{highlight.text}"</p>
) : (
<p className="text-sm text-muted-foreground italic line-clamp-2">
{pageNoteLabel(highlight.cfi, t)}
</p>
)}
{highlight.note && (
<div className="mt-1.5 text-xs text-muted-foreground bg-muted/50 rounded px-2 py-1.5 prose prose-xs dark:prose-invert max-w-none break-words overflow-hidden [overflow-wrap:anywhere]">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{highlight.note}</ReactMarkdown>
Expand Down Expand Up @@ -440,7 +485,13 @@ function HighlightItem({ highlight, onClick, onAddNote, onDelete }: HighlightIte
style={{ backgroundColor: HIGHLIGHT_COLOR_HEX[highlight.color] }}
/>
<div className="flex-1 min-w-0">
<p className="text-sm text-foreground line-clamp-2">"{highlight.text}"</p>
{highlight.text ? (
<p className="text-sm text-foreground line-clamp-2">"{highlight.text}"</p>
) : (
<p className="text-sm text-muted-foreground italic line-clamp-2">
{pageNoteLabel(highlight.cfi, t)}
</p>
)}
{highlight.chapterTitle && (
<p className="mt-1 text-xs text-muted-foreground/70">{highlight.chapterTitle}</p>
)}
Expand Down
17 changes: 17 additions & 0 deletions packages/app/src/components/reader/ReaderView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -2870,6 +2883,7 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) {
{/* Notebook sidebar — LEFT side */}
<NotebookSidebarWrapper
bookId={bookId}
onAddPageNote={handleAddPageNote}
onGoToCfi={navigateToCfi}
onAddAnnotation={(cfi, color, note) => {
foliateRef.current?.addAnnotation({
Expand Down Expand Up @@ -3214,6 +3228,7 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) {
// Separate component to use notebook store hook
function NotebookSidebarWrapper({
bookId,
onAddPageNote,
onGoToCfi,
onAddAnnotation,
onDeleteAnnotation,
Expand All @@ -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;
Expand Down Expand Up @@ -3250,6 +3266,7 @@ function NotebookSidebarWrapper({
<NotebookPanel
bookId={bookId}
onClose={closeNotebook}
onAddPageNote={onAddPageNote}
onGoToCfi={onGoToCfi}
onAddAnnotation={onAddAnnotation}
onDeleteAnnotation={onDeleteAnnotation}
Expand Down
29 changes: 29 additions & 0 deletions packages/app/src/lib/reader/page-note.ts
Original file line number Diff line number Diff line change
@@ -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, unknown>) => 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");
}
5 changes: 5 additions & 0 deletions packages/core/src/i18n/locales/en/reader.json
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,11 @@
"emptyHint": "Select text in the book to highlight or add notes",
"editNote": "Edit note",
"addNoteBtn": "Add note",
"addPageNote": "Add page note",
"pageNoteBadge": "Page note",
"pageNotePage": "Page {{page}}",
"pageNoteNoPage": "Current position",
"pageNoteWithPage": "Note on page {{page}}",
"deleteHighlightBtn": "Delete highlight"
},
"editor": {
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/i18n/locales/es/reader.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,11 @@
"emptyHint": "Selecciona texto en el libro para subrayar o agregar notas",
"editNote": "Editar nota",
"addNoteBtn": "Agregar nota",
"addPageNote": "Añadir nota de página",
"pageNoteBadge": "Nota de página",
"pageNotePage": "Página {{page}}",
"pageNoteNoPage": "Posición actual",
"pageNoteWithPage": "Nota en la página {{page}}",
"deleteHighlightBtn": "Eliminar subrayado"
},
"editor": {
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/i18n/locales/fr/reader.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,11 @@
"emptyHint": "Sélectionnez du texte dans le livre pour surligner ou ajouter des notes",
"editNote": "Modifier la note",
"addNoteBtn": "Ajouter une note",
"addPageNote": "Ajouter une note de page",
"pageNoteBadge": "Note de page",
"pageNotePage": "Page {{page}}",
"pageNoteNoPage": "Position actuelle",
"pageNoteWithPage": "Note page {{page}}",
"deleteHighlightBtn": "Supprimer le surlignage"
},
"editor": {
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/i18n/locales/ja/reader.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,11 @@
"emptyHint": "本のテキストを選択してハイライトやノートを追加",
"editNote": "ノートを編集",
"addNoteBtn": "ノートを追加",
"addPageNote": "ページメモを追加",
"pageNoteBadge": "ページメモ",
"pageNotePage": "{{page}} ページ",
"pageNoteNoPage": "現在位置",
"pageNoteWithPage": "{{page}} ページのメモ",
"deleteHighlightBtn": "ハイライトを削除"
},
"editor": {
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/i18n/locales/ko/reader.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,11 @@
"emptyHint": "책에서 텍스트를 선택하여 하이라이트하거나 노트를 추가하세요",
"editNote": "노트 편집",
"addNoteBtn": "노트 추가",
"addPageNote": "페이지 메모 추가",
"pageNoteBadge": "페이지 메모",
"pageNotePage": "{{page}}쪽",
"pageNoteNoPage": "현재 위치",
"pageNoteWithPage": "{{page}}쪽 메모",
"deleteHighlightBtn": "하이라이트 삭제"
},
"editor": {
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/i18n/locales/zh-TW/reader.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,11 @@
"emptyHint": "選取書中文字進行醒目標示或新增筆記",
"editNote": "編輯筆記",
"addNoteBtn": "新增筆記",
"addPageNote": "新增頁級筆記",
"pageNoteBadge": "頁級筆記",
"pageNotePage": "第 {{page}} 頁",
"pageNoteNoPage": "當前位置",
"pageNoteWithPage": "第 {{page}} 頁筆記",
"deleteHighlightBtn": "刪除醒目標示"
},
"editor": {
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/i18n/locales/zh/reader.json
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,11 @@
"emptyHint": "选择书中文本进行高亮或添加笔记",
"editNote": "编辑笔记",
"addNoteBtn": "添加笔记",
"addPageNote": "添加页级笔记",
"pageNoteBadge": "页级笔记",
"pageNotePage": "第 {{page}} 页",
"pageNoteNoPage": "当前位置",
"pageNoteWithPage": "第 {{page}} 页笔记",
"deleteHighlightBtn": "删除高亮"
},
"editor": {
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/stores/notebook-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down