Skip to content
Merged
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
2 changes: 1 addition & 1 deletion src/renderer/src/components/notebook/NotePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ function NoteEditorPanel({

{/* 编辑器内容 */}
<div className="flex-1 overflow-hidden">
<NoteEditor content={editContent} onChange={setEditContent} />
<NoteEditor noteId={note.id} content={editContent} onChange={setEditContent} />
</div>
</div>
)
Expand Down
96 changes: 90 additions & 6 deletions src/renderer/src/components/notebook/SourcePanel.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useState, useEffect, useCallback, ReactElement } from 'react'
import { useParams, useSearchParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { Plus, FileText, Globe, FileUp, Loader2, StickyNote, ArrowLeft } from 'lucide-react'
import { Plus, FileText, Globe, FileUp, Loader2, StickyNote, ArrowLeft, Quote } from 'lucide-react'
import { toast } from 'sonner'
import { useKnowledgeStore, setupKnowledgeListeners } from '../../store/knowledgeStore'
import { useItemStore } from '../../store/itemStore'
import { useUIStore } from '../../store/uiStore'
Expand All @@ -16,12 +17,15 @@ import { PanelHeader } from '../ui/panel-header'
import DocumentList from './source/DocumentList'
import SourceReader from './source/reader/SourceReader'
import type { KnowledgeDocument } from '../../../../shared/types/knowledge'
import type { ReaderAnchor } from '../../../../shared/types/source'
import type { ReaderAnchor, ReaderSelection } from '../../../../shared/types/source'
import {
selectionToSourceAnchor,
sourceAnchorFromSearchParams,
sourceAnchorsEqual
} from '../../../../shared/utils/sourceAnchor'
import { buildExcerptMarkdown } from '../../../../shared/utils/excerpt'
import { useSourceAnchorNavigation } from '../../hooks/useSourceAnchorNavigation'
import { requestAppendExcerpt } from './note/appendExcerptCommand'

// 添加来源类型
type AddSourceType = 'file' | 'url' | 'text' | 'note'
Expand Down Expand Up @@ -172,11 +176,42 @@ interface DocumentViewerPanelProps {
document: KnowledgeDocument
anchor?: ReaderAnchor | null
onBack: () => void
onSaveExcerpt: (excerptMarkdown: string, sourceTitle: string) => void
}

function DocumentViewerPanel({ document, anchor, onBack }: DocumentViewerPanelProps) {
function DocumentViewerPanel({
document,
anchor,
onBack,
onSaveExcerpt
}: DocumentViewerPanelProps) {
const { t } = useTranslation('ui')

// 阅读器里当前选中的那段文字。它与用来跳转的 `anchor` 无关,所以单独一份 state。
// 面板按 document.id 重挂载,切来源时选区自动清空。
const [selection, setSelection] = useState<ReaderSelection | null>(null)

const handleSelectionChange = useCallback((next: ReaderSelection | null): void => {
setSelection(next)
}, [])

const handleSaveExcerpt = useCallback((): void => {
if (!selection) return

const markdown = buildExcerptMarkdown({
text: selection.text,
anchor: selectionToSourceAnchor(selection),
label:
typeof selection.page === 'number'
? t('excerptSourceLabel', { title: document.title, page: selection.page })
: document.title
})
if (!markdown) return

onSaveExcerpt(markdown, document.title)
setSelection(null)
}, [selection, document.title, onSaveExcerpt, t])

// Esc 是返回按钮的键盘等价物。它不是「关闭笔记本」快捷键(那个已经刻意不再绑到裸
// Escape),并且会先把 Escape 让给已经打开的对话框。
useEffect(() => {
Expand Down Expand Up @@ -206,12 +241,31 @@ function DocumentViewerPanel({ document, anchor, onBack }: DocumentViewerPanelPr
</Button>
}
center={<span className="text-sm font-medium truncate">{document.title}</span>}
right={
selection && (
<Button
onClick={handleSaveExcerpt}
variant="ghost"
size="sm"
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
title={t('saveExcerpt')}
>
<Quote className="w-4 h-4" />
{t('saveExcerpt')}
</Button>
)
}
/>

{/* 来源阅读器(#71):PDF 渲染原页,其余格式走文本回退,两者同一契约。
`anchor`(#72)把引用定位传进来,reader 自己决定页/块/偏移的优先级。 */}
`anchor`(#72)把引用定位传进来,reader 自己决定页/块/偏移的优先级。
`onSelectionChange`(#73)把「选中的一段文字」交上来,用于摘录到笔记。 */}
<div className="flex min-h-0 flex-1 flex-col">
<SourceReader document={document} anchor={anchor} />
<SourceReader
document={document}
anchor={anchor}
onSelectionChange={handleSelectionChange}
/>
</div>
</>
)
Expand Down Expand Up @@ -240,7 +294,7 @@ export default function SourcePanel(): ReactElement {
selectFiles
} = useKnowledgeStore()

const { notes, loadNotes } = useItemStore()
const { notes, loadNotes, currentNote, createNote } = useItemStore()

const { openSettings } = useUIStore()

Expand Down Expand Up @@ -332,6 +386,35 @@ export default function SourcePanel(): ReactElement {
? focusedSource.location
: null

/**
* 「保存摘录」(#73)。
*
* 当前有打开的笔记就追加进去,没有才新建一条,并把新笔记打开(`createNote` 会把它设为
* 当前笔记并进入编辑态)。摘录是往正在工作的笔记里沉淀,不是每次产出一张独立卡片。
*
* 追加走命令而不是 `updateNote`:正文的写入者是笔记编辑器,外面直接改库会被编辑器下一次
* `onUpdate` 用旧正文覆盖(见 `appendExcerptCommand`)。
*/
const handleSaveExcerpt = useCallback(
async (excerptMarkdown: string, sourceTitle: string): Promise<void> => {
if (!notebookId) return

if (currentNote) {
requestAppendExcerpt(currentNote.id, excerptMarkdown)
toast.success(t('excerptAppended', { title: currentNote.title }))
return
}

try {
await createNote(notebookId, excerptMarkdown, t('excerptNoteTitle', { title: sourceTitle }))
} catch (error) {
console.error('[SourcePanel] Failed to save the excerpt as a note:', error)
toast.error(t('excerptSaveFailed'))
}
},
[notebookId, currentNote, createNote, t]
)

// 处理文件上传
const handleFileUpload = useCallback(async () => {
if (!notebookId) return
Expand Down Expand Up @@ -486,6 +569,7 @@ export default function SourcePanel(): ReactElement {
document={openDocument}
anchor={readerAnchor}
onBack={handleBack}
onSaveExcerpt={handleSaveExcerpt}
/>
) : (
// 文档列表页面
Expand Down
4 changes: 2 additions & 2 deletions src/renderer/src/components/notebook/chat/MessageItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { useKnowledgeStore } from '../../../store/knowledgeStore'
import { useNotebookStore } from '../../../store/notebookStore'
import { useSourceAnchorNavigation } from '../../../hooks/useSourceAnchorNavigation'
import { parseRetrievalStatus, sourcesForDisplay } from '../../../../../shared/utils/answerSources'
import { parseCitations, citationDocumentExists } from '../../../../../shared/utils/citations'
import { parseCitations, sourceDocumentExists } from '../../../../../shared/utils/citations'
import { citationToSourceAnchor } from '../../../../../shared/utils/sourceAnchor'
import { Button } from '../../ui/button'
import { ScrollArea, ScrollBar } from '../../ui/scroll-area'
Expand Down Expand Up @@ -55,7 +55,7 @@ export default function MessageItem({ message }: MessageItemProps): ReactElement
// provably absent. Before that (or after a failed load) the list is "unknown",
// not "deleted", so the chip stays clickable and the reader falls back.
const documentExists = (documentId: string): boolean =>
citationDocumentExists(documents, documentsLoaded, documentId)
sourceDocumentExists(documents, documentsLoaded, documentId)

const handleOpenCitation = (citation: (typeof citations)[number]): void => {
openSourceAnchor(citationToSourceAnchor(citation))
Expand Down
125 changes: 117 additions & 8 deletions src/renderer/src/components/notebook/note/NoteEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,59 @@
import { ReactElement, useEffect } from 'react'
import { ReactElement, useCallback, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useEditor, EditorContent } from '@tiptap/react'
import { useEditor, EditorContent, type Editor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { Markdown } from 'tiptap-markdown'
import { Placeholder } from '@tiptap/extensions'
import { ScrollArea } from '../../ui/scroll-area'
import { Toaster } from '../../ui/sonner'
import { toast } from 'sonner'
import { sourceDocumentExists } from '../../../../../shared/utils/citations'
import {
appendExcerptMarkdown,
excerptAnchorFromHref,
isExcerptSourceHref
} from '../../../../../shared/utils/excerpt'
import { useSourceAnchorNavigation } from '../../../hooks/useSourceAnchorNavigation'
import { useKnowledgeStore } from '../../../store/knowledgeStore'
import { subscribeAppendExcerpt } from './appendExcerptCommand'
import './noteEditor.css'

interface NoteEditorProps {
/** 正在编辑的笔记 id。摘录追加命令按它匹配目标,不再靠「当前挂载的是谁」。 */
noteId: string
content: string
onChange: (content: string) => void
}

export default function NoteEditor({ content, onChange }: NoteEditorProps): ReactElement {
/** 正文的当前 markdown。`onUpdate`、外部同步和摘录追加都走这一个序列化入口。 */
const markdownOf = (editor: Editor): string =>
// SAFETY: `tiptap-markdown` 把 serializer 挂在 `editor.storage.markdown` 上,但它的
// 类型增强没有覆盖到 @tiptap/core v3 的 storage 映射,所以 TS 看不到 `getMarkdown`。
// 该扩展是下面 `useEditor` 的固定成员,挂载后这个字段必然存在(原本这里写的是 `as any`)。
(editor.storage as unknown as { markdown: { getMarkdown: () => string } }).markdown.getMarkdown()

export default function NoteEditor({ noteId, content, onChange }: NoteEditorProps): ReactElement {
const { t } = useTranslation('notebook')

const documents = useKnowledgeStore((state) => state.documents)
const documentsLoaded = useKnowledgeStore((state) => state.documentsLoaded)
const { openSourceAnchor } = useSourceAnchorNavigation()

const containerRef = useRef<HTMLDivElement>(null)

// `useEditor` 只创建一次编辑器,`editorProps` 里的闭包不会随重渲染更新,所以点击派发
// 需要的东西放在 ref 里读最新值,而不是捕获进创建时的闭包。
const clickContext = useRef({ documents, documentsLoaded, openSourceAnchor })
useEffect(() => {
clickContext.current = { documents, documentsLoaded, openSourceAnchor }
}, [documents, documentsLoaded, openSourceAnchor])

const editor = useEditor({
extensions: [
StarterKit,
// 摘录锚点是普通 markdown 链接(#73),交给下面自己的派发处理。Tiptap 的 Link 默认
// `openOnClick: true`,会在可编辑状态下直接 `window.open(href)` —— 对笔记里的锚点
// 是错的,对普通外链也绕过了应用的 openExternalUrl。
StarterKit.configure({ link: { openOnClick: false } }),
Markdown,
Placeholder.configure({
placeholder: t('startEditing', '输入笔记内容...')
Expand All @@ -31,8 +66,7 @@ export default function NoteEditor({ content, onChange }: NoteEditorProps): Reac
}
},
onUpdate: ({ editor }) => {
const markdown = (editor.storage as any).markdown.getMarkdown()
onChange(markdown)
onChange(markdownOf(editor))
}
})

Expand All @@ -42,13 +76,88 @@ export default function NoteEditor({ content, onChange }: NoteEditorProps): Reac

// 当外部 content 变化时同步到编辑器
useEffect(() => {
if (editor && content !== (editor.storage as any).markdown.getMarkdown()) {
if (editor && content !== markdownOf(editor)) {
editor.commands.setContent(content)
}
}, [content, editor])

// 快捷键保存由 NoteEditorPanel 统一处理,因为它同时管理标题输入框。

// 摘录追加(#73):命令按 `noteId` 匹配,只被这篇笔记的编辑器消费;基线是它自己手上的
// 正文,因此不存在「外部写入被编辑器用旧正文覆盖」的双写问题。
// `setContent` 默认 emitUpdate,改动会沿既有 onUpdate → onChange → 保存路径流出去。
useEffect(() => {
if (!editor) return

return subscribeAppendExcerpt(noteId, (excerptMarkdown) => {
const current = markdownOf(editor)
const next = appendExcerptMarkdown(current, excerptMarkdown)
if (next === current) return
editor.commands.setContent(next)
})
}, [editor, noteId])

// 来源被删除时,正文里的锚点**原样保留**,只是不再可跳转 —— 「现在拿不到这份证据」和
// 「这段笔记当初来自这里」是两件事。标记只写在 DOM 上,markdown 一个字节都不改。
useEffect(() => {
const root = containerRef.current
if (!root) return

for (const element of root.querySelectorAll('a[href]')) {
const anchor = excerptAnchorFromHref(element.getAttribute('href'))
const missing =
anchor !== null && !sourceDocumentExists(documents, documentsLoaded, anchor.documentId)

element.toggleAttribute('data-source-missing', missing)
if (missing) element.setAttribute('aria-disabled', 'true')
else element.removeAttribute('aria-disabled')
}
}, [documents, documentsLoaded, content, editor])

/**
* 笔记正文里的链接点击派发。
*
* 用 React 的 capture 而不是 `editorProps.handleClick`:这里的闭包每次渲染都是新的,
* 不会读到过期的 documents;也不依赖 ProseMirror 插件与 view props 的处理顺序。
*
* #know-note-source?... → 应用内回到原文(复用 #72 的 openSourceAnchor)
* 其它 → 交给系统浏览器
*/
const handleClickCapture = useCallback(
(event: React.MouseEvent<HTMLDivElement>): void => {
const target = event.target
if (!(target instanceof Element)) return

const link = target.closest('a')
if (!link) return

const href = link.getAttribute('href')

if (isExcerptSourceHref(href)) {
event.preventDefault()
const anchor = excerptAnchorFromHref(href)
if (!anchor) return

const context = clickContext.current
// 来源已删除:不导航,也不把锚点从正文里删掉。但要说明为什么没反应,
// 否则「已禁用」和「坏了」在用户看来是一样的。
if (!sourceDocumentExists(context.documents, context.documentsLoaded, anchor.documentId)) {
toast.error(t('excerptSourceMissing'))
return
}
context.openSourceAnchor(anchor)
return
}

// 交给系统浏览器。空 href 和纯 fragment 不是外部地址;协议白名单不在这里重造 ——
// Link 的 renderHTML 已经把不被允许的协议写成 href=""。
if (!href || href.startsWith('#')) return
event.preventDefault()
void window.api.openExternalUrl(href)
},
[t]
)

if (!editor) {
return (
<div className="h-full flex items-center justify-center text-muted-foreground">
Expand All @@ -62,7 +171,7 @@ export default function NoteEditor({ content, onChange }: NoteEditorProps): Reac
<Toaster />
{/* 编辑器内容 */}
<ScrollArea className="flex-1">
<div className="p-4">
<div ref={containerRef} className="p-4" onClickCapture={handleClickCapture}>
<EditorContent editor={editor} />
</div>
</ScrollArea>
Expand Down
Loading
Loading