Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<ViewState>("notes");
const [sidebarVisible, setSidebarVisible] = useState(true);
const [aiModalOpen, setAiModalOpen] = useState(false);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -499,6 +512,28 @@ function AppContent() {
/>
)}

{/* Note navigator overlay (used when no note is open and editor is unavailable) */}
{noteNavigatorOpen && (
<>
<div
className="fixed inset-0 bg-text/50 backdrop-blur-sm z-40 animate-fade-in"
onClick={() => setNoteNavigatorOpen(false)}
/>
<div className="fixed inset-0 z-50 flex items-start justify-center pt-24 pointer-events-none">
<div className="pointer-events-auto">
<NoteNavigator
notes={notes}
onSelect={(note) => {
setNoteNavigatorOpen(false);
selectNote(note.id);
}}
onCancel={() => setNoteNavigatorOpen(false)}
/>
</div>
</div>
</>
)}

<KeyboardShortcutsModal
open={shortcutsOpen}
onClose={() => setShortcutsOpen(false)}
Expand Down
114 changes: 102 additions & 12 deletions src/components/editor/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -584,6 +585,7 @@ export function Editor({
const saveTimeoutRef = useRef<number | null>(null);
const linkPopupRef = useRef<TippyInstance | null>(null);
const blockMathPopupRef = useRef<TippyInstance | null>(null);
const noteNavigatorPopupRef = useRef<TippyInstance | null>(null);
const isLoadingRef = useRef(false);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<TiptapEditor | null>(null);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -876,7 +890,7 @@ export function Editor({
},
});
},
[closeBlockMathPopup],
[closeBlockMathPopup, closeLinkPopup, closeNoteNavigatorPopup],
);

const handleAddBlockMath = useCallback(() => {
Expand Down Expand Up @@ -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
Expand All @@ -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 || "";
Expand Down Expand Up @@ -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]);
Comment on lines +1696 to +1760

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Popup anchor uses stale coordinates; won't reposition on scroll/resize.

coords is computed once at line 1713, outside getBoundingClientRect, so the returned rect is frozen at the moment handleGoToNote runs. Every other cursor-anchored popup in this file (handleAddLink, handleAddBlockMath, handleEditBlockMath) recomputes coordsAtPos/domAtPos inside the getBoundingClientRect callback so the popup stays anchored if the editor scrolls or the window resizes while it's open.

🐛 Proposed fix
     const currentNotes = notesRef.current ?? [];
-    const { from } = editor.state.selection;
-    const coords = editor.view.coordsAtPos(from);
     const virtualElement = {
-      getBoundingClientRect: () =>
-        ({
+      getBoundingClientRect: () => {
+        const { from } = editor.state.selection;
+        const coords = editor.view.coordsAtPos(from);
+        return {
           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,
+        } as DOMRect;
+      },
     };

Separately, the toggle-close branch (1704-1709) and the onSelect/onCancel callbacks (1733-1734, 1739-1740) manually re-implement closeNoteNavigatorPopup's destroy-and-null logic instead of calling it — worth consolidating for consistency, though low priority.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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]);
// 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 virtualElement = {
getBoundingClientRect: () => {
const { from } = editor.state.selection;
const coords = editor.view.coordsAtPos(from);
return {
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]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/editor/Editor.tsx` around lines 1696 - 1760, The note
navigator popup in handleGoToNote is anchoring to stale cursor coordinates
because coordsAtPos is computed once before tippy is created, so it won’t track
scroll or resize changes. Move the coordsAtPos lookup into the
getReferenceClientRect callback (matching handleAddLink, handleAddBlockMath, and
handleEditBlockMath) so the rect is recomputed whenever Tippy queries it. While
touching this area, consider reusing the existing closeNoteNavigatorPopup helper
in the toggle-close path and the onSelect/onCancel callbacks instead of
duplicating destroy-and-null logic.


// Image handler
const handleAddImage = useCallback(async () => {
Expand Down Expand Up @@ -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) => {
Expand Down
122 changes: 122 additions & 0 deletions src/components/editor/NoteNavigator.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(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 (
<div className="bg-bg border border-border rounded-lg shadow-lg overflow-hidden w-72">
<div className="border-b border-border">
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => 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"
/>
</div>
<div
ref={listRef}
className="p-1.5 max-h-60 overflow-y-auto flex flex-col gap-0.5"
>
{filtered.length === 0 ? (
<div className="text-sm text-text-muted px-3 py-2">
No matching notes
</div>
) : (
filtered.map((note, i) => (
<div
key={note.id}
data-index={i}
role="button"
tabIndex={-1}
onClick={() => 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",
)}
>
<div className="flex flex-col min-w-0">
<span className="text-sm leading-snug font-medium truncate">
{cleanTitle(note.title)}
</span>
{note.preview && (
<span className="text-xs text-text-muted truncate mt-0.5">
{note.preview}
</span>
)}
</div>
</div>
))
)}
</div>
</div>
);
};
10 changes: 10 additions & 0 deletions src/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
Comment on lines +205 to +207

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

lastClickedNoteId isn't cleared when selection is dropped.

When selectedNoteId becomes falsy, multiSelectedNoteIds is reset but lastClickedNoteId keeps referencing the previously selected note, leaving a stale shift-click anchor.

🛠️ Proposed fix
     } else {
       setMultiSelectedNoteIds(new Set());
+      setLastClickedNoteId(null);
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} else {
setMultiSelectedNoteIds(new Set());
}
} else {
setMultiSelectedNoteIds(new Set());
setLastClickedNoteId(null);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/layout/Sidebar.tsx` around lines 205 - 207, The
selection-reset branch in Sidebar.tsx clears multiSelectedNoteIds but leaves
lastClickedNoteId pointing at the old note, so update the same else path that
handles selectedNoteId becoming falsy to also clear lastClickedNoteId. Use the
Sidebar selection state logic around setMultiSelectedNoteIds and
lastClickedNoteId so the shift-click anchor is reset whenever selection is
dropped.

}, [selectedNoteId]);

const toggleSearch = useCallback(() => {
setSearchOpen((prev) => {
if (prev) {
Expand Down
1 change: 1 addition & 0 deletions src/lib/shortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down