diff --git a/src/App.tsx b/src/App.tsx index 6bbf132b..16314df8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -60,6 +60,8 @@ function AppContent() { reloadCurrentNote, currentNote, syncNotesFolder, + goBack, + goForward, } = useNotes(); const { interfaceZoom, setInterfaceZoom, reloadSettings } = useTheme(); const interfaceZoomRef = useRef(interfaceZoom); @@ -360,6 +362,20 @@ function AppContent() { return; } + // Cmd+[ - Navigate back + if ((e.metaKey || e.ctrlKey) && e.key === "[") { + e.preventDefault(); + goBack(); + return; + } + + // Cmd+] - Navigate forward + if ((e.metaKey || e.ctrlKey) && e.key === "]") { + e.preventDefault(); + goForward(); + return; + } + // Arrow keys for note navigation // Skip if folder tree view is handling its own navigation const isInFolderTree = !!(e.target as HTMLElement).closest("[data-folder-tree]"); @@ -441,6 +457,8 @@ function AppContent() { focusMode, view, setInterfaceZoom, + goBack, + goForward, ]); const handleClosePalette = useCallback(() => { diff --git a/src/components/editor/Editor.tsx b/src/components/editor/Editor.tsx index dcf07161..95c6956d 100644 --- a/src/components/editor/Editor.tsx +++ b/src/components/editor/Editor.tsx @@ -107,6 +107,8 @@ import { MarkdownIcon, MarkdownOffIcon, FolderPlusIcon, + ArrowLeftIcon, + ArrowRightIcon, } from "../icons"; function formatDateTime(timestamp: number): string { @@ -546,6 +548,10 @@ export function Editor({ const pinNote = notesCtx?.pinNote; const unpinNote = notesCtx?.unpinNote; const notes = notesCtx?.notes; + const canGoBack = notesCtx?.canGoBack ?? false; + const canGoForward = notesCtx?.canGoForward ?? false; + const goBack = notesCtx?.goBack; + const goForward = notesCtx?.goForward; const { textDirection } = useTheme(); const [isSaving, setIsSaving] = useState(false); // Force re-render when selection changes to update toolbar active states @@ -2183,6 +2189,26 @@ export function Editor({ )} + {goBack && ( + + + + )} + {goForward && ( + + + + )} {formatDateTime(currentNote.modified)} diff --git a/src/context/NotesContext.tsx b/src/context/NotesContext.tsx index 7ad2ea11..22fba13a 100644 --- a/src/context/NotesContext.tsx +++ b/src/context/NotesContext.tsx @@ -27,6 +27,8 @@ interface NotesDataContextValue { isSearching: boolean; hasExternalChanges: boolean; reloadVersion: number; + canGoBack: boolean; + canGoForward: boolean; } // Actions context: stable references, rarely causes re-renders @@ -51,11 +53,37 @@ interface NotesActionsContextValue { renameFolder: (oldPath: string, newName: string) => Promise; moveNote: (id: string, targetFolder: string) => Promise; moveFolder: (path: string, targetParent: string) => Promise; + goBack: () => Promise; + goForward: () => Promise; } const NotesDataContext = createContext(null); const NotesActionsContext = createContext(null); +type NavState = { history: string[]; index: number }; + +function navPush(prev: NavState, id: string): NavState { + if (prev.history[prev.index] === id) return prev; + const newHistory = [...prev.history.slice(0, prev.index + 1), id]; + return { history: newHistory, index: newHistory.length - 1 }; +} + +function navRemove(prev: NavState, shouldRemove: (id: string) => boolean): NavState { + if (!prev.history.some(shouldRemove)) return prev; + if (prev.history[prev.index] != null && shouldRemove(prev.history[prev.index])) { + return { history: [], index: -1 }; + } + const newHistory = prev.history.filter((h) => !shouldRemove(h)); + if (newHistory.length === 0) return { history: [], index: -1 }; + const keptBeforeOrAt = prev.history + .slice(0, prev.index + 1) + .filter((h) => !shouldRemove(h)).length; + return { + history: newHistory, + index: Math.max(0, Math.min(keptBeforeOrAt - 1, newHistory.length - 1)), + }; +} + export function NotesProvider({ children }: { children: ReactNode }) { const [notes, setNotes] = useState([]); const [selectedNoteId, setSelectedNoteId] = useState(null); @@ -86,6 +114,13 @@ export function NotesProvider({ children }: { children: ReactNode }) { const searchRequestIdRef = useRef(0); // Tracks the ID of a newly created note so Editor can focus its title. const pendingNewNoteIdRef = useRef(null); + // Navigation history: stack of note IDs with a current position index + const [nav, setNav] = useState<{ history: string[]; index: number }>({ + history: [], + index: -1, + }); + const navRef = useRef(nav); + navRef.current = nav; const refreshNotes = useCallback(async () => { if (!notesFolder) return; @@ -108,16 +143,15 @@ export function NotesProvider({ children }: { children: ReactNode }) { }, 300); }, [refreshNotes]); - const selectNote = useCallback(async (id: string) => { + // Internal: load and display a note without touching navigation history + const loadNoteById = useCallback(async (id: string) => { const requestId = ++selectRequestIdRef.current; try { if (pendingNewNoteIdRef.current !== id) { pendingNewNoteIdRef.current = null; } - // Set selected ID immediately for responsive UI setSelectedNoteId(id); setHasExternalChanges(false); - // Expand parent folders so the note is visible in the tree const lastSlash = id.lastIndexOf("/"); if (lastSlash > 0) { window.dispatchEvent( @@ -129,12 +163,37 @@ export function NotesProvider({ children }: { children: ReactNode }) { const note = await notesService.readNote(id); if (requestId !== selectRequestIdRef.current) return; setCurrentNote(note); + return true; } catch (err) { if (requestId !== selectRequestIdRef.current) return; setError(err instanceof Error ? err.message : "Failed to load note"); } }, []); + const selectNote = useCallback( + async (id: string) => { + const ok = await loadNoteById(id); + if (ok) setNav((prev) => navPush(prev, id)); + }, + [loadNoteById], + ); + + const goBack = useCallback(async () => { + const { history, index } = navRef.current; + if (index <= 0) return; + const newIndex = index - 1; + const ok = await loadNoteById(history[newIndex]); + if (ok) setNav((prev) => ({ ...prev, index: newIndex })); + }, [loadNoteById]); + + const goForward = useCallback(async () => { + const { history, index } = navRef.current; + if (index >= history.length - 1) return; + const newIndex = index + 1; + const ok = await loadNoteById(history[newIndex]); + if (ok) setNav((prev) => ({ ...prev, index: newIndex })); + }, [loadNoteById]); + const reloadCurrentNote = useCallback(async () => { if (!selectedNoteIdRef.current) return; try { @@ -165,6 +224,7 @@ export function NotesProvider({ children }: { children: ReactNode }) { await refreshNotes(); setCurrentNote(note); setSelectedNoteId(note.id); + setNav((prev) => navPush(prev, note.id)); // Clear search when creating a new note setSearchQuery(""); setSearchResults([]); @@ -215,6 +275,14 @@ export function NotesProvider({ children }: { children: ReactNode }) { }; await notesService.updateSettings(updatedSettings); } + + // Update navigation history to reflect the new ID + setNav((prev) => ({ + ...prev, + history: prev.history.map((h) => + h === savingNoteId ? updated.id : h, + ), + })); } // Clear external changes flag - if it was set by our own save, we want to ignore it @@ -275,6 +343,10 @@ export function NotesProvider({ children }: { children: ReactNode }) { } return prevId; }); + + // Remove deleted note from navigation history + setNav((prev) => navRemove(prev, (h) => h === id)); + await refreshNotes(); } catch (err) { setError(err instanceof Error ? err.message : "Failed to delete note"); @@ -293,6 +365,7 @@ export function NotesProvider({ children }: { children: ReactNode }) { await refreshNotes(); setCurrentNote(newNote); setSelectedNoteId(newNote.id); + setNav((prev) => navPush(prev, newNote.id)); setTimeout(() => { recentlySavedRef.current.delete(newNote.id); }, 1000); @@ -353,6 +426,7 @@ export function NotesProvider({ children }: { children: ReactNode }) { await refreshNotes(); setCurrentNote(note); setSelectedNoteId(note.id); + setNav((prev) => navPush(prev, note.id)); setSearchQuery(""); setSearchResults([]); setTimeout(() => { @@ -394,6 +468,11 @@ export function NotesProvider({ children }: { children: ReactNode }) { } return prevId; }); + + // Remove notes inside the deleted folder from navigation history + const deletedPrefix = path + "/"; + setNav((prev) => navRemove(prev, (h) => h.startsWith(deletedPrefix))); + await refreshNotes(); } catch (err) { setError( @@ -432,6 +511,16 @@ export function NotesProvider({ children }: { children: ReactNode }) { return prevId; }); + // Update navigation history IDs for notes inside the renamed folder + setNav((prev) => ({ + ...prev, + history: prev.history.map((h) => + h.startsWith(oldPrefix) + ? newPrefix + h.substring(oldPrefix.length) + : h, + ), + })); + await refreshNotes(); } catch (err) { setError( @@ -458,6 +547,13 @@ export function NotesProvider({ children }: { children: ReactNode }) { } return prevId; }); + + // Update navigation history to reflect the new ID + setNav((prev) => ({ + ...prev, + history: prev.history.map((h) => (h === id ? newId : h)), + })); + await refreshNotes(); } catch (err) { setError(err instanceof Error ? err.message : "Failed to move note"); @@ -495,6 +591,16 @@ export function NotesProvider({ children }: { children: ReactNode }) { return prevId; }); + // Update navigation history IDs for notes inside the moved folder + setNav((prev) => ({ + ...prev, + history: prev.history.map((h) => + h.startsWith(oldPrefix) + ? newPrefix + h.substring(oldPrefix.length) + : h, + ), + })); + await refreshNotes(); } catch (err) { setError(err instanceof Error ? err.message : "Failed to move folder"); @@ -507,6 +613,7 @@ export function NotesProvider({ children }: { children: ReactNode }) { try { await notesService.setNotesFolder(path); setNotesFolderState(path); + setNav({ history: [], index: -1 }); // Start file watcher after setting folder await notesService.startFileWatcher(); } catch (err) { @@ -523,6 +630,7 @@ export function NotesProvider({ children }: { children: ReactNode }) { setNotesFolderState(path); setSelectedNoteId(null); setCurrentNote(null); + setNav({ history: [], index: -1 }); const notesList = await notesService.listNotes(); setNotes(notesList); await notesService.startFileWatcher(); @@ -681,6 +789,9 @@ export function NotesProvider({ children }: { children: ReactNode }) { }, [notesFolder, refreshNotes]); // Memoize data context value to prevent unnecessary re-renders + const canGoBack = nav.index > 0; + const canGoForward = nav.index < nav.history.length - 1; + const dataValue = useMemo( () => ({ notes, @@ -694,6 +805,8 @@ export function NotesProvider({ children }: { children: ReactNode }) { isSearching, hasExternalChanges, reloadVersion, + canGoBack, + canGoForward, }), [ notes, @@ -707,6 +820,8 @@ export function NotesProvider({ children }: { children: ReactNode }) { isSearching, hasExternalChanges, reloadVersion, + canGoBack, + canGoForward, ] ); @@ -733,6 +848,8 @@ export function NotesProvider({ children }: { children: ReactNode }) { renameFolder: renameFolderAction, moveNote: moveNoteAction, moveFolder: moveFolderAction, + goBack, + goForward, }), [ selectNote, @@ -755,6 +872,8 @@ export function NotesProvider({ children }: { children: ReactNode }) { renameFolderAction, moveNoteAction, moveFolderAction, + goBack, + goForward, ] ); diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index a58c0ce9..c7ff5d27 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -23,6 +23,8 @@ export const shortcutCategories: ShortcutCategory[] = [ { keys: [mod, "="], description: "Zoom in" }, { keys: [mod, "-"], description: "Zoom out" }, { keys: [mod, "0"], description: "Reset zoom" }, + { keys: [mod, "["], description: "Navigate back" }, + { keys: [mod, "]"], description: "Navigate forward" }, ], }, {