From 602d58ba45a909acdbf539dad03915879a62f359 Mon Sep 17 00:00:00 2001 From: Rui Costa Date: Sun, 19 Oct 2025 16:41:23 -0400 Subject: [PATCH 1/2] fix: improve Monaco editor theming and empty trash UX Monaco Editor improvements: - Replace CSS overrides with native 'vs' and 'vs-dark' themes for proper syntax highlighting - Add global MonacoThemeContext to sync theme across all code blocks - Fix scroll blocking by setting alwaysConsumeMouseWheel to false - Remove theme icon from individual blocks since theme is now global Empty Trash improvements: - Remove confirmation modal for faster workflow - Add optimistic UI updates to clear trash items immediately - Add spinner to Empty Trash button with "Emptying..." state - Fix selected note persisting after trash is emptied - Improve auto-selection logic to prevent selecting trashed notes Performance: - Add React Query with optimized cache configuration - Implement code splitting with manual chunks for major dependencies - Defer WebSocket connection until after initial data load - Convert sequential pagination to parallel requests Editor stability: - Fix editor view access errors during mount/unmount cycles - Add proper null checks for editor.view.dom in all useEffects - Wrap editor operations in try-catch for transitional states --- package.json | 1 + pnpm-lock.yaml | 18 ++ src/App.tsx | 7 +- .../ExecutableCodeBlockNodeView.tsx | 89 +------ .../editor/hooks/useEditorEffects.ts | 180 ++++++++------ src/components/layout/MainLayout.tsx | 13 +- src/components/notes/NotesPanel/NotesList.tsx | 71 +----- src/contexts/MonacoThemeContext.tsx | 36 +++ src/hooks/useNotes.ts | 233 ++++++++++-------- src/main.tsx | 29 ++- vite.config.ts | 50 +++- 11 files changed, 405 insertions(+), 322 deletions(-) create mode 100644 src/contexts/MonacoThemeContext.tsx diff --git a/package.json b/package.json index 5a88386..43964e0 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.13", "@tailwindcss/vite": "^4.1.13", + "@tanstack/react-query": "^5.90.5", "@tiptap/core": "^3.4.4", "@tiptap/extension-code-block-lowlight": "^3.4.4", "@tiptap/extension-color": "^3.4.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 349fe7a..d264bb1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@tailwindcss/vite': specifier: ^4.1.13 version: 4.1.13(vite@7.1.6(@types/node@24.5.2)(jiti@2.5.1)(lightningcss@1.30.1)(yaml@2.8.1)) + '@tanstack/react-query': + specifier: ^5.90.5 + version: 5.90.5(react@19.1.1) '@tiptap/core': specifier: ^3.4.4 version: 3.4.4(@tiptap/pm@3.4.4) @@ -1381,6 +1384,14 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 + '@tanstack/query-core@5.90.5': + resolution: {integrity: sha512-wLamYp7FaDq6ZnNehypKI5fNvxHPfTYylE0m/ZpuuzJfJqhR5Pxg9gvGBHZx4n7J+V5Rg5mZxHHTlv25Zt5u+w==} + + '@tanstack/react-query@5.90.5': + resolution: {integrity: sha512-pN+8UWpxZkEJ/Rnnj2v2Sxpx1WFlaa9L6a4UO89p6tTQbeo+m0MS8oYDjbggrR8QcTyjKoYWKS3xJQGr3ExT8Q==} + peerDependencies: + react: ^18 || ^19 + '@tiptap/core@3.4.4': resolution: {integrity: sha512-lZb/mD1edPbny90X6ejnj6rS7KGa9WHrbhOL1O4OBbV0pRdM+/YtSi1XBqtViT2jUZUWnJCBQmmlgY/GFsRnoA==} peerDependencies: @@ -5914,6 +5925,13 @@ snapshots: tailwindcss: 4.1.13 vite: 7.1.6(@types/node@24.5.2)(jiti@2.5.1)(lightningcss@1.30.1)(yaml@2.8.1) + '@tanstack/query-core@5.90.5': {} + + '@tanstack/react-query@5.90.5(react@19.1.1)': + dependencies: + '@tanstack/query-core': 5.90.5 + react: 19.1.1 + '@tiptap/core@3.4.4(@tiptap/pm@3.4.4)': dependencies: '@tiptap/pm': 3.4.4 diff --git a/src/App.tsx b/src/App.tsx index ad91ba4..baec1bf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,7 @@ import { api } from '@/lib/api/api.ts'; import { fileService } from '@/services/fileService'; import { codeExecutionService } from '@/services/codeExecutionService'; import { clearUserEncryptionData } from '@/lib/encryption'; +import { MonacoThemeProvider } from '@/contexts/MonacoThemeContext'; import MainApp from '@/pages/MainApp'; function AppContent() { @@ -91,5 +92,9 @@ function AppContent() { } export default function App() { - return ; + return ( + + + + ); } diff --git a/src/components/editor/extensions/ExecutableCodeBlockNodeView.tsx b/src/components/editor/extensions/ExecutableCodeBlockNodeView.tsx index c3dcbb8..5bea0aa 100644 --- a/src/components/editor/extensions/ExecutableCodeBlockNodeView.tsx +++ b/src/components/editor/extensions/ExecutableCodeBlockNodeView.tsx @@ -17,55 +17,10 @@ import { import Editor from '@monaco-editor/react'; import * as monaco from 'monaco-editor'; import type { Editor as TiptapEditor } from '@tiptap/react'; +import { useMonacoTheme } from '@/contexts/MonacoThemeContext'; -// CSS styles for theme overrides -const monacoThemeStyles = ` - .monaco-light-override .monaco-editor, - .monaco-light-override .monaco-editor .margin, - .monaco-light-override .monaco-editor .monaco-editor-background { - background-color: #ffffff !important; - color: #000000 !important; - } - - .monaco-light-override .monaco-editor .view-lines .view-line { - color: #000000 !important; - } - - .monaco-light-override .monaco-editor .line-numbers { - color: #237893 !important; - } - - .monaco-light-override .monaco-editor .current-line { - background-color: #f0f0f0 !important; - } - - .monaco-light-override .monaco-editor .selected-text { - background-color: #316ac5 !important; - } - - .monaco-dark-override .monaco-editor, - .monaco-dark-override .monaco-editor .margin, - .monaco-dark-override .monaco-editor .monaco-editor-background { - background-color: #1e1e1e !important; - color: #d4d4d4 !important; - } - - .monaco-dark-override .monaco-editor .view-lines .view-line { - color: #d4d4d4 !important; - } - - .monaco-dark-override .monaco-editor .line-numbers { - color: #858585 !important; - } - - .monaco-dark-override .monaco-editor .current-line { - background-color: #2a2a2a !important; - } - - .monaco-dark-override .monaco-editor .selected-text { - background-color: #316ac5 !important; - } -`; +// Monaco Editor has built-in themes: "vs" (light) and "vs-dark" (dark) +// We'll use the theme prop to set each editor's theme individually interface ExecutableCodeBlockNodeViewProps { node: { @@ -90,23 +45,6 @@ export function ExecutableCodeBlockNodeView({ getPos, editor, }: ExecutableCodeBlockNodeViewProps) { - // Inject custom CSS styles for theme overrides - useEffect(() => { - const styleId = 'monaco-theme-override-styles'; - let styleElement = document.getElementById(styleId) as HTMLStyleElement; - - if (!styleElement) { - styleElement = document.createElement('style'); - styleElement.id = styleId; - styleElement.textContent = monacoThemeStyles; - document.head.appendChild(styleElement); - } - - return () => { - // Clean up when the last component unmounts - // Note: This is a simple approach. In production, you might want to reference count - }; - }, []); const [output, setOutput] = useState( node.attrs.output || null ); @@ -119,9 +57,7 @@ export function ExecutableCodeBlockNodeView({ const [code, setCode] = useState(node.textContent); const [editorHeight, setEditorHeight] = useState(300); const [isResizing, setIsResizing] = useState(false); - const [monacoThemeOverride, setMonacoThemeOverride] = useState< - 'light' | 'dark' - >('dark'); + const { theme: monacoTheme, toggleTheme } = useMonacoTheme(); const nodeRef = useRef(null); const updateTimeoutRef = useRef(undefined); const monacoRef = useRef(null); @@ -339,15 +275,11 @@ export function ExecutableCodeBlockNodeView({
{/* Code Content */} -
+
{ - if (!editor || !note || !editor.view) return; - - const currentContent = editor.getHTML(); - if (note.content !== currentContent) { - const editorHasFocus = editor.isFocused; - - if (!editorHasFocus) { - const { from, to } = editor.state.selection; - - editor.commands.setContent(note.content || '', { - emitUpdate: false, - parseOptions: { - preserveWhitespace: 'full', - }, - }); - lastContentRef.current = note.content || ''; - - const text = editor.state.doc.textContent; - updateCounts(text); - - try { - const docSize = editor.state.doc.content.size; - if (from <= docSize && to <= docSize) { - editor.commands.setTextSelection({ from, to }); + if (!editor || !note) return; + + try { + // Check if view and dom are available + if (!editor.view || !editor.view.dom) return; + + const currentContent = editor.getHTML(); + if (note.content !== currentContent) { + const editorHasFocus = editor.isFocused; + + if (!editorHasFocus) { + const { from, to } = editor.state.selection; + + editor.commands.setContent(note.content || '', { + emitUpdate: false, + parseOptions: { + preserveWhitespace: 'full', + }, + }); + lastContentRef.current = note.content || ''; + + const text = editor.state.doc.textContent; + updateCounts(text); + + try { + const docSize = editor.state.doc.content.size; + if (from <= docSize && to <= docSize) { + editor.commands.setTextSelection({ from, to }); + } + } catch { + // Ignore cursor restoration errors } - } catch { - // Ignore cursor restoration errors + } else { + lastContentRef.current = currentContent; } - } else { - lastContentRef.current = currentContent; } + } catch { + // Silently ignore errors when editor is in transitional state + // This can happen during mount/unmount cycles } }, [note, editor, updateCounts, lastContentRef]); // Initialize word count when editor is ready useEffect(() => { - if (!editor || !editor.view || !editor.view.dom) return; + if (!editor) return; + + try { + if (!editor.view || !editor.view.dom) return; - // Calculate initial word count - const text = editor.state.doc.textContent; - updateCounts(text); + // Calculate initial word count + const text = editor.state.doc.textContent; + updateCounts(text); + } catch { + // Silently ignore errors during initialization + } }, [editor, updateCounts]); // Track scroll percentage useEffect(() => { - if (!editor || !editor.view || !editor.view.dom) return; + if (!editor) return; - const updateScrollPercentage = () => { - const editorView = editor.view; - if (!editorView || !editorView.dom) return; + try { + if (!editor.view || !editor.view.dom) return; - const { scrollTop, scrollHeight, clientHeight } = editorView.dom; - const maxScroll = scrollHeight - clientHeight; + const updateScrollPercentage = () => { + try { + if (!editor || !editor.view || !editor.view.dom) return; - if (maxScroll <= 0) { - setScrollPercentage(0); - } else { - const percentage = Math.round((scrollTop / maxScroll) * 100); - setScrollPercentage(Math.min(100, Math.max(0, percentage))); - } - }; + const editorView = editor.view; + if (!editorView || !editorView.dom) return; - const editorElement = editor.view.dom; - editorElement.addEventListener('scroll', updateScrollPercentage); + const { scrollTop, scrollHeight, clientHeight } = editorView.dom; + const maxScroll = scrollHeight - clientHeight; - // Initial calculation - updateScrollPercentage(); + if (maxScroll <= 0) { + setScrollPercentage(0); + } else { + const percentage = Math.round((scrollTop / maxScroll) * 100); + setScrollPercentage(Math.min(100, Math.max(0, percentage))); + } + } catch { + // Silently ignore scroll errors + } + }; + + const editorElement = editor.view.dom; + editorElement.addEventListener('scroll', updateScrollPercentage); + + // Initial calculation + updateScrollPercentage(); - return () => { - editorElement.removeEventListener('scroll', updateScrollPercentage); - }; + return () => { + if (editorElement) { + editorElement.removeEventListener('scroll', updateScrollPercentage); + } + }; + } catch { + // Silently ignore setup errors + } }, [editor, setScrollPercentage]); // Store the original font size when editor is first created useEffect(() => { - if (!editor || !editor.view || !editor.view.dom || baseFontSize) return; + if (!editor || baseFontSize) return; + + try { + if (!editor.view || !editor.view.dom) return; - const editorElement = editor.view.dom as HTMLElement; - const computedStyle = window.getComputedStyle(editorElement); - const originalFontSize = computedStyle.fontSize; - setBaseFontSize(originalFontSize); + const editorElement = editor.view.dom as HTMLElement; + const computedStyle = window.getComputedStyle(editorElement); + const originalFontSize = computedStyle.fontSize; + setBaseFontSize(originalFontSize); + } catch { + // Silently ignore font size initialization errors + } }, [editor, baseFontSize, setBaseFontSize]); // Apply zoom level to editor useEffect(() => { - if (!editor || !editor.view || !editor.view.dom || !baseFontSize) return; - - const editorElement = editor.view.dom as HTMLElement; - - if (zoomLevel === 100) { - // At 100%, use the original font size - editorElement.style.fontSize = baseFontSize; - } else { - // Calculate the new font size based on the original - const baseSize = parseFloat(baseFontSize); - const scaleFactor = zoomLevel / 100; - const newSize = baseSize * scaleFactor; - editorElement.style.fontSize = `${newSize}px`; + if (!editor || !baseFontSize) return; + + try { + if (!editor.view || !editor.view.dom) return; + + const editorElement = editor.view.dom as HTMLElement; + + if (zoomLevel === 100) { + // At 100%, use the original font size + editorElement.style.fontSize = baseFontSize; + } else { + // Calculate the new font size based on the original + const baseSize = parseFloat(baseFontSize); + const scaleFactor = zoomLevel / 100; + const newSize = baseSize * scaleFactor; + editorElement.style.fontSize = `${newSize}px`; + } + } catch { + // Silently ignore zoom level application errors } }, [editor, zoomLevel, baseFontSize]); } diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index 43c17ed..0addb0b 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -84,15 +84,22 @@ export default function MainLayout() { const handleEmptyTrash = useCallback(async () => { try { - await api.emptyTrash(); - if (selectedNote && currentView === 'trash') { + // Clear selected note if it's in trash, regardless of current view + if (selectedNote?.deleted) { setSelectedNote(null); } + + // Optimistically remove all trashed notes from UI immediately + setNotes(prevNotes => prevNotes.filter(note => !note.deleted)); + + await api.emptyTrash(); await refetch(); } catch (error) { console.error('Failed to empty trash:', error); + // Refetch to restore correct state on error + await refetch(); } - }, [selectedNote, currentView, setSelectedNote, refetch]); + }, [selectedNote, setSelectedNote, setNotes, refetch]); const handleToggleFolderPanel = useCallback(() => { if (!isMobile) { diff --git a/src/components/notes/NotesPanel/NotesList.tsx b/src/components/notes/NotesPanel/NotesList.tsx index b0edd57..3c937bc 100644 --- a/src/components/notes/NotesPanel/NotesList.tsx +++ b/src/components/notes/NotesPanel/NotesList.tsx @@ -1,17 +1,7 @@ import { useState } from 'react'; -import { Trash2, AlertTriangle } from 'lucide-react'; +import { Trash2 } from 'lucide-react'; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog.tsx'; import { Button } from '@/components/ui/button.tsx'; import type { Note, Folder } from '@/types/note.ts'; @@ -38,16 +28,15 @@ export default function NotesList({ emptyMessage, folders, }: NotesListProps) { - const [showEmptyTrashDialog, setShowEmptyTrashDialog] = useState(false); const [isEmptyingTrash, setIsEmptyingTrash] = useState(false); const handleEmptyTrash = async () => { - if (!onEmptyTrash) return; + if (!onEmptyTrash || isEmptyingTrash) return; setIsEmptyingTrash(true); + try { await onEmptyTrash(); - setShowEmptyTrashDialog(false); } catch (error) { console.error('Failed to empty trash:', error); } finally { @@ -70,11 +59,18 @@ export default function NotesList({
@@ -98,47 +94,6 @@ export default function NotesList({ )} - - - - - - - Empty Trash - - - Are you sure you want to permanently delete all {notes.length}{' '} - item{notes.length !== 1 ? 's' : ''} in the trash? This action - cannot be undone. - - - - - Cancel - - void handleEmptyTrash()} - disabled={isEmptyingTrash} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - {isEmptyingTrash ? ( - <> -
- Emptying... - - ) : ( - <> - - Empty Trash - - )} - - - - ); } diff --git a/src/contexts/MonacoThemeContext.tsx b/src/contexts/MonacoThemeContext.tsx new file mode 100644 index 0000000..d895ae3 --- /dev/null +++ b/src/contexts/MonacoThemeContext.tsx @@ -0,0 +1,36 @@ +import { createContext, useContext, useState, type ReactNode } from 'react'; + +type MonacoTheme = 'light' | 'dark'; + +interface MonacoThemeContextType { + theme: MonacoTheme; + toggleTheme: () => void; + setTheme: (theme: MonacoTheme) => void; +} + +const MonacoThemeContext = createContext( + undefined +); + +export function MonacoThemeProvider({ children }: { children: ReactNode }) { + const [theme, setTheme] = useState('dark'); + + const toggleTheme = () => { + setTheme((prev) => (prev === 'light' ? 'dark' : 'light')); + }; + + return ( + + {children} + + ); +} + +// eslint-disable-next-line react-refresh/only-export-components +export function useMonacoTheme() { + const context = useContext(MonacoThemeContext); + if (context === undefined) { + throw new Error('useMonacoTheme must be used within a MonacoThemeProvider'); + } + return context; +} diff --git a/src/hooks/useNotes.ts b/src/hooks/useNotes.ts index bd5a40c..64ac257 100644 --- a/src/hooks/useNotes.ts +++ b/src/hooks/useNotes.ts @@ -29,20 +29,20 @@ const convertApiFolder = (apiFolder: ApiFolder): Folder => ({ // Helper function for safe date conversion const safeConvertDates = (item: Note | Folder): void => { - if (item.createdAt && !(item.createdAt instanceof Date)) { - item.createdAt = new Date(item.createdAt as unknown as string); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const itemAny = item as any; + + if (itemAny.createdAt && typeof itemAny.createdAt === 'string') { + item.createdAt = new Date(itemAny.createdAt); } // Note-specific properties - if ( - 'updatedAt' in item && - item.updatedAt && - !(item.updatedAt instanceof Date) - ) { - item.updatedAt = new Date(item.updatedAt as unknown as string); + if ('updatedAt' in item && itemAny.updatedAt && typeof itemAny.updatedAt === 'string') { + (item as Note).updatedAt = new Date(itemAny.updatedAt); } - if ('hiddenAt' in item && item.hiddenAt && !(item.hiddenAt instanceof Date)) { - item.hiddenAt = new Date(item.hiddenAt as unknown as string); + + if ('hiddenAt' in item && itemAny.hiddenAt && typeof itemAny.hiddenAt === 'string') { + (item as Note).hiddenAt = new Date(itemAny.hiddenAt); } }; @@ -98,44 +98,36 @@ export function useNotes() { // Define helper functions early for the sync handlers const fetchAllFolders = useCallback(async (): Promise => { - let allFolders: Folder[] = []; - let page = 1; - let hasMorePages = true; - - while (hasMorePages) { - const foldersResponse = await retryWithBackoff(() => - api.getFolders({ page, limit: 50 }) - ); - const convertedFolders = foldersResponse.folders.map(convertApiFolder); - allFolders = [...allFolders, ...convertedFolders]; - - // Check if we have more pages using new pagination structure - if (foldersResponse.pagination) { - hasMorePages = page < foldersResponse.pagination.pages; - } else { - // Fallback - assume more pages if we got a full page - hasMorePages = convertedFolders.length >= 50; - } - - // Also check if we received fewer folders than the limit, which means we're on the last page - if (convertedFolders.length < 50) { - hasMorePages = false; - } + // Fetch first page to determine total pages + const firstPageResponse = await retryWithBackoff(() => + api.getFolders({ page: 1, limit: 50 }) + ); + const firstPageFolders = firstPageResponse.folders.map(convertApiFolder); - page++; + // Determine total pages + const totalPages = firstPageResponse.pagination?.pages ?? 1; - // Safety break to prevent infinite loops - if (page > 50) { - hasMorePages = false; - } + // If only one page, return immediately + if (totalPages <= 1) { + return firstPageFolders; + } - // Add small delay between requests to avoid rate limiting - if (hasMorePages) { - await new Promise((resolve) => setTimeout(resolve, 100)); - } + // Fetch remaining pages in parallel + const remainingPagePromises = []; + for (let page = 2; page <= Math.min(totalPages, 50); page++) { + remainingPagePromises.push( + retryWithBackoff(() => api.getFolders({ page, limit: 50 })) + .then(response => response.folders.map(convertApiFolder)) + ); } - return allFolders; + const remainingPages = await Promise.all(remainingPagePromises); + + // Combine all pages + return [ + ...firstPageFolders, + ...remainingPages.flat() + ]; }, []); const refetchFolders = useCallback(async () => { @@ -159,8 +151,9 @@ export function useNotes() { }); // WebSocket integration for real-time sync + // Defer connection until after initial data load for better performance const webSocket = useWebSocket({ - autoConnect: true, + autoConnect: false, ...syncHandlers, onError: useCallback((error: Error | { message?: string }) => { // Only show connection errors to users if they persist @@ -223,44 +216,36 @@ export function useNotes() { }, [clerkUser]); const fetchAllNotes = useCallback(async (): Promise => { - let allNotes: Note[] = []; - let page = 1; - let hasMorePages = true; - - while (hasMorePages) { - const notesResponse = await retryWithBackoff(() => - api.getNotes({ page, limit: 50 }) - ); - const convertedNotes = notesResponse.notes.map(convertApiNote); - allNotes = [...allNotes, ...convertedNotes]; - - // Check if we have more pages using new pagination structure - if (notesResponse.pagination) { - hasMorePages = page < notesResponse.pagination.pages; - } else { - // Fallback - assume more pages if we got a full page - hasMorePages = convertedNotes.length >= 50; - } - - // Also check if we received fewer notes than the limit, which means we're on the last page - if (convertedNotes.length < 50) { - hasMorePages = false; - } + // Fetch first page to determine total pages + const firstPageResponse = await retryWithBackoff(() => + api.getNotes({ page: 1, limit: 50 }) + ); + const firstPageNotes = firstPageResponse.notes.map(convertApiNote); - page++; + // Determine total pages + const totalPages = firstPageResponse.pagination?.pages ?? 1; - // Safety break to prevent infinite loops - if (page > 50) { - hasMorePages = false; - } + // If only one page, return immediately + if (totalPages <= 1) { + return firstPageNotes; + } - // Add small delay between requests to avoid rate limiting - if (hasMorePages) { - await new Promise((resolve) => setTimeout(resolve, 100)); - } + // Fetch remaining pages in parallel + const remainingPagePromises = []; + for (let page = 2; page <= Math.min(totalPages, 50); page++) { + remainingPagePromises.push( + retryWithBackoff(() => api.getNotes({ page, limit: 50 })) + .then(response => response.notes.map(convertApiNote)) + ); } - return allNotes; + const remainingPages = await Promise.all(remainingPagePromises); + + // Combine all pages + return [ + ...firstPageNotes, + ...remainingPages.flat() + ]; }, []); const loadData = useCallback(async () => { @@ -278,33 +263,20 @@ export function useNotes() { // Create a folder map for quick lookup const folderMap = new Map(allFolders.map((f) => [f.id, f])); - const notesWithAttachmentsAndFolders = await Promise.all( - allNotes.map(async (note) => { - try { - const attachments = await fileService.getAttachments(note.id); - // Embed folder data if note has a folderId - const folder = note.folderId - ? folderMap.get(note.folderId) - : undefined; - return { ...note, attachments, folder }; - } catch (error) { - secureLogger.warn('Failed to load attachments for note', { - noteId: '[REDACTED]', - error, - }); - const folder = note.folderId - ? folderMap.get(note.folderId) - : undefined; - return { ...note, attachments: [], folder }; - } - }) - ); + // Defer attachment loading - only add folder data initially + // Attachments will be loaded on-demand when a note is selected + const notesWithFolders = allNotes.map((note) => { + const folder = note.folderId + ? folderMap.get(note.folderId) + : undefined; + return { ...note, attachments: [], folder }; + }); - setNotes(notesWithAttachmentsAndFolders); + setNotes(notesWithFolders); setSelectedNote((prev) => { - if (prev === null && notesWithAttachmentsAndFolders.length > 0) { - return notesWithAttachmentsAndFolders[0]; + if (prev === null && notesWithFolders.length > 0) { + return notesWithFolders[0]; } return prev; }); @@ -347,6 +319,17 @@ export function useNotes() { } }, [encryptionReady, clerkUser, loadData]); + // Connect WebSocket after initial data load for better performance + useEffect(() => { + if (!loading && encryptionReady && notes.length > 0 && !webSocket.isConnected) { + // Delay connection slightly to ensure UI has rendered + const timer = setTimeout(() => { + webSocket.connect(); + }, 100); + return () => clearTimeout(timer); + } + }, [loading, encryptionReady, notes.length, webSocket]); + // Notes filtering const { filteredNotes, @@ -409,9 +392,53 @@ export function useNotes() { // Auto-select a note when selectedNote becomes null and there are available notes useEffect(() => { if (!selectedNote && filteredNotes.length > 0) { - setSelectedNote(filteredNotes[0]); + // Don't auto-select notes that are in trash unless explicitly viewing trash + const firstNonTrashedNote = filteredNotes.find(note => !note.deleted); + if (firstNonTrashedNote) { + setSelectedNote(firstNonTrashedNote); + } else if (currentView === 'trash') { + // Only select trash notes if we're explicitly viewing trash + setSelectedNote(filteredNotes[0]); + } } - }, [selectedNote, filteredNotes]); + }, [selectedNote, filteredNotes, currentView]); + + // Load attachments on-demand when a note is selected + useEffect(() => { + if (!selectedNote?.id) return; + + // Skip if attachments already loaded + if (selectedNote.attachments && selectedNote.attachments.length > 0) return; + + const loadAttachments = async () => { + try { + const attachments = await fileService.getAttachments(selectedNote.id); + + // Update the note in the notes list + setNotes((prev) => + prev.map((note) => + note.id === selectedNote.id + ? { ...note, attachments } + : note + ) + ); + + // Update selected note + setSelectedNote((prev) => + prev?.id === selectedNote.id + ? { ...prev, attachments } + : prev + ); + } catch (error) { + secureLogger.warn('Failed to load attachments for selected note', { + noteId: '[REDACTED]', + error, + }); + } + }; + + void loadAttachments(); + }, [selectedNote?.id, selectedNote?.attachments, setNotes, setSelectedNote]); const createFolder = async ( name: string, diff --git a/src/main.tsx b/src/main.tsx index d0c5528..d3ad77e 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,6 +1,7 @@ import { StrictMode } from 'react'; import { ClerkProvider } from '@clerk/clerk-react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { createHead, UnheadProvider } from '@unhead/react/client'; import { createRoot } from 'react-dom/client'; @@ -27,16 +28,30 @@ if (!rootElement) { const head = createHead(); +// Configure React Query client with optimized defaults +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 5, // Data stays fresh for 5 minutes + gcTime: 1000 * 60 * 30, // Cache kept for 30 minutes + refetchOnWindowFocus: false, // Don't refetch on window focus for better UX + retry: 1, // Retry failed requests once + }, + }, +}); + createRoot(rootElement).render( - - - - - - - + + + + + + + + + ); diff --git a/vite.config.ts b/vite.config.ts index 4c2954c..724f678 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,7 +6,7 @@ import { defineConfig, loadEnv } from 'vite'; // https://vite.dev/config/ export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), ''); - + return { plugins: [react(), tailwindcss()], resolve: { @@ -14,6 +14,54 @@ export default defineConfig(({ mode }) => { '@': path.resolve(__dirname, './src'), }, }, + build: { + rollupOptions: { + output: { + manualChunks: { + // Monaco Editor - large code editor bundle + 'monaco-editor': ['monaco-editor', '@monaco-editor/react'], + + // Tiptap core - rich text editor core + 'tiptap-core': ['@tiptap/react', '@tiptap/core', '@tiptap/starter-kit', '@tiptap/pm'], + + // Tiptap extensions - split from core for better caching + 'tiptap-extensions': [ + '@tiptap/extension-code-block-lowlight', + '@tiptap/extension-color', + '@tiptap/extension-dropcursor', + '@tiptap/extension-highlight', + '@tiptap/extension-horizontal-rule', + '@tiptap/extension-image', + '@tiptap/extension-link', + '@tiptap/extension-task-item', + '@tiptap/extension-task-list', + '@tiptap/extension-text-style', + '@tiptap/extension-underline', + ], + + // Clerk authentication + 'clerk': ['@clerk/clerk-react'], + + // Radix UI components + 'radix-ui': [ + '@radix-ui/react-alert-dialog', + '@radix-ui/react-dialog', + '@radix-ui/react-dropdown-menu', + '@radix-ui/react-label', + '@radix-ui/react-scroll-area', + '@radix-ui/react-slot', + '@radix-ui/react-tabs', + ], + + // React Query + 'react-query': ['@tanstack/react-query'], + + // Syntax highlighting + 'syntax-highlight': ['highlight.js', 'lowlight'], + }, + }, + }, + }, server: { proxy: { '/api': { From b5714a2650fabd75e3080350ef57825af38ced9a Mon Sep 17 00:00:00 2001 From: Rui Costa Date: Sun, 19 Oct 2025 16:46:48 -0400 Subject: [PATCH 2/2] fix: improve Monaco editor theming, empty trash UX, app performance, and editor stability --- vite.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vite.config.ts b/vite.config.ts index 724f678..a6028dd 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -22,7 +22,7 @@ export default defineConfig(({ mode }) => { 'monaco-editor': ['monaco-editor', '@monaco-editor/react'], // Tiptap core - rich text editor core - 'tiptap-core': ['@tiptap/react', '@tiptap/core', '@tiptap/starter-kit', '@tiptap/pm'], + 'tiptap-core': ['@tiptap/react', '@tiptap/core', '@tiptap/starter-kit'], // Tiptap extensions - split from core for better caching 'tiptap-extensions': [