diff --git a/apps/desktop/electron-vite.config.ts b/apps/desktop/electron-vite.config.ts index fc03c0c..94b5467 100644 --- a/apps/desktop/electron-vite.config.ts +++ b/apps/desktop/electron-vite.config.ts @@ -33,6 +33,20 @@ export default defineConfig({ }, renderer: { plugins: [react()], + // Pre-bundle all CodeMirror packages together to avoid multiple instances of @codemirror/state + // See: https://codemirror.net/docs/guide/#bundling + optimizeDeps: { + include: [ + '@codemirror/state', + '@codemirror/view', + '@codemirror/autocomplete', + '@codemirror/commands', + '@codemirror/language', + '@codemirror/lang-markdown', + '@codemirror/language-data', + '@lezer/highlight', + ], + }, build: { outDir: 'out/renderer', rollupOptions: { diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f5830a4..219686f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -28,6 +28,7 @@ "test:watch": "vitest" }, "dependencies": { + "@codemirror/autocomplete": "^6.20.0", "@codemirror/commands": "^6.10.1", "@codemirror/lang-markdown": "^6.5.0", "@codemirror/language": "^6.12.1", @@ -35,7 +36,11 @@ "@codemirror/state": "^6.5.3", "@codemirror/view": "^6.39.8", "@lezer/highlight": "^1.2.3", + "@readied/commands": "workspace:*", "@readied/core": "workspace:*", + "@readied/embeds": "workspace:*", + "@readied/tasks": "workspace:*", + "@readied/wikilinks": "workspace:*", "@readied/licensing": "workspace:*", "@readied/product-config": "workspace:*", "@readied/storage-core": "workspace:*", diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 4292bc1..b225b5f 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -414,6 +414,26 @@ function registerIpcHandlers(): void { return { ok: true }; }); + // ═══════════════════════════════════════════════════════════════════════════ + // Links (Wikilinks / Backlinks) + // ═══════════════════════════════════════════════════════════════════════════ + + // Sync links for a note (call after saving note) + ipcMain.handle('links:sync', async (_event, noteId: string, content: string) => { + repo.syncLinks(createNoteId(noteId), content); + return { ok: true }; + }); + + // Get backlinks (notes that link TO this note) + ipcMain.handle('links:backlinks', async (_event, noteId: string) => { + return repo.getBacklinks(createNoteId(noteId)); + }); + + // Get outgoing links (notes this note links TO) + ipcMain.handle('links:outgoing', async (_event, noteId: string) => { + return repo.getOutgoingLinks(createNoteId(noteId)); + }); + // Count notes ipcMain.handle('notes:count', async () => { // Get all notes to compute counts diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 8e453e7..040de18 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -140,6 +140,20 @@ export interface TagWithColor { color: string | null; } +/** Backlink information (notes that link TO a note) */ +export interface BacklinkInfo { + noteId: string; + noteTitle: string; + targetRef: string; +} + +/** Outgoing link information (notes that a note links TO) */ +export interface OutgoingLinkInfo { + targetRef: string; + targetNoteId: string | null; + targetTitle: string | null; +} + /** Log level types */ export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; @@ -260,6 +274,14 @@ export interface ReadiedAPI { /** Get log directory path */ getLogPath: () => Promise; }; + links: { + /** Sync links for a note (extracts wikilinks and updates link table) */ + sync: (noteId: string, content: string) => Promise<{ ok: boolean }>; + /** Get backlinks (notes that link TO this note) */ + getBacklinks: (noteId: string) => Promise; + /** Get outgoing links (notes this note links TO) */ + getOutgoing: (noteId: string) => Promise; + }; } // Expose the API @@ -334,6 +356,11 @@ const api: ReadiedAPI = { }, getLogPath: () => ipcRenderer.invoke('log:getPath'), }, + links: { + sync: (noteId, content) => ipcRenderer.invoke('links:sync', noteId, content), + getBacklinks: noteId => ipcRenderer.invoke('links:backlinks', noteId), + getOutgoing: noteId => ipcRenderer.invoke('links:outgoing', noteId), + }, }; contextBridge.exposeInMainWorld('readied', api); diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index fea29b6..fe524c5 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -18,6 +18,7 @@ import { useStatusFilter, } from './hooks/useNavigation'; import { useSearchNotes, useNoteMutations } from './hooks/useNotes'; +import { useSyncLinks } from './hooks/useLinks'; import { useEditorPreferencesStore } from './stores/editorPreferencesStore'; import { useTagColorsStore } from './stores/tagColorsStore'; import { usePerformanceMode } from './hooks/usePerformanceMode'; @@ -89,6 +90,9 @@ function NotesApp() { unpinNote, } = useNoteMutations(); + // Links sync mutation + const syncLinks = useSyncLinks(); + // Determine which notes to display // Both filteredNotes and searchNotesQuery.data have excerpt const displayedNotes = debouncedSearch.trim() ? (searchNotesQuery.data ?? []) : filteredNotes; @@ -152,8 +156,10 @@ function NotesApp() { if (!selectedNote) return; const updated = await updateNote.mutateAsync({ id: selectedNote.id, content }); setSelectedNote(updated); + // Sync links after save (fire-and-forget, don't block UI) + syncLinks.mutate({ noteId: selectedNote.id, content }); }, - [selectedNote, updateNote] + [selectedNote, updateNote, syncLinks] ); // Update note title @@ -347,6 +353,7 @@ function NotesApp() { onDuplicate={selectedNote ? () => handleDuplicateNote(selectedNote.id) : undefined} onDelete={selectedNote ? () => handleDeleteNote(selectedNote.id) : undefined} onWikilinkClick={handleWikilinkClick} + onNavigateToNote={handleSelectNote} /> diff --git a/apps/desktop/src/renderer/components/MarkdownEditor.tsx b/apps/desktop/src/renderer/components/MarkdownEditor.tsx index eb2dca6..3119a11 100644 --- a/apps/desktop/src/renderer/components/MarkdownEditor.tsx +++ b/apps/desktop/src/renderer/components/MarkdownEditor.tsx @@ -2,7 +2,7 @@ * CodeMirror 6 Markdown Editor */ -import { useEffect, useRef, useCallback, forwardRef, useImperativeHandle } from 'react'; +import { useEffect, useRef, useCallback, forwardRef, useImperativeHandle, useMemo } from 'react'; import { EditorState, type Extension } from '@codemirror/state'; import { EditorView, @@ -37,8 +37,13 @@ import { insertHorizontalRule, undoChange, redoChange, -} from './editor/toolbar-commands'; -import { wikilinkExtension } from './editor/wikilink-extension'; +} from '@readied/commands'; +import { + wikilinkExtension, + createWikilinkAutocomplete, + setCurrentNoteId, + currentNoteIdField, +} from '@readied/wikilinks'; /** Dark theme matching Readied's design */ const darkTheme = EditorView.theme( @@ -87,6 +92,32 @@ const darkTheme = EditorView.theme( backgroundColor: 'rgba(94, 234, 212, 0.3)', outline: 'none', }, + // Autocomplete tooltip + '.cm-tooltip-autocomplete': { + backgroundColor: 'rgba(24, 24, 27, 0.98)', + backdropFilter: 'blur(12px)', + border: '1px solid rgba(255, 255, 255, 0.1)', + borderRadius: '8px', + boxShadow: '0 8px 32px rgba(0, 0, 0, 0.5)', + overflow: 'hidden', + }, + '.cm-tooltip-autocomplete > ul': { + fontFamily: "'Inter', -apple-system, sans-serif", + fontSize: '13px', + maxHeight: '300px', + }, + '.cm-tooltip-autocomplete > ul > li': { + padding: '8px 12px', + color: '#a1a1aa', + cursor: 'pointer', + }, + '.cm-tooltip-autocomplete > ul > li[aria-selected]': { + backgroundColor: 'rgba(94, 234, 212, 0.15)', + color: '#5eead4', + }, + '.cm-completionLabel': { + fontWeight: '500', + }, }, { dark: true } ); @@ -144,6 +175,8 @@ interface MarkdownEditorProps { onChange: (content: string) => void; placeholder?: string; onReady?: () => void; + /** Current note ID (for excluding from wikilink autocomplete) */ + noteId?: string; } /** Imperative handle exposed via ref */ @@ -173,13 +206,33 @@ export interface MarkdownEditorHandle { export const MarkdownEditor = forwardRef( function MarkdownEditor( - { initialContent, onChange, placeholder = 'Start writing...', onReady }, + { initialContent, onChange, placeholder = 'Start writing...', onReady, noteId }, ref ) { const containerRef = useRef(null); const viewRef = useRef(null); const onChangeRef = useRef(onChange); + // Create wikilink autocomplete extension with injected dependencies + const wikilinkAutocomplete = useMemo( + () => + createWikilinkAutocomplete({ + searchNotes: async query => { + const notes = await window.readied.notes.search(query, 20); + return notes.map(n => ({ id: n.id, title: n.title })); + }, + listNotes: async () => { + const notes = await window.readied.notes.list({ + sortBy: 'updatedAt', + sortOrder: 'desc', + archived: 'active', + }); + return notes.map(n => ({ id: n.id, title: n.title })); + }, + }), + [] + ); + // Expose methods via ref useImperativeHandle(ref, () => ({ focus: () => { @@ -304,6 +357,9 @@ export const MarkdownEditor = forwardRef { @@ -355,16 +411,36 @@ export const MarkdownEditor = forwardRef { + const view = viewRef.current; + if (!view) return; + + // Guard: avoid redundant dispatch + const current = view.state.field(currentNoteIdField, false); + if (current === noteId) return; + + const { selection } = view.state; + + view.dispatch({ + effects: setCurrentNoteId.of(noteId ?? null), + selection, // Preserve cursor position + }); + }, [noteId]); + return
; } ); diff --git a/apps/desktop/src/renderer/components/NoteEditor.tsx b/apps/desktop/src/renderer/components/NoteEditor.tsx index ac5d7a3..dafde9e 100644 --- a/apps/desktop/src/renderer/components/NoteEditor.tsx +++ b/apps/desktop/src/renderer/components/NoteEditor.tsx @@ -1,6 +1,6 @@ import { useRef, useCallback, useState, useEffect, lazy, Suspense } from 'react'; import { useQueryClient } from '@tanstack/react-query'; -import { FileText, MoreVertical } from 'lucide-react'; +import { FileText, MoreVertical, Link2 } from 'lucide-react'; import { TitleInput } from './TitleInput'; import { ActionsPanel, @@ -9,12 +9,14 @@ import { FormattingToolbar, MarkdownPreview, } from './editor'; +import { BacklinksPanel } from './editor/BacklinksPanel'; import type { MarkdownPreviewHandle, ToolbarVisibility } from './editor'; import type { MarkdownEditorHandle } from './MarkdownEditor'; import type { NoteSnapshot, NoteStatus } from '../../preload/index'; import { useEditorPreferencesStore } from '../stores/editorPreferencesStore'; import { useScrollSync } from '../hooks/useScrollSync'; import { noteKeys } from '../hooks/useNotes'; +import { useBacklinks } from '../hooks/useLinks'; // Lazy load the markdown editor for better initial load performance const MarkdownEditor = lazy(() => @@ -39,6 +41,7 @@ interface NoteEditorProps { onDuplicate?: () => void; onDelete?: () => void; onWikilinkClick?: (target: string) => void; + onNavigateToNote?: (noteId: string) => void; } export function NoteEditor({ @@ -50,6 +53,7 @@ export function NoteEditor({ onDuplicate, onDelete, onWikilinkClick, + onNavigateToNote, }: NoteEditorProps) { const queryClient = useQueryClient(); const debounceRef = useRef(null); @@ -67,6 +71,11 @@ export function NoteEditor({ // Actions panel state const [actionsOpen, setActionsOpen] = useState(false); + // Backlinks panel state + const [backlinksOpen, setBacklinksOpen] = useState(false); + const { data: backlinks } = useBacklinks(note?.id ?? null); + const backlinksCount = backlinks?.length ?? 0; + // Toolbar visibility state (for passing to ActionsPanel) const [toolbarVisibility, setToolbarVisibility] = useState({ text: true, @@ -211,18 +220,30 @@ export function NoteEditor({ return (
- {/* Title row with actions button */} + {/* Title row with actions buttons */}
- +
+ + +
{/* Metadata row: Notebook, Status, Tags */} {onMoveToNotebook && onStatusChange && ( @@ -258,6 +279,7 @@ export function NoteEditor({ initialContent={note.content} onChange={handleChange} onReady={handleEditorReady} + noteId={note.id} />
@@ -296,6 +318,14 @@ export function NoteEditor({ hiddenFormatting={toolbarVisibility} editorRef={editorRef} /> + + {/* Backlinks Panel */} + setBacklinksOpen(false)} + noteId={note.id} + onNavigateToNote={onNavigateToNote ?? (() => {})} + /> ); } diff --git a/apps/desktop/src/renderer/components/editor/BacklinksPanel/BacklinksPanel.module.css b/apps/desktop/src/renderer/components/editor/BacklinksPanel/BacklinksPanel.module.css new file mode 100644 index 0000000..5f276bd --- /dev/null +++ b/apps/desktop/src/renderer/components/editor/BacklinksPanel/BacklinksPanel.module.css @@ -0,0 +1,239 @@ +/** + * BacklinksPanel CSS Module + * + * Slide-in panel from the right with backlinks list. + * Uses portal to avoid stacking context issues. + * Matches ActionsPanel styling for consistency. + */ + +/* Backdrop overlay */ +.backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + z-index: 49; + opacity: 0; + pointer-events: none; + transition: opacity var(--transition-normal); +} + +.backdropOpen { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + z-index: 49; + opacity: 1; + pointer-events: auto; + transition: opacity var(--transition-normal); +} + +/* Panel container - Glassmorphism */ +.panel { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: 300px; + background: var(--glass-bg); + backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); + border-left: 1px solid var(--glass-border); + box-shadow: var(--glass-shadow); + transform: translateX(100%); + transition: transform var(--transition-normal); + z-index: 50; + display: flex; + flex-direction: column; + overflow: hidden; + -webkit-app-region: no-drag; +} + +.panelOpen { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: 300px; + background: var(--glass-bg); + backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); + border-left: 1px solid var(--glass-border); + box-shadow: var(--glass-shadow); + transform: translateX(0); + transition: transform var(--transition-normal); + z-index: 50; + display: flex; + flex-direction: column; + overflow: hidden; + -webkit-app-region: no-drag; +} + +/* Header */ +.header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px; + border-bottom: 1px solid var(--border-subtle); + flex-shrink: 0; +} + +.title { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + margin: 0; +} + +.count { + font-size: 12px; + font-weight: 500; + padding: 2px 8px; + border-radius: 10px; + background: var(--accent-muted); + color: var(--accent); +} + +.closeBtn { + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + color: var(--text-secondary); + cursor: pointer; + transition: background var(--transition-fast), color var(--transition-fast); + -webkit-app-region: no-drag; +} + +.closeBtn:hover { + background: var(--bg-elevated); + color: var(--text-primary); +} + +/* Scrollable content */ +.content { + flex: 1; + overflow-y: auto; + padding: 8px 0; +} + +/* Backlinks list */ +.list { + display: flex; + flex-direction: column; +} + +/* Backlink item */ +.item { + width: 100%; + display: flex; + align-items: flex-start; + gap: 12px; + padding: 12px 16px; + background: transparent; + border: none; + color: var(--text-secondary); + font-size: 13px; + cursor: pointer; + text-align: left; + transition: background var(--transition-fast), color var(--transition-fast); +} + +.item:hover { + background: var(--bg-elevated); + color: var(--text-primary); +} + +/* Icon container */ +.icon { + width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-top: 2px; + color: var(--text-muted); +} + +.item:hover .icon { + color: var(--accent); +} + +/* Item content */ +.itemContent { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.itemTitle { + font-weight: 500; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.itemRef { + font-size: 11px; + color: var(--text-muted); + font-family: var(--font-mono); +} + +/* Empty state */ +.emptyState { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 24px; + text-align: center; + gap: 12px; +} + +.emptyIcon { + color: var(--text-muted); + opacity: 0.5; +} + +.emptyText { + font-size: 14px; + color: var(--text-secondary); + font-weight: 500; +} + +.emptyHint { + font-size: 12px; + color: var(--text-muted); + max-width: 200px; + line-height: 1.5; +} + +/* Loading spinner */ +.spinner { + width: 24px; + height: 24px; + border: 2px solid var(--border-subtle); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} diff --git a/apps/desktop/src/renderer/components/editor/BacklinksPanel/BacklinksPanel.tsx b/apps/desktop/src/renderer/components/editor/BacklinksPanel/BacklinksPanel.tsx new file mode 100644 index 0000000..bc42e75 --- /dev/null +++ b/apps/desktop/src/renderer/components/editor/BacklinksPanel/BacklinksPanel.tsx @@ -0,0 +1,165 @@ +/** + * BacklinksPanel - Shows notes that link to the current note + * + * Part of PKM features (Personal Knowledge Management). + * Displays a slide-in panel from the right with backlinks. + */ + +import { memo, useEffect, useCallback } from 'react'; +import { createPortal } from 'react-dom'; +import { X, Link2, FileText, AlertCircle } from 'lucide-react'; +import { useBacklinks } from '../../../hooks/useLinks'; +import styles from './BacklinksPanel.module.css'; + +interface BacklinksPanelProps { + readonly isOpen: boolean; + readonly onClose: () => void; + readonly noteId: string | null; + readonly onNavigateToNote: (noteId: string) => void; +} + +/** + * BacklinksPanel - Slide-in panel showing backlinks + * + * Features: + * - Shows all notes that contain wikilinks to the current note + * - Click a backlink to navigate to that note + * - Empty state when no backlinks exist + */ +export const BacklinksPanel = memo(function BacklinksPanel({ + isOpen, + onClose, + noteId, + onNavigateToNote, +}: BacklinksPanelProps) { + const { data: backlinks, isLoading, error } = useBacklinks(noteId); + + // Handle ESC key to close + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isOpen, onClose]); + + // Set data-overlay-open for blur policy + useEffect(() => { + document.documentElement.dataset.overlayOpen = isOpen ? 'true' : 'false'; + return () => { + document.documentElement.dataset.overlayOpen = 'false'; + }; + }, [isOpen]); + + // Handle backlink click + const handleBacklinkClick = useCallback( + (backlinkNoteId: string) => { + onNavigateToNote(backlinkNoteId); + onClose(); + }, + [onNavigateToNote, onClose] + ); + + // Render content based on state + const renderContent = () => { + if (isLoading) { + return ( +
+
+ Loading backlinks... +
+ ); + } + + if (error) { + return ( +
+ + Failed to load backlinks +
+ ); + } + + if (!backlinks || backlinks.length === 0) { + return ( +
+ + No backlinks yet + + Notes that link to this note using [[wikilinks]] will appear here. + +
+ ); + } + + return ( +
+ {backlinks.map(backlink => ( + + ))} +
+ ); + }; + + // Render via portal to avoid stacking context issues + return createPortal( + <> + {/* Backdrop */} +