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
14 changes: 14 additions & 0 deletions apps/desktop/electron-vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,19 @@
"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",
"@codemirror/language-data": "^6.5.2",
"@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:*",
Expand Down
20 changes: 20 additions & 0 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -260,6 +274,14 @@ export interface ReadiedAPI {
/** Get log directory path */
getLogPath: () => Promise<string | null>;
};
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<BacklinkInfo[]>;
/** Get outgoing links (notes this note links TO) */
getOutgoing: (noteId: string) => Promise<OutgoingLinkInfo[]>;
};
}

// Expose the API
Expand Down Expand Up @@ -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);
Expand Down
9 changes: 8 additions & 1 deletion apps/desktop/src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -347,6 +353,7 @@ function NotesApp() {
onDuplicate={selectedNote ? () => handleDuplicateNote(selectedNote.id) : undefined}
onDelete={selectedNote ? () => handleDeleteNote(selectedNote.id) : undefined}
onWikilinkClick={handleWikilinkClick}
onNavigateToNote={handleSelectNote}
/>
</Panel>
</Group>
Expand Down
86 changes: 81 additions & 5 deletions apps/desktop/src/renderer/components/MarkdownEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 }
);
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -173,13 +206,33 @@ export interface MarkdownEditorHandle {

export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorProps>(
function MarkdownEditor(
{ initialContent, onChange, placeholder = 'Start writing...', onReady },
{ initialContent, onChange, placeholder = 'Start writing...', onReady, noteId },
ref
) {
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(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: () => {
Expand Down Expand Up @@ -304,6 +357,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
// Wikilink [[note]] highlighting
wikilinkExtension,

// Wikilink autocomplete (triggers on [[)
wikilinkAutocomplete,

// Dark theme
darkTheme,

Expand All @@ -318,7 +374,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
}
}),
];
}, [placeholder]);
}, [placeholder, wikilinkAutocomplete]);

// Initialize editor
useEffect(() => {
Expand Down Expand Up @@ -355,16 +411,36 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro

const currentContent = view.state.doc.toString();
if (currentContent !== initialContent) {
const { selection } = view.state;

view.dispatch({
changes: {
from: 0,
to: view.state.doc.length,
insert: initialContent,
},
selection, // Preserve cursor (CodeMirror clamps if invalid)
});
}
}, [initialContent]);

// Update currentNoteId in editor state (for autocomplete filtering)
useEffect(() => {
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 <div ref={containerRef} className="markdown-editor" />;
}
);
Loading
Loading