From 96deb3a43002f5aa0b62a6fdb12dcc21e24f5828 Mon Sep 17 00:00:00 2001 From: Rui Costa Date: Sat, 22 Nov 2025 08:52:47 -0500 Subject: [PATCH] feat(editor): add note linking and backlinks - Add [[note]] syntax to link between notes - Create NoteLink TipTap extension for rendering linked notes - Add NoteLinkSuggestion extension with search popup on [[ - Show all notes (not just filtered) in link suggestions - Add BacklinksPanel showing notes that link to current note - Add useBacklinks hook to compute incoming/outgoing links - Add "Link to Note" option in toolbar dropdown and slash commands - Click on note links to navigate to linked note --- .../editor/Editor/BacklinksPanel.tsx | 176 ++++++++++++++++++ src/components/editor/Editor/Toolbar.tsx | 32 +++- src/components/editor/config/editor-config.ts | 11 +- src/components/editor/extensions/NoteLink.ts | 154 +++++++++++++++ .../editor/extensions/NoteLinkSuggestion.ts | 55 ++++++ .../extensions/NoteLinkSuggestionList.tsx | 140 ++++++++++++++ .../editor/extensions/SlashCommands.tsx | 9 + .../editor/extensions/noteLinkUtils.ts | 31 +++ src/components/editor/hooks/useBacklinks.ts | 138 ++++++++++++++ src/components/editor/index.tsx | 155 ++++++++++++++- src/components/layout/MainLayout.tsx | 3 + src/hooks/useNotes.ts | 1 + src/types/layout.ts | 2 + 13 files changed, 896 insertions(+), 11 deletions(-) create mode 100644 src/components/editor/Editor/BacklinksPanel.tsx create mode 100644 src/components/editor/extensions/NoteLink.ts create mode 100644 src/components/editor/extensions/NoteLinkSuggestion.ts create mode 100644 src/components/editor/extensions/NoteLinkSuggestionList.tsx create mode 100644 src/components/editor/extensions/noteLinkUtils.ts create mode 100644 src/components/editor/hooks/useBacklinks.ts diff --git a/src/components/editor/Editor/BacklinksPanel.tsx b/src/components/editor/Editor/BacklinksPanel.tsx new file mode 100644 index 0000000..4a2424d --- /dev/null +++ b/src/components/editor/Editor/BacklinksPanel.tsx @@ -0,0 +1,176 @@ +import { useState } from 'react'; +import { + Link2, + ChevronDown, + ChevronRight, + FileText, + FolderOpen, + ArrowUpRight, + ArrowDownLeft, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import type { Backlink } from '../hooks/useBacklinks'; + +interface BacklinksPanelProps { + backlinks: Backlink[]; + outgoingLinks: Backlink[]; + onNavigateToNote: (noteId: string) => void; +} + +export function BacklinksPanel({ + backlinks, + outgoingLinks, + onNavigateToNote, +}: BacklinksPanelProps) { + const [isBacklinksExpanded, setIsBacklinksExpanded] = useState(true); + const [isOutgoingExpanded, setIsOutgoingExpanded] = useState(true); + + const totalLinks = backlinks.length + outgoingLinks.length; + + if (totalLinks === 0) { + return null; + } + + return ( +
+ {/* Backlinks Section */} + {backlinks.length > 0 && ( +
+ + + {isBacklinksExpanded && ( +
+ {backlinks.map((backlink) => ( + + ))} +
+ )} +
+ )} + + {/* Outgoing Links Section */} + {outgoingLinks.length > 0 && ( +
+ + + {isOutgoingExpanded && ( +
+ {outgoingLinks.map((link) => ( + + ))} +
+ )} +
+ )} +
+ ); +} + +/** + * Compact version for showing in status bar or header + */ +interface BacklinksIndicatorProps { + backlinksCount: number; + outgoingCount: number; + onClick?: () => void; +} + +export function BacklinksIndicator({ + backlinksCount, + outgoingCount, + onClick, +}: BacklinksIndicatorProps) { + const totalLinks = backlinksCount + outgoingCount; + + if (totalLinks === 0) { + return null; + } + + return ( + + ); +} diff --git a/src/components/editor/Editor/Toolbar.tsx b/src/components/editor/Editor/Toolbar.tsx index da5e320..35e6e96 100644 --- a/src/components/editor/Editor/Toolbar.tsx +++ b/src/components/editor/Editor/Toolbar.tsx @@ -18,6 +18,7 @@ import { CheckSquare, ChevronDown, Link, + Link2, Minus, Highlighter, FileText, @@ -171,14 +172,29 @@ function ToolbarComponent({ editor }: ToolbarProps) { > - + + + + + + + + External Link + + editor.chain().focus().insertContent('[[').run()}> + + Link to Note + + + + ))} + + ); +}); + +NoteLinkSuggestionList.displayName = 'NoteLinkSuggestionList'; diff --git a/src/components/editor/extensions/SlashCommands.tsx b/src/components/editor/extensions/SlashCommands.tsx index acea512..4a38687 100644 --- a/src/components/editor/extensions/SlashCommands.tsx +++ b/src/components/editor/extensions/SlashCommands.tsx @@ -21,6 +21,7 @@ import { Quote, Minus, Link, + Link2, Highlighter, FileText, Image, @@ -186,6 +187,14 @@ const commands: CommandItem[] = [ } }, }, + { + title: 'Link to Note', + icon: , + command: ({ editor, range }) => { + // Delete the slash command and insert [[ to trigger note link suggestion + editor.chain().focus().deleteRange(range).insertContent('[[').run(); + }, + }, { title: 'Table of Contents', icon: , diff --git a/src/components/editor/extensions/noteLinkUtils.ts b/src/components/editor/extensions/noteLinkUtils.ts new file mode 100644 index 0000000..7853727 --- /dev/null +++ b/src/components/editor/extensions/noteLinkUtils.ts @@ -0,0 +1,31 @@ +import type { Note } from '@/types/note'; + +export interface NoteLinkItem { + noteId: string; + noteTitle: string; + folderName?: string; +} + +/** + * Helper function to convert notes to suggestion items + */ +export function notesToSuggestionItems( + notes: Note[], + currentNoteId?: string +): NoteLinkItem[] { + return notes + .filter((note) => { + // Exclude current note, deleted, and hidden notes + if (note.id === currentNoteId) return false; + if (note.deleted) return false; + if (note.hidden) return false; + if (note.archived) return false; + return true; + }) + .map((note) => ({ + noteId: note.id, + noteTitle: note.title || 'Untitled', + folderName: note.folder?.name, + })) + .sort((a, b) => a.noteTitle.localeCompare(b.noteTitle)); +} diff --git a/src/components/editor/hooks/useBacklinks.ts b/src/components/editor/hooks/useBacklinks.ts new file mode 100644 index 0000000..25d89ed --- /dev/null +++ b/src/components/editor/hooks/useBacklinks.ts @@ -0,0 +1,138 @@ +import { useMemo } from 'react'; +import type { Note } from '@/types/note'; + +export interface Backlink { + noteId: string; + noteTitle: string; + folderName?: string; +} + +/** + * Extracts note link IDs from HTML content + * Looks for elements + */ +function extractNoteLinksFromContent(content: string): string[] { + if (!content) return []; + + const noteIds: string[] = []; + + // Match data-note-id attributes in noteLink spans + const regex = /data-type="noteLink"[^>]*data-note-id="([^"]+)"/g; + let match; + + while ((match = regex.exec(content)) !== null) { + noteIds.push(match[1]); + } + + // Also try the reverse order (data-note-id before data-type) + const reverseRegex = /data-note-id="([^"]+)"[^>]*data-type="noteLink"/g; + while ((match = reverseRegex.exec(content)) !== null) { + if (!noteIds.includes(match[1])) { + noteIds.push(match[1]); + } + } + + return noteIds; +} + +/** + * Hook to compute backlinks for a given note + * Returns all notes that link to the current note + */ +export function useBacklinks( + currentNoteId: string | undefined, + allNotes: Note[] +): Backlink[] { + return useMemo(() => { + if (!currentNoteId) return []; + + const backlinks: Backlink[] = []; + + for (const note of allNotes) { + // Skip the current note itself + if (note.id === currentNoteId) continue; + + // Skip deleted, hidden, or archived notes + if (note.deleted || note.hidden || note.archived) continue; + + // Extract note links from content + const linkedNoteIds = extractNoteLinksFromContent(note.content); + + // Check if this note links to the current note + if (linkedNoteIds.includes(currentNoteId)) { + backlinks.push({ + noteId: note.id, + noteTitle: note.title || 'Untitled', + folderName: note.folder?.name, + }); + } + } + + // Sort alphabetically by title + return backlinks.sort((a, b) => a.noteTitle.localeCompare(b.noteTitle)); + }, [currentNoteId, allNotes]); +} + +/** + * Hook to get all outgoing links from a note + * Returns all notes that the current note links to + */ +export function useOutgoingLinks( + currentNote: Note | null, + allNotes: Note[] +): Backlink[] { + return useMemo(() => { + if (!currentNote) return []; + + const linkedNoteIds = extractNoteLinksFromContent(currentNote.content); + + if (linkedNoteIds.length === 0) return []; + + const outgoingLinks: Backlink[] = []; + + for (const noteId of linkedNoteIds) { + const linkedNote = allNotes.find((n) => n.id === noteId); + + if (linkedNote && !linkedNote.deleted && !linkedNote.hidden) { + outgoingLinks.push({ + noteId: linkedNote.id, + noteTitle: linkedNote.title || 'Untitled', + folderName: linkedNote.folder?.name, + }); + } + } + + // Sort alphabetically by title + return outgoingLinks.sort((a, b) => a.noteTitle.localeCompare(b.noteTitle)); + }, [currentNote, allNotes]); +} + +/** + * Get link counts for a note (for display in notes list) + */ +export function useNoteLinkCounts( + currentNoteId: string | undefined, + allNotes: Note[] +): { backlinks: number; outgoing: number } { + return useMemo(() => { + if (!currentNoteId) return { backlinks: 0, outgoing: 0 }; + + const currentNote = allNotes.find((n) => n.id === currentNoteId); + const outgoing = currentNote + ? extractNoteLinksFromContent(currentNote.content).length + : 0; + + let backlinks = 0; + for (const note of allNotes) { + if (note.id === currentNoteId) continue; + if (note.deleted || note.hidden || note.archived) continue; + + const linkedNoteIds = extractNoteLinksFromContent(note.content); + if (linkedNoteIds.includes(currentNoteId)) { + backlinks++; + } + } + + return { backlinks, outgoing }; + }, [currentNoteId, allNotes]); +} diff --git a/src/components/editor/index.tsx b/src/components/editor/index.tsx index 1f645d0..5b29009 100644 --- a/src/components/editor/index.tsx +++ b/src/components/editor/index.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect, useRef } from 'react'; +import { useState, useCallback, useEffect, useRef, useMemo } from 'react'; import { Star, Archive, @@ -29,6 +29,12 @@ import tippy from 'tippy.js'; import { createEditorExtensions } from './config/editor-config'; import { SlashCommands } from './extensions/SlashCommandsExtension'; import { SlashCommandsList } from './extensions/SlashCommands'; +import { NoteLinkSuggestion } from './extensions/NoteLinkSuggestion'; +import { NoteLinkSuggestionList } from './extensions/NoteLinkSuggestionList'; +import { + notesToSuggestionItems, + type NoteLinkItem, +} from './extensions/noteLinkUtils'; import { editorStyles } from './config/editor-styles'; import { EmptyState } from '@/components/editor/Editor/EmptyState'; import { Toolbar } from '@/components/editor/Editor/Toolbar'; @@ -37,6 +43,8 @@ import FileUpload from '@/components/editor/FileUpload'; import { StatusBar } from '@/components/editor/Editor/StatusBar'; import { useEditorState } from '@/components/editor/hooks/useEditorState'; import { useEditorEffects } from '@/components/editor/hooks/useEditorEffects'; +import { useBacklinks, useOutgoingLinks } from '@/components/editor/hooks/useBacklinks'; +import { BacklinksPanel } from '@/components/editor/Editor/BacklinksPanel'; import { fileService } from '@/services/fileService'; import DiagramEditor from '@/components/diagrams/DiagramEditor'; import CodeEditor from '@/components/code/CodeEditor'; @@ -66,8 +74,22 @@ interface SlashCommandsHandle { onKeyDown: (props: SuggestionKeyDownProps) => boolean; } +interface NoteLinkSuggestionProps { + editor: Editor; + range: { from: number; to: number }; + query: string; + text: string; + command: (item: NoteLinkItem) => void; + clientRect?: (() => DOMRect | null) | null; +} + +interface NoteLinkSuggestionHandle { + onKeyDown: (props: SuggestionKeyDownProps) => boolean; +} + interface NoteEditorProps { note: Note | null; + notes?: Note[]; // All notes for note linking feature folders?: FolderType[]; onUpdateNote: (noteId: string, updates: Partial) => void; onDeleteNote: (noteId: string) => void; @@ -78,6 +100,7 @@ interface NoteEditorProps { hidingNote?: boolean; onUnhideNote: (noteId: string) => void; onRefreshNote?: (noteId: string) => void; + onSelectNote?: (note: Note) => void; // Navigate to a linked note userId?: string; isNotesPanelOpen?: boolean; onToggleNotesPanel?: () => void; @@ -93,6 +116,7 @@ interface NoteEditorProps { export default function Index({ note, + notes = [], folders, onUpdateNote, onDeleteNote, @@ -103,6 +127,7 @@ export default function Index({ hidingNote = false, onUnhideNote, onRefreshNote, + onSelectNote, userId = 'current-user', isNotesPanelOpen, onToggleNotesPanel, @@ -144,11 +169,32 @@ export default function Index({ resetZoom, } = useEditorState(); + // Handle note link click - navigate to the linked note + const handleNoteLinkClick = useCallback( + (noteId: string) => { + const linkedNote = notes.find((n) => n.id === noteId); + if (linkedNote && onSelectNote) { + onSelectNote(linkedNote); + } + }, + [notes, onSelectNote] + ); + + // Memoize note suggestion items + const noteLinkItems = useMemo( + () => notesToSuggestionItems(notes, note?.id), + [notes, note?.id] + ); + + // Backlinks and outgoing links + const backlinks = useBacklinks(note?.id, notes); + const outgoingLinks = useOutgoingLinks(note, notes); + const editor = useEditor( { editable: !note?.hidden, extensions: [ - ...createEditorExtensions(), + ...createEditorExtensions({ onNoteLinkClick: handleNoteLinkClick }), SlashCommands.configure({ suggestion: { items: () => { @@ -204,6 +250,104 @@ export default function Index({ return component?.onKeyDown?.(props); }, + onExit: () => { + if (popup && !popup.state.isDestroyed) { + popup.destroy(); + } + popup = null; + + if (root) { + setTimeout(() => { + root?.unmount(); + root = null; + }, 0); + } + }, + }; + }, + }, + }), + NoteLinkSuggestion.configure({ + suggestion: { + items: ({ query }: { query: string }) => { + // Filter notes based on query + if (!query) { + return noteLinkItems.slice(0, 10); + } + const lowerQuery = query.toLowerCase(); + return noteLinkItems + .filter((item) => + item.noteTitle.toLowerCase().includes(lowerQuery) + ) + .slice(0, 10); + }, + render: () => { + let component: NoteLinkSuggestionHandle | null = null; + let popup: ReturnType[0] | null = null; + let root: Root | null = null; + let currentQuery = ''; + + return { + onStart: (props: NoteLinkSuggestionProps) => { + if (!props.clientRect) { + return; + } + + currentQuery = props.query; + const container = document.createElement('div'); + + popup = tippy('body', { + getReferenceClientRect: props.clientRect as () => DOMRect, + appendTo: () => document.body, + content: container, + showOnCreate: true, + interactive: true, + trigger: 'manual', + placement: 'bottom-start', + })[0]; + + root = ReactDOM.createRoot(container); + + root.render( + { + component = ref; + }} + items={noteLinkItems} + query={currentQuery} + command={props.command} + /> + ); + }, + + onUpdate: (props: NoteLinkSuggestionProps) => { + currentQuery = props.query; + popup?.setProps({ + getReferenceClientRect: props.clientRect as () => DOMRect, + }); + + // Re-render with updated query + root?.render( + { + component = ref; + }} + items={noteLinkItems} + query={currentQuery} + command={props.command} + /> + ); + }, + + onKeyDown: (props: SuggestionKeyDownProps) => { + if (props.event.key === 'Escape') { + popup?.hide(); + return true; + } + + return component?.onKeyDown?.(props) ?? false; + }, + onExit: () => { if (popup && !popup.state.isDestroyed) { popup.destroy(); @@ -1023,6 +1167,13 @@ export default function Index({ /> + {/* Backlinks Panel */} + + ) => Promise; onDeleteNote: (noteId: string) => Promise; @@ -63,6 +64,7 @@ export interface EditorProps { hidingNote?: boolean; onUnhideNote: (noteId: string) => Promise; onRefreshNote?: (noteId: string) => Promise; + onSelectNote?: (note: Note) => void; // Navigate to a linked note userId?: string; isNotesPanelOpen?: boolean; onToggleNotesPanel?: () => void;