From aa6b72946526837a1d89365d63e588beae3747f6 Mon Sep 17 00:00:00 2001 From: Dale Lane Date: Sun, 28 Jun 2026 11:08:38 +0100 Subject: [PATCH] feat: add Cmd+G shortcut to navigate to a note Adds a global Cmd+G (Ctrl+G on Windows/Linux) shortcut that opens a searchable popup to jump to any note without modifying the current page. When a note is open the popup appears inline near the cursor; when no note is open it shows as a centered overlay. This is based on the link editor (Cmd+K) that is used to insert a link to another wiki page - copying the same basic control and behaviour but making it available as a keyboard-shortcut friendly navigation interaction. I've also updated the Sidebar so that the highlighting for the currently-selected page reflects the right page after a Cmd+G. This was arguably a separate issue as this feels like it was a bug affecting clicking on Wikilinks on pages, too. Signed-off-by: Dale Lane --- src/App.tsx | 35 +++++++ src/components/editor/Editor.tsx | 114 +++++++++++++++++++--- src/components/editor/NoteNavigator.tsx | 122 ++++++++++++++++++++++++ src/components/layout/Sidebar.tsx | 10 ++ src/lib/shortcuts.ts | 1 + 5 files changed, 270 insertions(+), 12 deletions(-) create mode 100644 src/components/editor/NoteNavigator.tsx diff --git a/src/App.tsx b/src/App.tsx index 6bbf132b..ad1548cf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ import { Editor } from "./components/editor/Editor"; import type { Editor as TiptapEditor } from "@tiptap/react"; import { FolderPicker } from "./components/layout/FolderPicker"; import { CommandPalette } from "./components/command-palette/CommandPalette"; +import { NoteNavigator } from "./components/editor/NoteNavigator"; import { SettingsPage } from "./components/settings"; import { SpinnerIcon, @@ -67,6 +68,7 @@ function AppContent() { const currentNoteRef = useRef(currentNote); currentNoteRef.current = currentNote; const [paletteOpen, setPaletteOpen] = useState(false); + const [noteNavigatorOpen, setNoteNavigatorOpen] = useState(false); const [view, setView] = useState("notes"); const [sidebarVisible, setSidebarVisible] = useState(true); const [aiModalOpen, setAiModalOpen] = useState(false); @@ -285,6 +287,17 @@ function AppContent() { return; } + // Cmd+G - Go to note + if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key === "g") { + e.preventDefault(); + if (currentNoteRef.current) { + window.dispatchEvent(new CustomEvent("navigate-to-note")); + } else { + setNoteNavigatorOpen(true); + } + return; + } + // Cmd+Shift+P - Print if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "p") { e.preventDefault(); @@ -499,6 +512,28 @@ function AppContent() { /> )} + {/* Note navigator overlay (used when no note is open and editor is unavailable) */} + {noteNavigatorOpen && ( + <> +
setNoteNavigatorOpen(false)} + /> +
+
+ { + setNoteNavigatorOpen(false); + selectNote(note.id); + }} + onCancel={() => setNoteNavigatorOpen(false)} + /> +
+
+ + )} + setShortcutsOpen(false)} diff --git a/src/components/editor/Editor.tsx b/src/components/editor/Editor.tsx index dcf07161..4097dd53 100644 --- a/src/components/editor/Editor.tsx +++ b/src/components/editor/Editor.tsx @@ -63,6 +63,7 @@ import { useTheme } from "../../context/ThemeContext"; import { Frontmatter } from "./Frontmatter"; import { BlockMathEditor } from "./BlockMathEditor"; import { LinkEditor } from "./LinkEditor"; +import { NoteNavigator } from "./NoteNavigator"; import { SearchToolbar } from "./SearchToolbar"; import { SlashCommand } from "./SlashCommand"; import { Wikilink, type WikilinkStorage } from "./Wikilink"; @@ -74,7 +75,7 @@ import { plainTextFromMarkdown } from "../../lib/plainText"; import { Button, IconButton, ToolbarButton, Tooltip } from "../ui"; import * as notesService from "../../services/notes"; import { downloadPdf, downloadMarkdown } from "../../services/pdf"; -import type { Settings } from "../../types/note"; +import type { Settings, NoteMetadata } from "../../types/note"; import { BoldIcon, ItalicIcon, @@ -584,6 +585,7 @@ export function Editor({ const saveTimeoutRef = useRef(null); const linkPopupRef = useRef(null); const blockMathPopupRef = useRef(null); + const noteNavigatorPopupRef = useRef(null); const isLoadingRef = useRef(false); const scrollContainerRef = useRef(null); const editorRef = useRef(null); @@ -786,15 +788,27 @@ export function Editor({ } }, []); + const closeLinkPopup = useCallback(() => { + if (linkPopupRef.current) { + linkPopupRef.current.destroy(); + linkPopupRef.current = null; + } + }, []); + + const closeNoteNavigatorPopup = useCallback(() => { + if (noteNavigatorPopupRef.current) { + noteNavigatorPopupRef.current.destroy(); + noteNavigatorPopupRef.current = null; + } + }, []); + const handleEditBlockMath = useCallback( (pos: number) => { const currentEditor = editorRef.current; if (!currentEditor) return; - if (linkPopupRef.current) { - linkPopupRef.current.destroy(); - linkPopupRef.current = null; - } + closeLinkPopup(); + closeNoteNavigatorPopup(); closeBlockMathPopup(); const node = currentEditor.state.doc.nodeAt(pos); @@ -876,7 +890,7 @@ export function Editor({ }, }); }, - [closeBlockMathPopup], + [closeBlockMathPopup, closeLinkPopup, closeNoteNavigatorPopup], ); const handleAddBlockMath = useCallback(() => { @@ -1547,6 +1561,9 @@ export function Editor({ if (blockMathPopupRef.current) { blockMathPopupRef.current.destroy(); } + if (noteNavigatorPopupRef.current) { + noteNavigatorPopupRef.current.destroy(); + } }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Only run cleanup on unmount, not when saveNote changes @@ -1555,14 +1572,12 @@ export function Editor({ const handleAddLink = useCallback(() => { if (!editor) return; - // Close block math popup if open (popups are mutually exclusive) + // Close other popups if open (popups are mutually exclusive) closeBlockMathPopup(); + closeNoteNavigatorPopup(); // Destroy existing popup if any - if (linkPopupRef.current) { - linkPopupRef.current.destroy(); - linkPopupRef.current = null; - } + closeLinkPopup(); // Get existing link URL if cursor is on a link const existingUrl = editor.getAttributes("link").href || ""; @@ -1676,7 +1691,73 @@ export function Editor({ component.destroy(); }, }); - }, [editor, closeBlockMathPopup]); + }, [editor, closeBlockMathPopup, closeLinkPopup, closeNoteNavigatorPopup]); + + // Navigate to note handler - show inline popup at cursor position + const handleGoToNote = useCallback(() => { + if (!editor) return; + + closeBlockMathPopup(); + closeLinkPopup(); + + // Toggle: second Cmd+G closes the popup + if (noteNavigatorPopupRef.current) { + noteNavigatorPopupRef.current.destroy(); + noteNavigatorPopupRef.current = null; + editor.commands.focus(); + return; + } + + const currentNotes = notesRef.current ?? []; + const { from } = editor.state.selection; + const coords = editor.view.coordsAtPos(from); + const virtualElement = { + getBoundingClientRect: () => + ({ + width: 0, + height: 0, + top: coords.top, + left: coords.left, + right: coords.right, + bottom: coords.bottom, + x: coords.left, + y: coords.top, + toJSON: () => ({}), + }) as DOMRect, + }; + + const component = new ReactRenderer(NoteNavigator, { + props: { + notes: currentNotes, + onSelect: (note: NoteMetadata) => { + noteNavigatorPopupRef.current?.destroy(); + noteNavigatorPopupRef.current = null; + notesCtxRef.current?.selectNote(note.id); + }, + onCancel: () => { + editor.commands.focus(); + noteNavigatorPopupRef.current?.destroy(); + noteNavigatorPopupRef.current = null; + }, + }, + editor, + }); + + noteNavigatorPopupRef.current = tippy(document.body, { + getReferenceClientRect: () => + virtualElement.getBoundingClientRect() as DOMRect, + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: "manual", + placement: "bottom-start", + offset: [0, 8], + onDestroy: () => { + component.destroy(); + }, + }); + }, [editor, closeBlockMathPopup, closeLinkPopup]); // Image handler const handleAddImage = useCallback(async () => { @@ -1744,6 +1825,15 @@ export function Editor({ return () => document.removeEventListener("keydown", handleKeyDown); }, [handleAddLink, editor]); + // Cmd+G to navigate to a note (dispatched globally from App.tsx) + useEffect(() => { + const handler = () => { + if (editor) handleGoToNote(); + }; + window.addEventListener("navigate-to-note", handler); + return () => window.removeEventListener("navigate-to-note", handler); + }, [handleGoToNote, editor]); + // Keyboard shortcut for Cmd+Shift+C to open copy menu useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { diff --git a/src/components/editor/NoteNavigator.tsx b/src/components/editor/NoteNavigator.tsx new file mode 100644 index 00000000..49358341 --- /dev/null +++ b/src/components/editor/NoteNavigator.tsx @@ -0,0 +1,122 @@ +import { useState, useEffect, useMemo, useRef } from "react"; +import { cleanTitle, cn } from "../../lib/utils"; +import type { NoteMetadata } from "../../types/note"; + +interface NoteNavigatorProps { + notes: NoteMetadata[]; + onSelect: (note: NoteMetadata) => void; + onCancel: () => void; +} + +export const NoteNavigator = ({ + notes, + onSelect, + onCancel, +}: NoteNavigatorProps) => { + const [query, setQuery] = useState(""); + const [selectedIndex, setSelectedIndex] = useState(0); + const inputRef = useRef(null); + const listRef = useRef(null); + + const filtered = useMemo( + () => + notes + .filter((n) => + cleanTitle(n.title).toLowerCase().includes(query.toLowerCase()), + ) + .slice(0, 10), + [notes, query], + ); + + useEffect(() => { + requestAnimationFrame(() => inputRef.current?.focus()); + }, []); + + useEffect(() => { + setSelectedIndex(0); + }, [query]); + + useEffect(() => { + const el = listRef.current?.querySelector( + `[data-index="${selectedIndex}"]`, + ); + el?.scrollIntoView({ block: "nearest" }); + }, [selectedIndex]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "ArrowDown") { + e.preventDefault(); + e.stopPropagation(); + setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + e.stopPropagation(); + setSelectedIndex((i) => Math.max(i - 1, 0)); + } else if (e.key === "Enter") { + e.preventDefault(); + e.stopPropagation(); + if (filtered[selectedIndex]) onSelect(filtered[selectedIndex]); + } else if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + onCancel(); + } + }; + + return ( +
+
+ setQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Go to note..." + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + spellCheck={false} + className="w-full px-3 py-2 text-sm bg-transparent outline-none text-text placeholder:text-text-muted" + /> +
+
+ {filtered.length === 0 ? ( +
+ No matching notes +
+ ) : ( + filtered.map((note, i) => ( +
onSelect(note)} + className={cn( + "w-full text-left p-2 rounded-md transition-colors cursor-pointer", + selectedIndex === i + ? "bg-bg-muted text-text" + : "text-text hover:bg-bg-muted", + )} + > +
+ + {cleanTitle(note.title)} + + {note.preview && ( + + {note.preview} + + )} +
+
+ )) + )} +
+
+ ); +}; diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index d2ad1884..a7df92a9 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -197,6 +197,16 @@ export function Sidebar({ onOpenSettings }: SidebarProps) { [search], ); + // Sync highlighting / selection when note changes + useEffect(() => { + if (selectedNoteId) { + setMultiSelectedNoteIds(new Set([selectedNoteId])); + setLastClickedNoteId(selectedNoteId); + } else { + setMultiSelectedNoteIds(new Set()); + } + }, [selectedNoteId]); + const toggleSearch = useCallback(() => { setSearchOpen((prev) => { if (prev) { diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index a58c0ce9..5c798b29 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -15,6 +15,7 @@ export const shortcutCategories: ShortcutCategory[] = [ title: "Navigation", shortcuts: [ { keys: [mod, "P"], description: "Command palette" }, + { keys: [mod, "G"], description: "Go to note" }, { keys: [mod, shift, "F"], description: "Search notes" }, { keys: [mod, "\\"], description: "Toggle sidebar" }, { keys: [mod, ","], description: "Settings" },