diff --git a/apps/desktop/electron-vite.config.ts b/apps/desktop/electron-vite.config.ts index 94b5467..66d1830 100644 --- a/apps/desktop/electron-vite.config.ts +++ b/apps/desktop/electron-vite.config.ts @@ -52,6 +52,7 @@ export default defineConfig({ rollupOptions: { input: { index: resolve(__dirname, 'src/renderer/index.html'), + settings: resolve(__dirname, 'src/renderer/settings.html'), }, }, }, diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 219686f..80fcac3 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -39,12 +39,12 @@ "@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:*", "@readied/storage-sqlite": "workspace:*", + "@readied/tasks": "workspace:*", + "@readied/wikilinks": "workspace:*", "@tanstack/react-query": "^5.90.16", "better-sqlite3": "^11.7.0", "electron-updater": "^6.6.2", @@ -65,10 +65,13 @@ "@vitejs/plugin-react": "^4.2.1", "electron": "^29.1.4", "electron-builder": "^26.0.12", + "electron-devtools-installer": "^4.0.0", "electron-vite": "^2.1.0", "pino-pretty": "^13.1.3", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-force-graph-2d": "^1.29.0", + "rehype-raw": "^7.0.0", "typescript": "^5.7.2", "vite": "^5.4.11", "vitest": "^2.1.8" diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index b225b5f..6948b02 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -4,11 +4,12 @@ * Initializes the app, database, and IPC handlers. */ -import { join } from 'path'; -import { readFile, writeFile, unlink } from 'fs/promises'; +import { join, normalize } from 'path'; +import { readFile, writeFile, unlink, mkdir } from 'fs/promises'; import { existsSync } from 'fs'; -import { app, BrowserWindow, ipcMain, dialog, shell } from 'electron'; +import { app, BrowserWindow, ipcMain, dialog, shell, protocol } from 'electron'; import { autoUpdater } from 'electron-updater'; +import installExtension, { REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer'; import { runMigrations, createDataPaths, @@ -166,14 +167,96 @@ function createWindow(): void { }); // Load renderer - if (process.env.NODE_ENV === 'development') { - mainWindow.loadURL('http://localhost:5173'); + if (process.env.NODE_ENV === 'development' && process.env.ELECTRON_RENDERER_URL) { + mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL); mainWindow.webContents.openDevTools(); } else { mainWindow.loadFile(join(__dirname, '../renderer/index.html')); } } +/** Create a new window for viewing a single note */ +function createNoteWindow(noteId: string, noteTitle: string): void { + const noteWindow = new BrowserWindow({ + width: 800, + height: 700, + minWidth: 500, + minHeight: 400, + show: false, + titleBarStyle: 'hiddenInset', + trafficLightPosition: { x: 8, y: 8 }, + backgroundColor: '#0a0b0d', + title: noteTitle || 'Note', + webPreferences: { + preload: join(__dirname, '../preload/index.js'), + nodeIntegration: false, + contextIsolation: true, + sandbox: false, + }, + }); + + noteWindow.on('ready-to-show', () => { + noteWindow.show(); + }); + + // Load renderer with note ID in query param + const query = `?noteWindow=${encodeURIComponent(noteId)}`; + if (process.env.NODE_ENV === 'development' && process.env.ELECTRON_RENDERER_URL) { + noteWindow.loadURL(`${process.env.ELECTRON_RENDERER_URL}${query}`); + } else { + noteWindow.loadFile(join(__dirname, '../renderer/index.html'), { + query: { noteWindow: noteId }, + }); + } +} + +/** Settings window singleton */ +let settingsWindow: BrowserWindow | null = null; + +/** Create or focus the settings window */ +function createSettingsWindow(): void { + // If window exists, focus it + if (settingsWindow && !settingsWindow.isDestroyed()) { + settingsWindow.focus(); + return; + } + + settingsWindow = new BrowserWindow({ + width: 700, + height: 500, + minWidth: 500, + minHeight: 400, + show: false, + titleBarStyle: 'hiddenInset', + trafficLightPosition: { x: 8, y: 8 }, + backgroundColor: '#0a0b0d', + title: 'Settings', + webPreferences: { + preload: join(__dirname, '../preload/index.js'), + nodeIntegration: false, + contextIsolation: true, + sandbox: false, + }, + }); + + settingsWindow.on('ready-to-show', () => { + settingsWindow?.show(); + }); + + settingsWindow.on('closed', () => { + settingsWindow = null; + }); + + // Load settings page + if (process.env.NODE_ENV === 'development' && process.env.ELECTRON_RENDERER_URL) { + // Replace index.html with settings.html in the URL + const settingsUrl = process.env.ELECTRON_RENDERER_URL.replace(/\/?$/, '/settings.html'); + settingsWindow.loadURL(settingsUrl); + } else { + settingsWindow.loadFile(join(__dirname, '../renderer/settings.html')); + } +} + /** Helper to convert a Note to a snapshot for IPC */ function noteToSnapshot(note: { id: string; @@ -414,6 +497,11 @@ function registerIpcHandlers(): void { return { ok: true }; }); + // Rename tag across all notes + ipcMain.handle('tags:rename', async (_event, oldName: string, newName: string) => { + return repo.renameTag(oldName, newName); + }); + // ═══════════════════════════════════════════════════════════════════════════ // Links (Wikilinks / Backlinks) // ═══════════════════════════════════════════════════════════════════════════ @@ -434,6 +522,145 @@ function registerIpcHandlers(): void { return repo.getOutgoingLinks(createNoteId(noteId)); }); + // Get graph data (all notes and links for visualization) + ipcMain.handle('links:graph', async () => { + try { + return repo.getGraphData(); + } catch (error) { + console.error('Failed to get graph data:', error); + // Return empty data on error + return { nodes: [], edges: [] }; + } + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // Window Management + // ═══════════════════════════════════════════════════════════════════════════ + + // Open a note in a new window + ipcMain.handle('window:openNote', async (_event, noteId: string, noteTitle: string) => { + createNoteWindow(noteId, noteTitle); + return { ok: true }; + }); + + // Open settings window + ipcMain.handle('window:openSettings', async () => { + createSettingsWindow(); + return { ok: true }; + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // Embeds (File Resolution) + // ═══════════════════════════════════════════════════════════════════════════ + + // Resolve embed target to asset:// URL + ipcMain.handle('embeds:resolve', async (_event, target: string, noteId: string) => { + if (!dataPaths) return null; + + // Build path to note's assets folder: /assets/{noteId}/{target} + const assetPath = join(dataPaths.assets, noteId, target); + + // Check if file exists + if (existsSync(assetPath)) { + // Return asset:// URL with host (required for browser to recognize protocol) + return `asset://local/${noteId}/${target}`; + } + + // File not found + return null; + }); + + // Batch resolve multiple embed targets (more efficient) + ipcMain.handle( + 'embeds:resolveBatch', + async (_event, targets: string[], noteId: string): Promise> => { + if (!dataPaths) return {}; + + const result: Record = {}; + for (const target of targets) { + const assetPath = join(dataPaths.assets, noteId, target); + // Return asset:// URL with host (required for browser to recognize protocol) + result[target] = existsSync(assetPath) ? `asset://local/${noteId}/${target}` : null; + } + return result; + } + ); + + // Save asset (image/file) for a note via drag & drop or paste + ipcMain.handle( + 'embeds:saveAsset', + async ( + _event, + noteId: string, + mime: string, + bytes: ArrayBuffer, + originalName?: string + ): Promise<{ ok: true; filename: string; relPath: string } | { ok: false; error: string }> => { + if (!dataPaths) { + return { ok: false, error: 'Data paths not initialized' }; + } + + // Validate noteId (non-empty, alphanumeric with hyphens/underscores) + if (!noteId || !/^[\w-]+$/.test(noteId)) { + return { ok: false, error: 'Invalid noteId' }; + } + + // Validate size (max 20MB) + const MAX_SIZE = 20 * 1024 * 1024; + if (bytes.byteLength > MAX_SIZE) { + return { ok: false, error: 'File too large (max 20MB)' }; + } + + // Derive extension from mime type + const mimeToExt: Record = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/jpg': 'jpg', + 'image/gif': 'gif', + 'image/webp': 'webp', + 'image/svg+xml': 'svg', + 'image/bmp': 'bmp', + 'image/ico': 'ico', + 'video/mp4': 'mp4', + 'video/webm': 'webm', + 'video/quicktime': 'mov', + 'audio/mpeg': 'mp3', + 'audio/wav': 'wav', + 'audio/ogg': 'ogg', + 'application/pdf': 'pdf', + }; + + let ext = mimeToExt[mime]; + if (!ext && originalName) { + // Fallback to originalName extension + const match = originalName.match(/\.([a-zA-Z0-9]+)$/); + ext = match?.[1]?.toLowerCase() ?? 'bin'; + } + if (!ext) { + ext = 'bin'; + } + + // Generate unique filename: timestamp-random.ext + const timestamp = Date.now(); + const random = Math.random().toString(36).substring(2, 8); + const filename = `${timestamp}-${random}.${ext}`; + + // Ensure note's assets directory exists + const noteAssetsDir = join(dataPaths.assets, noteId); + await mkdir(noteAssetsDir, { recursive: true }); + + // Write file + const assetPath = join(noteAssetsDir, filename); + await writeFile(assetPath, Buffer.from(bytes)); + + return { + ok: true, + filename, + relPath: `${noteId}/${filename}`, + }; + } + ); + // Count notes ipcMain.handle('notes:count', async () => { // Get all notes to compute counts @@ -960,6 +1187,30 @@ function initAutoUpdater(): void { }, 3000); } +/** + * asset:// protocol + * + * Invariant: + * - Renderer NEVER accesses filesystem paths directly + * - All local assets are resolved via asset:// URLs + * + * Rationale: + * - Avoids file:// which is blocked in dev (http://localhost) + * - Same behavior in dev and production + * - Enables secure embeds (images, video, pdf) + */ +protocol.registerSchemesAsPrivileged([ + { + scheme: 'asset', + privileges: { + secure: true, + standard: true, + supportFetchAPI: true, + stream: true, + }, + }, +]); + // App lifecycle app .whenReady() @@ -967,6 +1218,28 @@ app // Initialize data paths first (creates directories) dataPaths = initDataPaths(); + // Register asset:// protocol handler + protocol.registerFileProtocol('asset', (request, callback) => { + // asset://local/noteId/filename → assets/noteId/filename + // Strip protocol and host (local/) + let urlPath = decodeURIComponent(request.url.slice('asset://'.length)); + if (urlPath.startsWith('local/')) { + urlPath = urlPath.slice('local/'.length); + } + + // Sanitize: prevent path traversal attacks + const safePath = normalize(urlPath).replace(/^(\.\.[/\\])+/, ''); + + const filePath = join(dataPaths!.assets, safePath); + + if (!existsSync(filePath)) { + callback({ error: -6 }); // FILE_NOT_FOUND + return; + } + + callback({ path: filePath }); + }); + // Initialize logger (must be after dataPaths) const log = initLogger({ logsDir: dataPaths.logs, @@ -987,6 +1260,13 @@ app registerLogHandlers(); log.info('All IPC handlers registered'); + // Install React DevTools in development + if (process.env.NODE_ENV === 'development') { + installExtension(REACT_DEVELOPER_TOOLS) + .then(name => log.info({ extension: name }, 'DevTools extension installed')) + .catch(err => log.warn({ error: err.message }, 'Failed to install DevTools extension')); + } + // Create window and start auto-updater createWindow(); initAutoUpdater(); diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 040de18..6ef6136 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -154,6 +154,12 @@ export interface OutgoingLinkInfo { targetTitle: string | null; } +/** Graph data for visualization */ +export interface GraphData { + nodes: Array<{ id: string; title: string; notebookId: string }>; + edges: Array<{ source: string; target: string }>; +} + /** Log level types */ export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; @@ -205,6 +211,8 @@ export interface ReadiedAPI { setTagColor: (tagName: string, color: string | null) => Promise<{ ok: boolean }>; /** Delete a tag from the system */ deleteTag: (tagName: string) => Promise<{ ok: boolean }>; + /** Rename a tag across all notes */ + renameTag: (oldName: string, newName: string) => Promise<{ ok: boolean; error?: string }>; /** Set manual tags for a note (full replacement) */ setManualTags: (noteId: string, tags: string[]) => Promise<{ ok: boolean }>; /** Get manual tags only (for editor to know which are removable) */ @@ -281,6 +289,27 @@ export interface ReadiedAPI { getBacklinks: (noteId: string) => Promise; /** Get outgoing links (notes this note links TO) */ getOutgoing: (noteId: string) => Promise; + /** Get graph data (all notes and links for visualization) */ + getGraph: () => Promise; + }; + embeds: { + /** Resolve embed target to file:// URL (returns null if not found) */ + resolve: (target: string, noteId: string) => Promise; + /** Batch resolve multiple embed targets (more efficient) */ + resolveBatch: (targets: string[], noteId: string) => Promise>; + /** Save asset (image/file) for a note via drag & drop or paste */ + saveAsset: ( + noteId: string, + mime: string, + bytes: ArrayBuffer, + originalName?: string + ) => Promise<{ ok: true; filename: string; relPath: string } | { ok: false; error: string }>; + }; + windows: { + /** Open a note in a new window */ + openNote: (noteId: string, noteTitle: string) => Promise<{ ok: boolean }>; + /** Open the settings window */ + openSettings: () => Promise<{ ok: boolean }>; }; } @@ -307,6 +336,7 @@ const api: ReadiedAPI = { tagsWithColors: () => ipcRenderer.invoke('tags:listWithColors'), setTagColor: (tagName, color) => ipcRenderer.invoke('tags:setColor', tagName, color), deleteTag: tagName => ipcRenderer.invoke('tags:delete', tagName), + renameTag: (oldName, newName) => ipcRenderer.invoke('tags:rename', oldName, newName), setManualTags: (noteId, tags) => ipcRenderer.invoke('notes:setManualTags', noteId, tags), getManualTags: noteId => ipcRenderer.invoke('notes:getManualTags', noteId), count: () => ipcRenderer.invoke('notes:count'), @@ -333,7 +363,8 @@ const api: ReadiedAPI = { openFolder: () => ipcRenderer.invoke('data:openFolder'), }, app: { - version: () => '0.1.0', + // TODO: Use IPC to get version dynamically from main process + version: () => '0.1.5', }, license: { getState: () => ipcRenderer.invoke('license:getState'), @@ -360,6 +391,17 @@ const api: ReadiedAPI = { sync: (noteId, content) => ipcRenderer.invoke('links:sync', noteId, content), getBacklinks: noteId => ipcRenderer.invoke('links:backlinks', noteId), getOutgoing: noteId => ipcRenderer.invoke('links:outgoing', noteId), + getGraph: () => ipcRenderer.invoke('links:graph'), + }, + embeds: { + resolve: (target, noteId) => ipcRenderer.invoke('embeds:resolve', target, noteId), + resolveBatch: (targets, noteId) => ipcRenderer.invoke('embeds:resolveBatch', targets, noteId), + saveAsset: (noteId, mime, bytes, originalName) => + ipcRenderer.invoke('embeds:saveAsset', noteId, mime, bytes, originalName), + }, + windows: { + openNote: (noteId, noteTitle) => ipcRenderer.invoke('window:openNote', noteId, noteTitle), + openSettings: () => ipcRenderer.invoke('window:openSettings'), }, }; diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index fe524c5..b7a390d 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -1,10 +1,11 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { Group, Panel, Separator, useDefaultLayout } from 'react-resizable-panels'; import type { NoteSnapshot, NoteStatus } from '../preload/index'; import { NoteList } from './components/NoteList'; import { NoteEditor } from './components/NoteEditor'; +import { NoteWindow } from './components/NoteWindow'; import { Sidebar } from './components/sidebar'; +import { GraphView } from './components/GraphView'; import { LicenseProvider } from './contexts/LicenseContext'; import { ErrorBoundary } from './components/ErrorBoundary'; import { @@ -19,9 +20,12 @@ import { } from './hooks/useNavigation'; import { useSearchNotes, useNoteMutations } from './hooks/useNotes'; import { useSyncLinks } from './hooks/useLinks'; +import { useDebouncedSearch } from './hooks/useDebouncedSearch'; +import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'; import { useEditorPreferencesStore } from './stores/editorPreferencesStore'; import { useTagColorsStore } from './stores/tagColorsStore'; import { usePerformanceMode } from './hooks/usePerformanceMode'; +import { useResizableLayout } from './hooks/useResizableLayout'; const queryClient = new QueryClient({ defaultOptions: { @@ -34,19 +38,13 @@ const queryClient = new QueryClient({ /** * Main Notes Application - * - * Uses Zustand for navigation state (no Provider needed). - * All filtering is derived via useFilteredNotes hook. */ function NotesApp() { - // Initialize performance mode (glass/blur tuning) usePerformanceMode(); - // Layout persistence - const { defaultLayout, onLayoutChange } = useDefaultLayout({ - id: 'readied-main-layout', - storage: localStorage, - }); + // Resizable layout + const { sidebarWidth, notelistWidth, startResizeSidebar, startResizeNotelist } = + useResizableLayout(); // Navigation state from Zustand const navigation = useNavigation(); @@ -68,9 +66,8 @@ function NotesApp() { // Local UI state const [selectedNote, setSelectedNote] = useState(null); - const [searchQuery, setSearchQuery] = useState(''); - const [debouncedSearch, setDebouncedSearch] = useState(''); - const searchDebounceRef = useRef(null); + const { searchQuery, debouncedSearch, handleSearch, clearSearch } = useDebouncedSearch(300); + const [isGraphOpen, setIsGraphOpen] = useState(false); // Search query const searchNotesQuery = useSearchNotes(debouncedSearch, 50); @@ -102,19 +99,6 @@ function NotesApp() { // Determine selected quick filter for NoteList header const selectedQuickFilter = navigation.kind === 'global' ? navigation.filter : null; - // Handle search with debounce - const handleSearch = useCallback((query: string) => { - setSearchQuery(query); - - if (searchDebounceRef.current) { - clearTimeout(searchDebounceRef.current); - } - - searchDebounceRef.current = setTimeout(() => { - setDebouncedSearch(query); - }, 300); - }, []); - // Create new note (respects current navigation context) const handleNewNote = useCallback(async () => { const newNote = await createNote.mutateAsync({ @@ -122,9 +106,8 @@ function NotesApp() { notebookId: selectedNotebookId ?? undefined, }); setSelectedNote(newNote); - setSearchQuery(''); - setDebouncedSearch(''); - }, [createNote, selectedNotebookId]); + clearSearch(); + }, [createNote, selectedNotebookId, clearSearch]); // Select note const handleSelectNote = useCallback(async (id: string) => { @@ -258,66 +241,35 @@ function NotesApp() { [selectedNote, setNoteStatus, statusFilter] ); - // Keyboard shortcuts - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - const isMod = e.metaKey || e.ctrlKey; - - if (isMod && e.key === 'n') { - e.preventDefault(); - handleNewNote(); - } - - if (isMod && e.key === 'f') { - e.preventDefault(); - const searchInput = document.querySelector('.search-input') as HTMLInputElement; - searchInput?.focus(); - } - - if (isMod && e.key === 'd' && selectedNote) { - e.preventDefault(); - handleDuplicateNote(selectedNote.id); - } - - // Cmd+Shift+P to cycle view mode (Editor → Split → Preview) - if (isMod && e.shiftKey && e.key === 'p') { - e.preventDefault(); - cycleViewMode(); - } - - if (e.key === 'Escape') { - if (searchQuery) { - setSearchQuery(''); - setDebouncedSearch(''); - } else if (selectedNote) { - setSelectedNote(null); - } - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [handleNewNote, handleDuplicateNote, selectedNote, searchQuery, cycleViewMode]); + // Keyboard shortcuts (extracted to hook) + useKeyboardShortcuts({ + onNewNote: handleNewNote, + onDuplicateNote: handleDuplicateNote, + onCycleViewMode: cycleViewMode, + onToggleGraph: useCallback(() => setIsGraphOpen(prev => !prev), []), + onCloseGraph: useCallback(() => setIsGraphOpen(false), []), + onClearSearch: clearSearch, + onDeselectNote: useCallback(() => setSelectedNote(null), []), + selectedNote, + searchQuery, + isGraphOpen, + }); return (
- - {/* Sidebar Panel */} - - - - - - - {/* NoteList Panel */} - +
+ +
+ +
- - - - - {/* Editor Panel - elastic, takes remaining space */} - - handleDuplicateNote(selectedNote.id) : undefined} - onDelete={selectedNote ? () => handleDeleteNote(selectedNote.id) : undefined} - onWikilinkClick={handleWikilinkClick} - onNavigateToNote={handleSelectNote} - /> - - +
+
+ +
+ {isGraphOpen ? ( + { + handleSelectNote(noteId); + setIsGraphOpen(false); + }} + onClose={() => setIsGraphOpen(false)} + /> + ) : ( + handleDuplicateNote(selectedNote.id) : undefined} + onDelete={selectedNote ? () => handleDeleteNote(selectedNote.id) : undefined} + onWikilinkClick={handleWikilinkClick} + onNavigateToNote={handleSelectNote} + onNoteUpdate={setSelectedNote} + /> + )} +
+
); } export function App() { + // Check for note window mode via URL query param + const urlParams = new URLSearchParams(window.location.search); + const noteWindowId = urlParams.get('noteWindow'); + + // If this is a note window, render just the note editor + if (noteWindowId) { + return ( + + + + ); + } + + // Main app return ( diff --git a/apps/desktop/src/renderer/components/Breadcrumb.tsx b/apps/desktop/src/renderer/components/Breadcrumb.tsx deleted file mode 100644 index 7e9a8a1..0000000 --- a/apps/desktop/src/renderer/components/Breadcrumb.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { useMemo } from 'react'; -import type { NotebookSnapshot } from '../../preload/index'; -import { useNotebookList } from '../hooks/useNotebooks'; - -interface BreadcrumbProps { - selectedNotebookId: string | null; - onNavigate: (notebookId: string) => void; -} - -/** Build the path from root to the selected notebook */ -function buildPath(notebooks: NotebookSnapshot[], selectedId: string | null): NotebookSnapshot[] { - if (!selectedId) return []; - - const notebookMap = new Map(notebooks.map(nb => [nb.id, nb])); - const path: NotebookSnapshot[] = []; - - let current = notebookMap.get(selectedId); - while (current) { - path.unshift(current); - current = current.parentId ? notebookMap.get(current.parentId) : undefined; - } - - return path; -} - -export function Breadcrumb({ selectedNotebookId, onNavigate }: BreadcrumbProps) { - const { data: notebooks = [] } = useNotebookList(); - - const path = useMemo( - () => buildPath(notebooks, selectedNotebookId), - [notebooks, selectedNotebookId] - ); - - // Don't show breadcrumb if we're at root level (Inbox) or no selection - if (path.length <= 1) { - return null; - } - - return ( - - ); -} diff --git a/apps/desktop/src/renderer/components/GraphView.css b/apps/desktop/src/renderer/components/GraphView.css new file mode 100644 index 0000000..8d3f075 --- /dev/null +++ b/apps/desktop/src/renderer/components/GraphView.css @@ -0,0 +1,85 @@ +/** + * Graph View Styles + */ + +.graph-view { + display: flex; + flex-direction: column; + height: 100%; + background: var(--bg-base); +} + +.graph-view__header { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + background: var(--bg-surface); +} + +.graph-view__header h2 { + margin: 0; + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} + +.graph-view__stats { + font-size: 12px; + color: var(--text-muted); + flex: 1; +} + +.graph-view__close { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.15s ease; +} + +.graph-view__close:hover { + background: var(--bg-elevated); + color: var(--text-primary); +} + +.graph-view__container { + flex: 1; + min-height: 0; + position: relative; +} + +.graph-view__container canvas { + display: block; +} + +.graph-view__loading, +.graph-view__error, +.graph-view__empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-muted); + font-size: 14px; + text-align: center; + padding: 24px; +} + +.graph-view__error { + color: var(--error); +} + +.graph-view__hint { + margin-top: 8px; + font-size: 12px; + opacity: 0.7; +} diff --git a/apps/desktop/src/renderer/components/GraphView.tsx b/apps/desktop/src/renderer/components/GraphView.tsx new file mode 100644 index 0000000..551473c --- /dev/null +++ b/apps/desktop/src/renderer/components/GraphView.tsx @@ -0,0 +1,191 @@ +/** + * Graph View Component + * + * Visualizes notes as nodes and their wikilinks as edges + * using react-force-graph-2d for interactive exploration. + */ + +import { useCallback, useRef, useMemo } from 'react'; +import ForceGraph2D, { ForceGraphMethods } from 'react-force-graph-2d'; +import { X } from 'lucide-react'; +import { useGraphData } from '../hooks/useLinks'; +import './GraphView.css'; + +interface GraphViewProps { + /** Currently selected note ID (will be highlighted) */ + selectedNoteId?: string | null; + /** Callback when a node is clicked */ + onNodeClick?: (noteId: string) => void; + /** Callback to close the graph view */ + onClose?: () => void; +} + +interface GraphNode { + id: string; + title: string; + notebookId: string; + // Force graph internal properties + x?: number; + y?: number; + vx?: number; + vy?: number; +} + +interface GraphLink { + source: string | GraphNode; + target: string | GraphNode; +} + +export function GraphView({ selectedNoteId, onNodeClick, onClose }: GraphViewProps) { + const { data, isLoading, error } = useGraphData(); + const graphRef = useRef>(); + + // Transform data for react-force-graph + const graphData = useMemo(() => { + if (!data) return { nodes: [], links: [] }; + + return { + nodes: data.nodes.map(n => ({ + id: n.id, + title: n.title, + notebookId: n.notebookId, + })), + links: data.edges.map(e => ({ + source: e.source, + target: e.target, + })), + }; + }, [data]); + + // Node color based on selection (using hex - CSS vars don't work in canvas) + const getNodeColor = useCallback( + (node: GraphNode) => { + if (node.id === selectedNoteId) { + return '#6366f1'; // accent/indigo + } + return '#a1a1aa'; // zinc-400 + }, + [selectedNoteId] + ); + + // Node size based on connections (nodeVal is area, not radius) + const getNodeSize = useCallback( + (node: GraphNode) => { + const linkCount = graphData.links.filter(l => { + const sourceId = typeof l.source === 'string' ? l.source : (l.source as GraphNode).id; + const targetId = typeof l.target === 'string' ? l.target : (l.target as GraphNode).id; + return sourceId === node.id || targetId === node.id; + }).length; + // Small values: 1 = tiny, 3 = medium, 5 = large + return Math.max(1, Math.min(4, 1 + linkCount * 0.5)); + }, + [graphData.links] + ); + + // Handle node click + const handleNodeClick = useCallback( + (node: GraphNode) => { + onNodeClick?.(node.id); + }, + [onNodeClick] + ); + + // Draw node label (using hex - CSS vars don't work in canvas) + const drawNodeLabel = useCallback( + (node: GraphNode, ctx: CanvasRenderingContext2D, globalScale: number) => { + const label = node.title || 'Untitled'; + const fontSize = 11 / globalScale; + + ctx.font = `${fontSize}px -apple-system, BlinkMacSystemFont, sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ctx.fillStyle = node.id === selectedNoteId ? '#818cf8' : '#d4d4d8'; // indigo-400 : zinc-300 + ctx.fillText(label, node.x ?? 0, (node.y ?? 0) + 6); + }, + [selectedNoteId] + ); + + if (isLoading) { + return ( +
+
Loading graph...
+
+ ); + } + + if (error) { + console.error('Graph data error:', error); + return ( +
+
+

Graph View

+ {onClose && ( + + )} +
+
+ Failed to load graph data +

Check console for details

+
+
+ ); + } + + if (graphData.nodes.length === 0) { + return ( +
+
+

Graph View

+ {onClose && ( + + )} +
+
+

No notes to display

+

Create some notes and link them with [[wikilinks]]

+
+
+ ); + } + + return ( +
+
+

Graph View

+ + {graphData.nodes.length} notes, {graphData.links.length} links + + {onClose && ( + + )} +
+
+ '#52525b'} + linkWidth={1.5} + linkDirectionalArrowLength={4} + linkDirectionalArrowRelPos={1} + onNodeClick={handleNodeClick} + nodeCanvasObjectMode={() => 'after'} + nodeCanvasObject={drawNodeLabel} + backgroundColor="#18181b" + cooldownTicks={100} + d3AlphaDecay={0.02} + d3VelocityDecay={0.3} + /> +
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/ImageLightbox.tsx b/apps/desktop/src/renderer/components/ImageLightbox.tsx new file mode 100644 index 0000000..3e397b6 --- /dev/null +++ b/apps/desktop/src/renderer/components/ImageLightbox.tsx @@ -0,0 +1,123 @@ +/** + * ImageLightbox - Fullscreen image viewer + * + * Opens when clicking on embedded images in the preview. + * Supports zoom and pan. + */ + +import { useCallback, useEffect, useState } from 'react'; +import { X, ZoomIn, ZoomOut, RotateCw } from 'lucide-react'; + +interface ImageLightboxProps { + readonly src: string; + readonly alt?: string; + readonly onClose: () => void; +} + +export function ImageLightbox({ src, alt, onClose }: ImageLightboxProps) { + const [scale, setScale] = useState(1); + const [rotation, setRotation] = useState(0); + + // Handle keyboard + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } else if (e.key === '+' || e.key === '=') { + setScale(s => Math.min(s + 0.25, 4)); + } else if (e.key === '-') { + setScale(s => Math.max(s - 0.25, 0.25)); + } else if (e.key === 'r') { + setRotation(r => (r + 90) % 360); + } else if (e.key === '0') { + setScale(1); + setRotation(0); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + // Prevent body scroll while open + useEffect(() => { + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = ''; + }; + }, []); + + const handleBackdropClick = useCallback( + (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose(); + } + }, + [onClose] + ); + + const zoomIn = useCallback(() => setScale(s => Math.min(s + 0.25, 4)), []); + const zoomOut = useCallback(() => setScale(s => Math.max(s - 0.25, 0.25)), []); + const rotate = useCallback(() => setRotation(r => (r + 90) % 360), []); + + return ( +
+ {/* Controls */} +
+ + {Math.round(scale * 100)}% + + + +
+ + {/* Image */} +
+ {alt +
+ + {/* Filename */} + {alt &&
{alt}
} +
+ ); +} diff --git a/apps/desktop/src/renderer/components/MarkdownEditor.tsx b/apps/desktop/src/renderer/components/MarkdownEditor.tsx index 3119a11..ea7af84 100644 --- a/apps/desktop/src/renderer/components/MarkdownEditor.tsx +++ b/apps/desktop/src/renderer/components/MarkdownEditor.tsx @@ -3,7 +3,7 @@ */ import { useEffect, useRef, useCallback, forwardRef, useImperativeHandle, useMemo } from 'react'; -import { EditorState, type Extension } from '@codemirror/state'; +import { EditorState, EditorSelection, type Extension } from '@codemirror/state'; import { EditorView, keymap, @@ -44,6 +44,8 @@ import { setCurrentNoteId, currentNoteIdField, } from '@readied/wikilinks'; +import { embedInlinePreview } from '@readied/embeds/codemirror'; +import { useEditorBufferStore } from '../stores/editorBufferStore'; /** Dark theme matching Readied's design */ const darkTheme = EditorView.theme( @@ -177,6 +179,8 @@ interface MarkdownEditorProps { onReady?: () => void; /** Current note ID (for excluding from wikilink autocomplete) */ noteId?: string; + /** Callback to get resolved embed URL (for inline image preview) */ + getEmbedUrl?: (target: string) => string | null; } /** Imperative handle exposed via ref */ @@ -206,12 +210,14 @@ export interface MarkdownEditorHandle { export const MarkdownEditor = forwardRef( function MarkdownEditor( - { initialContent, onChange, placeholder = 'Start writing...', onReady, noteId }, + { initialContent, onChange, placeholder = 'Start writing...', onReady, noteId, getEmbedUrl }, ref ) { const containerRef = useRef(null); const viewRef = useRef(null); const onChangeRef = useRef(onChange); + const noteIdRef = useRef(noteId); + const getEmbedUrlRef = useRef(getEmbedUrl); // Create wikilink autocomplete extension with injected dependencies const wikilinkAutocomplete = useMemo( @@ -314,8 +320,10 @@ export const MarkdownEditor = forwardRef { @@ -360,6 +368,9 @@ export const MarkdownEditor = forwardRef getEmbedUrlRef.current?.(target) ?? null), + // Dark theme darkTheme, @@ -370,6 +381,9 @@ export const MarkdownEditor = forwardRef { if (update.docChanged) { const content = update.state.doc.toString(); + // Update live buffer immediately (for preview sync) + useEditorBufferStore.getState().updateBuffer(content); + // Trigger debounced save via callback onChangeRef.current(content); } }), @@ -398,7 +412,73 @@ export const MarkdownEditor = forwardRef { + const file = e.dataTransfer?.files[0]; + if (!file?.type.startsWith('image/')) return; + const currentNoteId = noteIdRef.current; + if (!currentNoteId) return; + + e.preventDefault(); + + const bytes = await file.arrayBuffer(); + const result = await window.readied.embeds.saveAsset( + currentNoteId, + file.type, + bytes, + file.name + ); + if (!result.ok) { + console.error('Failed to save asset:', result.error); + return; + } + + const embed = `![[${result.filename}]]`; + const pos = view.state.selection.main.head; + view.dispatch({ + changes: { from: pos, insert: embed }, + selection: EditorSelection.cursor(pos + embed.length), + userEvent: 'input.drop', + }); + }; + + // Handle image paste (Cmd+V) + const handlePaste = async (e: ClipboardEvent) => { + const items = Array.from(e.clipboardData?.items || []); + const imageItem = items.find(i => i.type.startsWith('image/')); + + if (!imageItem) return; // Let default paste handle text + const currentNoteId = noteIdRef.current; + if (!currentNoteId) return; + + e.preventDefault(); + const blob = imageItem.getAsFile(); + if (!blob) return; + + const bytes = await blob.arrayBuffer(); + const result = await window.readied.embeds.saveAsset(currentNoteId, blob.type, bytes); + if (!result.ok) { + console.error('Failed to save asset:', result.error); + return; + } + + const embed = `![[${result.filename}]]`; + const pos = view.state.selection.main.head; + view.dispatch({ + changes: { from: pos, insert: embed }, + selection: EditorSelection.cursor(pos + embed.length), + userEvent: 'input.paste', + }); + }; + + // Add event listeners to the editor DOM + const dom = view.dom; + dom.addEventListener('drop', handleDrop); + dom.addEventListener('paste', handlePaste); + return () => { + dom.removeEventListener('drop', handleDrop); + dom.removeEventListener('paste', handlePaste); view.destroy(); viewRef.current = null; }; diff --git a/apps/desktop/src/renderer/components/NoteEditor.tsx b/apps/desktop/src/renderer/components/NoteEditor.tsx index dafde9e..a6f4a66 100644 --- a/apps/desktop/src/renderer/components/NoteEditor.tsx +++ b/apps/desktop/src/renderer/components/NoteEditor.tsx @@ -1,7 +1,16 @@ import { useRef, useCallback, useState, useEffect, lazy, Suspense } from 'react'; -import { useQueryClient } from '@tanstack/react-query'; import { FileText, MoreVertical, Link2 } from 'lucide-react'; -import { TitleInput } from './TitleInput'; +import type { NoteSnapshot, NoteStatus } from '../../preload/index'; +import { useEditorPreferencesStore } from '../stores/editorPreferencesStore'; +import { useEditorBufferStore } from '../stores/editorBufferStore'; +import { useScrollSync } from '../hooks/useScrollSync'; +import { useManualTags } from '../hooks/useManualTags'; +import { useEmbedResolver } from '../hooks/useEmbedResolver'; +import { useBacklinks } from '../hooks/useLinks'; +import type { MarkdownEditorHandle } from './MarkdownEditor'; +import type { MarkdownPreviewHandle, ToolbarVisibility } from './editor'; +import { ImageLightbox } from './ImageLightbox'; +import { BacklinksPanel } from './editor/BacklinksPanel'; import { ActionsPanel, EditorHeader, @@ -9,14 +18,7 @@ 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'; +import { TitleInput } from './TitleInput'; // Lazy load the markdown editor for better initial load performance const MarkdownEditor = lazy(() => @@ -42,6 +44,8 @@ interface NoteEditorProps { onDelete?: () => void; onWikilinkClick?: (target: string) => void; onNavigateToNote?: (noteId: string) => void; + /** Called when note is updated (e.g., tags changed) */ + onNoteUpdate?: (note: NoteSnapshot) => void; } export function NoteEditor({ @@ -54,8 +58,8 @@ export function NoteEditor({ onDelete, onWikilinkClick, onNavigateToNote, + onNoteUpdate, }: NoteEditorProps) { - const queryClient = useQueryClient(); const debounceRef = useRef(null); const editorRef = useRef(null); const previewRef = useRef(null); @@ -65,8 +69,27 @@ export function NoteEditor({ const viewMode = useEditorPreferencesStore(state => state.viewMode); const setViewMode = useEditorPreferencesStore(state => state.setViewMode); - // Manual tags state (fetched separately, not in NoteSnapshot) - const [manualTags, setManualTags] = useState([]); + // Initialize editor buffer when note changes + useEffect(() => { + // Cancel any pending debounce from previous note to prevent stale mutations + if (debounceRef.current) { + clearTimeout(debounceRef.current); + debounceRef.current = null; + } + + if (note) { + useEditorBufferStore.getState().setNote(note.id, note.content); + } else { + useEditorBufferStore.getState().clear(); + } + }, [note?.id, note?.content]); + + // Manual tags (extracted to hook) + const { manualTags, displayTags, addTag, removeTag } = useManualTags({ + noteId: note?.id ?? null, + inlineTags: note?.tags ?? [], + onNoteUpdate, + }); // Actions panel state const [actionsOpen, setActionsOpen] = useState(false); @@ -76,6 +99,15 @@ export function NoteEditor({ const { data: backlinks } = useBacklinks(note?.id ?? null); const backlinksCount = backlinks?.length ?? 0; + // Lightbox state for embedded images + const [lightbox, setLightbox] = useState<{ src: string; alt: string } | null>(null); + + // Embed resolution (extracted to hook) + const { resolvedEmbeds, getEmbedUrl } = useEmbedResolver({ + noteId: note?.id ?? null, + content: note?.content ?? null, + }); + // Toolbar visibility state (for passing to ActionsPanel) const [toolbarVisibility, setToolbarVisibility] = useState({ text: true, @@ -84,38 +116,6 @@ export function NoteEditor({ history: true, }); - // Merge note.tags with manualTags for display (deduplicated) - const displayTags = note ? [...new Set([...note.tags, ...manualTags])].sort() : []; - - // Fetch manual tags when note changes - useEffect(() => { - if (!note) { - setManualTags([]); - return; - } - - const noteId = note.id; - let cancelled = false; - async function loadManualTags() { - try { - const tags = await window.readied.notes.getManualTags(noteId); - if (!cancelled) { - setManualTags(tags); - } - } catch (error) { - console.error('Failed to load manual tags:', error); - if (!cancelled) { - setManualTags([]); - } - } - } - loadManualTags(); - - return () => { - cancelled = true; - }; - }, [note?.id]); - const showEditor = viewMode === 'editor' || viewMode === 'split'; const showPreview = viewMode === 'preview' || viewMode === 'split'; const isSplitMode = viewMode === 'split'; @@ -143,6 +143,15 @@ export function NoteEditor({ [onUpdate] ); + // Cleanup debounce timer on unmount to prevent stale mutations + useEffect(() => { + return () => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + }; + }, []); + // Handle title change const handleTitleChange = useCallback( (title: string) => { @@ -156,54 +165,6 @@ export function NoteEditor({ editorRef.current?.focus(); }, []); - // Handle adding a manual tag - const handleAddTag = useCallback( - async (tag: string) => { - if (!note) return; - const normalized = tag.trim().toLowerCase().replace(/^#/, ''); - if (!normalized || manualTags.includes(normalized)) return; - - const updatedTags = [...manualTags, normalized]; - setManualTags(updatedTags); - - try { - await window.readied.notes.setManualTags(note.id, updatedTags); - // Invalidate tags query so sidebar updates - queryClient.invalidateQueries({ queryKey: noteKeys.tags() }); - // Invalidate notes list so NoteList updates immediately - queryClient.invalidateQueries({ queryKey: noteKeys.lists() }); - } catch (error) { - console.error('Failed to save manual tags:', error); - // Revert on error - setManualTags(manualTags); - } - }, - [note, manualTags, queryClient] - ); - - // Handle removing a manual tag - const handleRemoveTag = useCallback( - async (tag: string) => { - if (!note) return; - - const updatedTags = manualTags.filter(t => t !== tag); - setManualTags(updatedTags); - - try { - await window.readied.notes.setManualTags(note.id, updatedTags); - // Invalidate tags query so sidebar updates - queryClient.invalidateQueries({ queryKey: noteKeys.tags() }); - // Invalidate notes list so NoteList updates immediately - queryClient.invalidateQueries({ queryKey: noteKeys.lists() }); - } catch (error) { - console.error('Failed to save manual tags:', error); - // Revert on error - setManualTags(manualTags); - } - }, - [note, manualTags, queryClient] - ); - if (!note) { return (
@@ -253,8 +214,8 @@ export function NoteEditor({ manualTags={manualTags} onMoveToNotebook={onMoveToNotebook} onStatusChange={onStatusChange} - onAddTag={handleAddTag} - onRemoveTag={handleRemoveTag} + onAddTag={addTag} + onRemoveTag={removeTag} /> )}
@@ -280,6 +241,7 @@ export function NoteEditor({ onChange={handleChange} onReady={handleEditorReady} noteId={note.id} + getEmbedUrl={getEmbedUrl} />
@@ -294,10 +256,13 @@ export function NoteEditor({ setLightbox({ src: url, alt: target })} + resolvedEmbeds={resolvedEmbeds} />
)} @@ -313,6 +278,7 @@ export function NoteEditor({ isOpen={actionsOpen} onClose={() => setActionsOpen(false)} noteId={note.id} + noteTitle={note.title} onDuplicate={onDuplicate} onDelete={onDelete} hiddenFormatting={toolbarVisibility} @@ -326,6 +292,11 @@ export function NoteEditor({ noteId={note.id} onNavigateToNote={onNavigateToNote ?? (() => {})} /> + + {/* Image Lightbox */} + {lightbox && ( + setLightbox(null)} /> + )} ); } diff --git a/apps/desktop/src/renderer/components/NoteList.tsx b/apps/desktop/src/renderer/components/NoteList.tsx index 9041cf9..e2817bf 100644 --- a/apps/desktop/src/renderer/components/NoteList.tsx +++ b/apps/desktop/src/renderer/components/NoteList.tsx @@ -13,8 +13,8 @@ import { import { useNotebookList, useNotebook } from '../hooks/useNotebooks'; import type { NoteWithExcerpt, SortBy, SortOrder } from '../hooks/useNavigation'; import { formatRelativeTime } from '../utils/date'; -import type { QuickFilterType } from './sidebar'; import { useTagColorsStore } from '../stores/tagColorsStore'; +import type { QuickFilterType } from './sidebar'; import { NoteListContextMenu } from './NoteListContextMenu'; import { NotebookPicker } from './NotebookPicker'; diff --git a/apps/desktop/src/renderer/components/NoteWindow.css b/apps/desktop/src/renderer/components/NoteWindow.css new file mode 100644 index 0000000..1abac79 --- /dev/null +++ b/apps/desktop/src/renderer/components/NoteWindow.css @@ -0,0 +1,54 @@ +/** + * NoteWindow Styles + * Standalone note editor in a separate window + */ + +.note-window { + display: flex; + flex-direction: column; + height: 100vh; + width: 100vw; + background: var(--bg-base); + /* macOS traffic light padding */ + padding-top: 38px; +} + +.note-window--loading, +.note-window--error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + color: var(--text-muted); + font-size: 14px; +} + +.note-window__spinner { + width: 24px; + height: 24px; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Override note-editor styles for window mode */ +.note-window .note-editor { + height: 100%; + border: none; +} + +.note-window .note-editor-header { + -webkit-app-region: drag; +} + +.note-window .note-editor-header-actions { + -webkit-app-region: no-drag; +} diff --git a/apps/desktop/src/renderer/components/NoteWindow.tsx b/apps/desktop/src/renderer/components/NoteWindow.tsx new file mode 100644 index 0000000..729a289 --- /dev/null +++ b/apps/desktop/src/renderer/components/NoteWindow.tsx @@ -0,0 +1,141 @@ +/** + * NoteWindow Component + * + * Standalone note editor displayed in a separate window. + * Receives noteId via URL query param. + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { NoteSnapshot, NoteStatus } from '../../preload/index'; +import { useSyncLinks } from '../hooks/useLinks'; +import { NoteEditor } from './NoteEditor'; +import './NoteWindow.css'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60, + retry: 1, + }, + }, +}); + +interface NoteWindowContentProps { + noteId: string; +} + +function NoteWindowContent({ noteId }: NoteWindowContentProps) { + const [note, setNote] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const debounceRef = useRef(null); + const syncLinks = useSyncLinks(); + + // Load note on mount + useEffect(() => { + async function loadNote() { + setLoading(true); + setError(null); + try { + const result = await window.readied.notes.get(noteId); + if (result.ok) { + setNote(result.data); + } else { + setError('Note not found'); + } + } catch { + setError('Failed to load note'); + } finally { + setLoading(false); + } + } + loadNote(); + }, [noteId]); + + // Update note content + const handleUpdate = useCallback( + async (content: string) => { + if (!note) return; + + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + + debounceRef.current = setTimeout(async () => { + const updated = await window.readied.notes.update({ id: note.id, content }); + if (updated.ok) { + setNote(updated.data); + syncLinks.mutate({ noteId: note.id, content }); + } + }, 500); + }, + [note, syncLinks] + ); + + // Update note title + const handleTitleUpdate = useCallback( + async (title: string) => { + if (!note) return; + const updated = await window.readied.notes.updateTitle({ id: note.id, title }); + if (updated.ok) { + setNote(updated.data); + // Update window title + document.title = title || 'Note'; + } + }, + [note] + ); + + // Update note status + const handleStatusChange = useCallback( + async (status: NoteStatus) => { + if (!note) return; + const updated = await window.readied.notes.setStatus(note.id, status); + if (updated.ok) { + setNote(updated.data); + } + }, + [note] + ); + + if (loading) { + return ( +
+
+ Loading note... +
+ ); + } + + if (error || !note) { + return ( +
+ {error || 'Note not found'} +
+ ); + } + + return ( +
+ +
+ ); +} + +interface NoteWindowProps { + noteId: string; +} + +export function NoteWindow({ noteId }: NoteWindowProps) { + return ( + + + + ); +} diff --git a/apps/desktop/src/renderer/components/editor/ActionsPanel/ActionsPanel.tsx b/apps/desktop/src/renderer/components/editor/ActionsPanel/ActionsPanel.tsx index fb52b39..930c1bc 100644 --- a/apps/desktop/src/renderer/components/editor/ActionsPanel/ActionsPanel.tsx +++ b/apps/desktop/src/renderer/components/editor/ActionsPanel/ActionsPanel.tsx @@ -8,6 +8,7 @@ import { Trash2, History, Share2, + ExternalLink, // Formatting icons for overflow List, ListOrdered, @@ -26,6 +27,7 @@ interface ActionsPanelProps { readonly isOpen: boolean; readonly onClose: () => void; readonly noteId: string; + readonly noteTitle?: string; readonly onDuplicate?: () => void; readonly onDelete?: () => void; /** Hidden formatting groups from toolbar overflow */ @@ -51,6 +53,7 @@ export const ActionsPanel = memo(function ActionsPanel({ isOpen, onClose, noteId, + noteTitle, onDuplicate, onDelete, hiddenFormatting, @@ -102,6 +105,12 @@ export const ActionsPanel = memo(function ActionsPanel({ onClose(); }, [onDelete, onClose]); + // Open note in new window + const handleOpenInNewWindow = useCallback(async () => { + await window.readied.windows.openNote(noteId, noteTitle || 'Note'); + onClose(); + }, [noteId, noteTitle, onClose]); + // Check if any formatting is hidden const hasHiddenFormatting = hiddenFormatting && @@ -301,6 +310,13 @@ export const ActionsPanel = memo(function ActionsPanel({ Soon + +
{isOpen && ( -
+
{notebooks.map(notebook => ( + ); + })} +
+ )} +
) : ( +
+ +
+ {/* Performance Section */} +
+
+ + Performance +
+

Adjust visual effects for your hardware

+
+ + + +
+
+ + {/* Editor Section */} +
+
+ + Editor +
+

Default view mode when opening notes

+
+ + + +
+
+ + {/* License Section */} +
+
+ + License +
+
+
+ Status + + {getLicenseStatusText()} + +
+ {licenseState?.expiresAt && ( +
+ Expires + + {new Date(licenseState.expiresAt).toLocaleDateString()} + +
+ )} +
+
+ + {/* About Section */} +
+
+ + About +
+
+
+ Version + {window.readied.app.version()} +
+
+ App + Readied +
+
+
+
+
+
, + document.body + ); +} diff --git a/apps/desktop/src/renderer/components/sidebar/Sidebar.tsx b/apps/desktop/src/renderer/components/sidebar/Sidebar.tsx index d59e4d4..d86fa49 100644 --- a/apps/desktop/src/renderer/components/sidebar/Sidebar.tsx +++ b/apps/desktop/src/renderer/components/sidebar/Sidebar.tsx @@ -20,13 +20,17 @@ import { StatusFilters } from './StatusFilters'; import { SidebarFooter } from './SidebarFooter'; import { NotebookCreateModal } from './NotebookCreateModal'; +interface SidebarProps { + onOpenGraph?: () => void; +} + /** * Sidebar component - Pure render of NavigationState from Zustand * * Uses granular selectors to minimize re-renders. * Owns modal state for notebook creation. */ -export function Sidebar() { +export function Sidebar({ onOpenGraph }: SidebarProps) { // Modal state - lives HERE, not in NotebookList const [isCreateNotebookOpen, setIsCreateNotebookOpen] = useState(false); const [createParentId, setCreateParentId] = useState(null); @@ -85,7 +89,7 @@ export function Sidebar() { return (