Skip to content

Commit 2ea5547

Browse files
authored
Merge pull request #31 from typelets/feature/note-linking-backlinks
feat(editor): add note linking and backlinks
2 parents 8bb48b9 + 96deb3a commit 2ea5547

13 files changed

Lines changed: 896 additions & 11 deletions

File tree

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { useState } from 'react';
2+
import {
3+
Link2,
4+
ChevronDown,
5+
ChevronRight,
6+
FileText,
7+
FolderOpen,
8+
ArrowUpRight,
9+
ArrowDownLeft,
10+
} from 'lucide-react';
11+
import { Button } from '@/components/ui/button';
12+
import type { Backlink } from '../hooks/useBacklinks';
13+
14+
interface BacklinksPanelProps {
15+
backlinks: Backlink[];
16+
outgoingLinks: Backlink[];
17+
onNavigateToNote: (noteId: string) => void;
18+
}
19+
20+
export function BacklinksPanel({
21+
backlinks,
22+
outgoingLinks,
23+
onNavigateToNote,
24+
}: BacklinksPanelProps) {
25+
const [isBacklinksExpanded, setIsBacklinksExpanded] = useState(true);
26+
const [isOutgoingExpanded, setIsOutgoingExpanded] = useState(true);
27+
28+
const totalLinks = backlinks.length + outgoingLinks.length;
29+
30+
if (totalLinks === 0) {
31+
return null;
32+
}
33+
34+
return (
35+
<div className="border-border bg-muted/30 border-t">
36+
{/* Backlinks Section */}
37+
{backlinks.length > 0 && (
38+
<div className="border-border border-b">
39+
<button
40+
onClick={() => setIsBacklinksExpanded(!isBacklinksExpanded)}
41+
className="hover:bg-muted/50 flex w-full items-center gap-2 px-4 py-2 text-left transition-colors"
42+
>
43+
{isBacklinksExpanded ? (
44+
<ChevronDown className="h-4 w-4" />
45+
) : (
46+
<ChevronRight className="h-4 w-4" />
47+
)}
48+
<ArrowDownLeft className="text-muted-foreground h-4 w-4" />
49+
<span className="text-sm font-medium">
50+
{backlinks.length} note{backlinks.length !== 1 ? 's' : ''} link
51+
here
52+
</span>
53+
</button>
54+
55+
{isBacklinksExpanded && (
56+
<div className="px-2 pb-2">
57+
{backlinks.map((backlink) => (
58+
<Button
59+
key={backlink.noteId}
60+
variant="ghost"
61+
size="sm"
62+
className="h-auto w-full justify-start gap-2 px-2 py-1.5 text-left"
63+
onClick={() => onNavigateToNote(backlink.noteId)}
64+
>
65+
<FileText className="text-muted-foreground h-4 w-4 shrink-0" />
66+
<div className="min-w-0 flex-1">
67+
<div className="truncate text-sm">{backlink.noteTitle}</div>
68+
{backlink.folderName && (
69+
<div className="text-muted-foreground flex items-center gap-1 text-xs">
70+
<FolderOpen className="h-3 w-3" />
71+
<span className="truncate">{backlink.folderName}</span>
72+
</div>
73+
)}
74+
</div>
75+
</Button>
76+
))}
77+
</div>
78+
)}
79+
</div>
80+
)}
81+
82+
{/* Outgoing Links Section */}
83+
{outgoingLinks.length > 0 && (
84+
<div>
85+
<button
86+
onClick={() => setIsOutgoingExpanded(!isOutgoingExpanded)}
87+
className="hover:bg-muted/50 flex w-full items-center gap-2 px-4 py-2 text-left transition-colors"
88+
>
89+
{isOutgoingExpanded ? (
90+
<ChevronDown className="h-4 w-4" />
91+
) : (
92+
<ChevronRight className="h-4 w-4" />
93+
)}
94+
<ArrowUpRight className="text-muted-foreground h-4 w-4" />
95+
<span className="text-sm font-medium">
96+
{outgoingLinks.length} outgoing link
97+
{outgoingLinks.length !== 1 ? 's' : ''}
98+
</span>
99+
</button>
100+
101+
{isOutgoingExpanded && (
102+
<div className="px-2 pb-2">
103+
{outgoingLinks.map((link) => (
104+
<Button
105+
key={link.noteId}
106+
variant="ghost"
107+
size="sm"
108+
className="h-auto w-full justify-start gap-2 px-2 py-1.5 text-left"
109+
onClick={() => onNavigateToNote(link.noteId)}
110+
>
111+
<FileText className="text-muted-foreground h-4 w-4 shrink-0" />
112+
<div className="min-w-0 flex-1">
113+
<div className="truncate text-sm">{link.noteTitle}</div>
114+
{link.folderName && (
115+
<div className="text-muted-foreground flex items-center gap-1 text-xs">
116+
<FolderOpen className="h-3 w-3" />
117+
<span className="truncate">{link.folderName}</span>
118+
</div>
119+
)}
120+
</div>
121+
</Button>
122+
))}
123+
</div>
124+
)}
125+
</div>
126+
)}
127+
</div>
128+
);
129+
}
130+
131+
/**
132+
* Compact version for showing in status bar or header
133+
*/
134+
interface BacklinksIndicatorProps {
135+
backlinksCount: number;
136+
outgoingCount: number;
137+
onClick?: () => void;
138+
}
139+
140+
export function BacklinksIndicator({
141+
backlinksCount,
142+
outgoingCount,
143+
onClick,
144+
}: BacklinksIndicatorProps) {
145+
const totalLinks = backlinksCount + outgoingCount;
146+
147+
if (totalLinks === 0) {
148+
return null;
149+
}
150+
151+
return (
152+
<Button
153+
variant="ghost"
154+
size="sm"
155+
className="text-muted-foreground hover:text-foreground h-7 gap-1.5 px-2 text-xs"
156+
onClick={onClick}
157+
>
158+
<Link2 className="h-3.5 w-3.5" />
159+
<span>
160+
{backlinksCount > 0 && (
161+
<>
162+
<ArrowDownLeft className="inline h-3 w-3" />
163+
{backlinksCount}
164+
</>
165+
)}
166+
{backlinksCount > 0 && outgoingCount > 0 && ' · '}
167+
{outgoingCount > 0 && (
168+
<>
169+
<ArrowUpRight className="inline h-3 w-3" />
170+
{outgoingCount}
171+
</>
172+
)}
173+
</span>
174+
</Button>
175+
);
176+
}

