Skip to content

Commit db7c1f8

Browse files
committed
feat(notes): 阶段二双向链接([[ wiki-link 自动补全 + 反向链接面板 + noteLinks 索引 + React Flow 笔记图谱)
1 parent f27d21d commit db7c1f8

19 files changed

Lines changed: 776 additions & 2 deletions

‎client/package-lock.json‎

Lines changed: 32 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎client/package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"@tiptap/extension-color": "^3.27.3",
4545
"@tiptap/extension-highlight": "^3.27.3",
4646
"@tiptap/extension-image": "^3.27.3",
47+
"@tiptap/extension-mention": "3.27.3",
4748
"@tiptap/extension-placeholder": "^3.27.3",
4849
"@tiptap/extension-table": "^3.27.3",
4950
"@tiptap/extension-table-cell": "^3.27.3",
@@ -57,6 +58,7 @@
5758
"@tiptap/pm": "^3.27.3",
5859
"@tiptap/react": "^3.27.3",
5960
"@tiptap/starter-kit": "^3.27.3",
61+
"@tiptap/suggestion": "3.27.3",
6062
"@xyflow/react": "^12.11.2",
6163
"better-sqlite3": "^11.0.0",
6264
"class-variance-authority": "^0.7.1",
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/**
2+
* 反向链接面板(笔记底部)
3+
* Backlinks panel (bottom of note editor)
4+
*
5+
* @ai-context: 阶段二双向链接。查询 noteLinks 中 toId=当前笔记 的链接,
6+
* 解析来源笔记标题(取自 useNoteStore.notes),去重后列出可点击跳转的 chip。
7+
* 无反链时返回 null(不占位)。依赖 notes 变化自动刷新(新建链接后可见)。
8+
* @ai-context: Queries noteLinks targeting the current note, resolves source
9+
* titles from the notes store, renders clickable chips; null when no backlinks.
10+
*/
11+
import { useEffect, useState } from 'react';
12+
import { useNavigate } from 'react-router-dom';
13+
import { Link2 } from 'lucide-react';
14+
import { getBacklinks } from '../lib/links/noteLinkStore';
15+
import { useNoteStore } from '../store/useNoteStore';
16+
17+
interface BacklinkEntry {
18+
id: string;
19+
title: string;
20+
}
21+
22+
export function BacklinksPanel({ noteId }: { noteId: string }) {
23+
const navigate = useNavigate();
24+
const notes = useNoteStore((s) => s.notes);
25+
const [backlinks, setBacklinks] = useState<BacklinkEntry[]>([]);
26+
27+
useEffect(() => {
28+
let cancelled = false;
29+
getBacklinks(noteId)
30+
.then((links) => {
31+
if (cancelled) return;
32+
const seen = new Set<string>();
33+
const entries: BacklinkEntry[] = [];
34+
for (const link of links) {
35+
if (seen.has(link.fromId)) continue;
36+
const note = notes.find((n) => n.id === link.fromId);
37+
if (!note) continue;
38+
seen.add(link.fromId);
39+
entries.push({ id: note.id, title: note.title });
40+
}
41+
setBacklinks(entries);
42+
})
43+
.catch(() => {});
44+
return () => { cancelled = true; };
45+
}, [noteId, notes]);
46+
47+
if (backlinks.length === 0) return null;
48+
49+
return (
50+
<div className="mt-6 pt-4 border-t border-border/40">
51+
<div className="flex items-center gap-1.5 text-c1 text-text-tertiary mb-2">
52+
<Link2 className="w-3.5 h-3.5" strokeWidth={1.5} />
53+
<span>{backlinks.length} 个反向链接</span>
54+
</div>
55+
<div className="flex flex-wrap gap-2">
56+
{backlinks.map((b) => (
57+
<button
58+
key={b.id}
59+
onClick={() => navigate(`/notes/${b.id}`)}
60+
className="inline-flex items-center px-2.5 py-1 rounded-kb-sm bg-brand-50 text-brand-700 text-b3 font-medium hover:bg-brand-100 transition-colors"
61+
>
62+
{b.title || '未命名笔记'}
63+
</button>
64+
))}
65+
</div>
66+
</div>
67+
);
68+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/**
2+
* WikiLink TipTap 扩展(双向链接的编辑侧)
3+
* WikiLink TipTap extension (editing side of bidirectional links)
4+
*
5+
* @ai-context: 阶段二双向链接。基于 Mention.extend(name:'wikiLink'),输入
6+
* `[[` 触发笔记标题自动补全:suggestion char='[',allow 要求 range 文本以 '[['
7+
* 开头(即输入了第二个 '['),items 的 query 去掉前导 '[' 后按标题过滤
8+
* useNoteStore.notes。选中后以 wikiLink 节点(attrs id/label)替换 `[[xxx`,
9+
* 渲染为可识别 chip。弹窗经 ReactRenderer 手动定位(项目未装 tippy.js)。
10+
* @ai-context: Mention-based wiki-link; `[[` triggers note-title autocomplete;
11+
* selected note becomes a wikiLink node (attrs id/label) rendered as a chip.
12+
*/
13+
import Mention from '@tiptap/extension-mention';
14+
import { mergeAttributes } from '@tiptap/core';
15+
import { ReactRenderer } from '@tiptap/react';
16+
import type { SuggestionProps, SuggestionKeyDownProps } from '@tiptap/suggestion';
17+
import { useNoteStore } from '../../store/useNoteStore';
18+
import { WikiLinkSuggestions, type WikiLinkSuggestionsHandle, type WikiLinkItem } from './WikiLinkSuggestions';
19+
20+
type RectGetter = (() => DOMRect | null) | null | undefined;
21+
22+
/** suggestion 弹窗渲染器(ReactRenderer + 手动 fixed 定位,无 tippy 依赖) */
23+
function suggestionRender() {
24+
let component: ReactRenderer<WikiLinkSuggestionsHandle, { items: WikiLinkItem[]; command: (item: { id: string; label: string }) => void }> | null = null;
25+
let container: HTMLDivElement | null = null;
26+
27+
const updatePosition = (clientRect: RectGetter) => {
28+
if (!container || !clientRect) return;
29+
const rect = clientRect();
30+
if (!rect) return;
31+
container.style.left = `${rect.left}px`;
32+
container.style.top = `${rect.bottom + 4}px`;
33+
};
34+
35+
const cleanup = () => {
36+
component?.destroy();
37+
container?.remove();
38+
component = null;
39+
container = null;
40+
};
41+
42+
return {
43+
onStart: (props: SuggestionProps) => {
44+
component = new ReactRenderer(WikiLinkSuggestions, { props, editor: props.editor });
45+
container = document.createElement('div');
46+
container.style.position = 'fixed';
47+
container.style.zIndex = '9999';
48+
container.appendChild(component.element);
49+
document.body.appendChild(container);
50+
updatePosition(props.clientRect);
51+
},
52+
onUpdate: (props: SuggestionProps) => {
53+
component?.updateProps(props);
54+
updatePosition(props.clientRect);
55+
},
56+
onKeyDown: (props: SuggestionKeyDownProps) => {
57+
if (props.event.key === 'Escape') { cleanup(); return true; }
58+
return component?.ref?.onKeyDown(props) ?? false;
59+
},
60+
onExit: cleanup,
61+
};
62+
}
63+
64+
export const WikiLink = Mention.extend({
65+
name: 'wikiLink',
66+
67+
renderHTML({ node, HTMLAttributes }) {
68+
return [
69+
'span',
70+
mergeAttributes(HTMLAttributes, { class: 'wiki-link', 'data-id': node.attrs.id }),
71+
`${node.attrs.label ?? ''}`,
72+
];
73+
},
74+
75+
renderText({ node }) {
76+
return `[[${node.attrs.label ?? ''}]]`;
77+
},
78+
}).configure({
79+
HTMLAttributes: { class: 'wiki-link' },
80+
suggestion: {
81+
char: '[',
82+
// 仅当输入了 `[[`(range 文本以 '[[' 开头)时才弹出 / only after `[[`
83+
allow: ({ editor, range }) => {
84+
const text = editor.state.doc.textBetween(range.from, range.to);
85+
return text.startsWith('[[');
86+
},
87+
// query 含前导 '['(第二个括号),去掉后按标题过滤 / strip leading '[' then filter by title
88+
items: ({ query }): WikiLinkItem[] => {
89+
const realQuery = query.startsWith('[') ? query.slice(1) : query;
90+
const q = realQuery.trim().toLowerCase();
91+
return useNoteStore.getState().notes
92+
.filter((n) => (n.title || '').toLowerCase().includes(q))
93+
.slice(0, 8)
94+
.map((n) => ({ id: n.id, label: n.title }));
95+
},
96+
command: ({ editor, range, props }) => {
97+
const attrs = props as { id?: string | null; label?: string | null };
98+
if (!attrs.id) return;
99+
editor.chain().focus()
100+
.deleteRange(range)
101+
.insertContent({ type: 'wikiLink', attrs: { id: attrs.id, label: attrs.label ?? '' } })
102+
.insertContent(' ')
103+
.run();
104+
},
105+
render: suggestionRender,
106+
},
107+
});
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* WikiLink 自动补全列表(suggestion 弹窗内容)
3+
* WikiLink autocomplete list (suggestion popup content)
4+
*
5+
* @ai-context: 阶段二双向链接。由 WikiLink 扩展的 suggestion render 经
6+
* ReactRenderer 挂载。键盘上下选择/回车插入(onKeyDown 经 ref 暴露给
7+
* suggestion 插件);无匹配项时返回 null 隐藏弹窗。
8+
* @ai-context: Mounted via ReactRenderer from the WikiLink suggestion render.
9+
* Exposes onKeyDown through ref; returns null (hidden) when no matches.
10+
*/
11+
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
12+
import { Link2 } from 'lucide-react';
13+
import { cn } from '@/lib/utils';
14+
15+
export interface WikiLinkItem {
16+
id: string;
17+
label: string;
18+
}
19+
20+
interface WikiLinkSuggestionsProps {
21+
items: WikiLinkItem[];
22+
command: (item: { id: string; label: string }) => void;
23+
}
24+
25+
export interface WikiLinkSuggestionsHandle {
26+
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
27+
}
28+
29+
export const WikiLinkSuggestions = forwardRef<WikiLinkSuggestionsHandle, WikiLinkSuggestionsProps>(
30+
function WikiLinkSuggestions({ items, command }, ref) {
31+
const [selectedIndex, setSelectedIndex] = useState(0);
32+
33+
useEffect(() => setSelectedIndex(0), [items]);
34+
35+
const selectItem = (index: number) => {
36+
const item = items[index];
37+
if (item) command({ id: item.id, label: item.label });
38+
};
39+
40+
useImperativeHandle(ref, () => ({
41+
onKeyDown: ({ event }) => {
42+
if (items.length === 0) return false;
43+
if (event.key === 'ArrowUp') {
44+
setSelectedIndex((i) => (i + items.length - 1) % items.length);
45+
return true;
46+
}
47+
if (event.key === 'ArrowDown') {
48+
setSelectedIndex((i) => (i + 1) % items.length);
49+
return true;
50+
}
51+
if (event.key === 'Enter') {
52+
selectItem(selectedIndex);
53+
return true;
54+
}
55+
return false;
56+
},
57+
}));
58+
59+
if (items.length === 0) return null;
60+
61+
return (
62+
<div className="w-60 max-h-64 overflow-y-auto rounded-kb-md border border-border/60 bg-bg-elevated shadow-kb-md py-1">
63+
<div className="px-3 py-1.5 text-c1 text-text-tertiary border-b border-border/40">链接到笔记</div>
64+
{items.map((item, i) => (
65+
<button
66+
key={item.id}
67+
onClick={() => selectItem(i)}
68+
className={cn(
69+
'w-full flex items-center gap-2 px-3 py-1.5 text-left text-b2 transition-colors',
70+
i === selectedIndex ? 'bg-brand-50 text-brand-700' : 'text-text-primary hover:bg-bg-tertiary',
71+
)}
72+
>
73+
<Link2 className="w-3.5 h-3.5 flex-shrink-0 opacity-60" strokeWidth={1.5} />
74+
<span className="truncate">{item.label || '未命名笔记'}</span>
75+
</button>
76+
))}
77+
</div>
78+
);
79+
},
80+
);

‎client/src/features/notes/hooks/useNoteEditor.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { Image } from '@tiptap/extension-image';
2222
import { TextAlign } from '@tiptap/extension-text-align';
2323
import { Color } from '@tiptap/extension-color';
2424
import { TextStyle } from '@tiptap/extension-text-style';
25+
import { WikiLink } from '../components/editor/WikiLink';
2526
import { soundPlayer } from '@/lib/audio/SoundPlayer';
2627
import { compressImageForNote } from '../lib/imageCompress';
2728
import type { SaveStatus } from '../components/NoteEditHeader';
@@ -103,6 +104,7 @@ export function useNoteEditor({ noteId, rawContent, noteKey, updateNote }: UseNo
103104
TextAlign.configure({ types: ['heading', 'paragraph'] }),
104105
Color,
105106
TextStyle,
107+
WikiLink,
106108
],
107109
content: initialContent,
108110
// TipTap v3 不再自动给编辑元素加 tiptap 类(v2 会自动加),

0 commit comments

Comments
 (0)