src/components/editor/Editor/Toolbar.tsx

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
CheckSquare,
1919
ChevronDown,
2020
Link,
21+
Link2,
2122
Minus,
2223
Highlighter,
2324
FileText,
@@ -171,14 +172,29 @@ function ToolbarComponent({ editor }: ToolbarProps) {
171172
>
172173
<Code className="h-4 w-4" />
173174
</Button>
174-
<Button
175-
variant={editor.isActive('link') ? 'default' : 'ghost'}
176-
size="sm"
177-
onClick={setLink}
178-
title="Add Link (Ctrl+K)"
179-
>
180-
<Link className="h-4 w-4" />
181-
</Button>
175+
<DropdownMenu>
176+
<DropdownMenuTrigger asChild>
177+
<Button
178+
variant={editor.isActive('link') || editor.isActive('noteLink') ? 'default' : 'ghost'}
179+
size="sm"
180+
title="Links"
181+
className="gap-1"
182+
>
183+
<Link className="h-4 w-4" />
184+
<ChevronDown className="h-3 w-3" />
185+
</Button>
186+
</DropdownMenuTrigger>
187+
<DropdownMenuContent align="start">
188+
<DropdownMenuItem onClick={setLink}>
189+
<Link className="mr-2 h-4 w-4" />
190+
External Link
191+
</DropdownMenuItem>
192+
<DropdownMenuItem onClick={() => editor.chain().focus().insertContent('[[').run()}>
193+
<Link2 className="mr-2 h-4 w-4" />
194+
Link to Note
195+
</DropdownMenuItem>
196+
</DropdownMenuContent>
197+
</DropdownMenu>
182198
<Button
183199
variant={editor.isActive('highlight') ? 'default' : 'ghost'}
184200
size="sm"

src/components/editor/config/editor-config.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { TextStyle } from '@tiptap/extension-text-style';
1111
import { TableOfContents } from '../extensions/TableOfContents';
1212
import { ResizableImage } from '../extensions/ResizableImage';
1313
import { ExecutableCodeBlock } from '../extensions/ExecutableCodeBlock';
14+
import { NoteLink } from '../extensions/NoteLink';
1415
import { StarterKit } from '@tiptap/starter-kit';
1516
import bash from 'highlight.js/lib/languages/bash';
1617
import cpp from 'highlight.js/lib/languages/cpp';
@@ -44,7 +45,11 @@ lowlight.register('php', php);
4445
lowlight.register('sql', sql);
4546
lowlight.register('markdown', markdown);
4647

47-
export function createEditorExtensions() {
48+
export interface EditorExtensionsOptions {
49+
onNoteLinkClick?: (noteId: string) => void;
50+
}
51+
52+
export function createEditorExtensions(options: EditorExtensionsOptions = {}) {
4853
return [
4954
StarterKit.configure({
5055
heading: {
@@ -57,6 +62,10 @@ export function createEditorExtensions() {
5762
link: false,
5863
underline: false,
5964
}),
65+
NoteLink.configure({
66+
HTMLAttributes: {},
67+
onNoteLinkClick: options.onNoteLinkClick,
68+
}),
6069
ExecutableCodeBlock.configure({
6170
defaultLanguage: 'javascript',
6271
HTMLAttributes: {

0 commit comments

Comments
 (0)