Skip to content
Merged
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
19 changes: 19 additions & 0 deletions src/components/CommandPalette.css
Original file line number Diff line number Diff line change
Expand Up @@ -115,4 +115,23 @@
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
color: #a1a1aa;
flex-shrink: 0;
}

.command-palette-content li .truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}

.command-palette-content li.command-palette-empty {
cursor: default;
color: #71717a;
justify-content: center;
}

.command-palette-content li.command-palette-empty:hover {
background-color: transparent;
}
131 changes: 77 additions & 54 deletions src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
@@ -1,97 +1,120 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import useUiStore from '../store/UiStore';
import { useNotesStore } from '../store/notesStore';
import { commandPaletteResults, cycleIndex } from '../lib/note-utils';
import './CommandPalette.css';

export const CommandPalette = () => {
const { searchQuery, setSearchQuery, searchResults, isCommandPaletteOpen, closeCommandPalette } = useUiStore();
const { notes, selectNote } = useNotesStore();
const [selectedIndex, setSelectedIndex] = useState(0);

useEffect(() => {
if (isCommandPaletteOpen) {
// Focus the input when palette opens (though autoFocus handles init)
}
}, [isCommandPaletteOpen]);
const displayResults = useMemo(
() => commandPaletteResults(searchQuery, notes, searchResults),
[searchQuery, notes, searchResults],
);

useEffect(() => {
setSelectedIndex(0);
}, [searchResults]);
}, [displayResults]);

useEffect(() => {
if (!isCommandPaletteOpen) return;

const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
closeCommandPalette();
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [isCommandPaletteOpen, closeCommandPalette]);

if (!isCommandPaletteOpen) {
return null;
}

const focusEditor = () => {
setTimeout(() => {
const editorElement = document.querySelector('.bn-editor') as HTMLElement | null;
if (editorElement) {
editorElement.focus();
return;
}
const contentEditable = document.querySelector('[contenteditable="true"]') as HTMLElement | null;
contentEditable?.focus();
}, 100);
};

const openNoteAt = (index: number) => {
const note = displayResults[index];
if (!note) return;
selectNote(note);
closeCommandPalette();
focusEditor();
};

const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((prevIndex) => (prevIndex + 1) % searchResults.length);
setSelectedIndex((prev) => cycleIndex(prev, 1, displayResults.length));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((prevIndex) => (prevIndex - 1 + searchResults.length) % searchResults.length);
setSelectedIndex((prev) => cycleIndex(prev, -1, displayResults.length));
} else if (e.key === 'Enter') {
e.preventDefault();
if (searchResults[selectedIndex]) {
selectNote(searchResults[selectedIndex]);
closeCommandPalette();

// Focus editor after a short delay
setTimeout(() => {
const editorElement = document.querySelector('.bn-editor') as HTMLElement;
if (editorElement) {
editorElement.focus();
} else {
const contentEditable = document.querySelector('[contenteditable="true"]') as HTMLElement;
if (contentEditable) {
contentEditable.focus();
}
}
}, 100);
}
openNoteAt(selectedIndex);
} else if (e.key === 'Escape') {
e.preventDefault();
console.log("Escape key pressed in input");
closeCommandPalette();
}
};

return (
<div className="command-palette-overlay" onClick={closeCommandPalette}>
<div className="command-palette-container" onClick={(e) => e.stopPropagation()}>
<div className="command-palette-overlay" onClick={closeCommandPalette} role="presentation">
<div
className="command-palette-container"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label="Search notes"
>
<div className="command-palette-content">
<input
type="text"
placeholder="Search notes (Esc to close)"
placeholder={
displayResults.length && !searchQuery.trim()
? 'Search notes or pick a recent note…'
: 'Search notes (Esc to close)'
}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value, notes)}
onKeyDown={handleKeyDown}
autoFocus
aria-label="Search notes"
/>
<ul>
{searchResults.map((note, index) => (
<li
key={note.id}
className={index === selectedIndex ? 'selected' : ''}
onClick={() => {
selectNote(note);
closeCommandPalette();

setTimeout(() => {
const editorElement = document.querySelector('.bn-editor') as HTMLElement;
if (editorElement) {
editorElement.focus();
} else {
const contentEditable = document.querySelector('[contenteditable="true"]') as HTMLElement;
if (contentEditable) {
contentEditable.focus();
}
}
}, 100);
}}
>
{note.title}
<ul className="command-palette-results" role="listbox">
{displayResults.length === 0 ? (
<li className="command-palette-empty" role="option" aria-disabled="true">
{searchQuery.trim() ? 'No matching notes' : 'No notes yet'}
</li>
))}
) : (
displayResults.map((note, index) => (
<li
key={note.id}
role="option"
aria-selected={index === selectedIndex}
className={index === selectedIndex ? 'selected' : ''}
onClick={() => openNoteAt(index)}
>
<span className="truncate">{note.title || 'Untitled'}</span>
{!searchQuery.trim() && (
<span className="shortcut">recent</span>
)}
</li>
))
)}
</ul>
</div>
</div>
Expand Down
36 changes: 35 additions & 1 deletion src/lib/note-utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import assert from 'node:assert/strict';
import { openCreatedNote, countWordsInNoteContent } from './note-utils.ts';
import {
openCreatedNote,
countWordsInNoteContent,
recentNotes,
commandPaletteResults,
cycleIndex,
} from './note-utils.ts';
import type { Note } from '../types/Note.ts';

function makeNote(partial: Partial<Note> & { id: string; title: string }): Note {
Expand Down Expand Up @@ -83,3 +89,31 @@ const nested = JSON.stringify([
assert.equal(countWordsInNoteContent(nested), 5);

console.log('note-utils countWordsInNoteContent: ok');

// --- recentNotes / commandPaletteResults / cycleIndex ---
const n1 = makeNote({ id: '1', title: 'Older', updated_at: '2026-01-01T00:00:00.000Z' });
const n2 = makeNote({ id: '2', title: 'Newer', updated_at: '2026-06-01T00:00:00.000Z' });
const n3 = makeNote({ id: '3', title: 'Mid', updated_at: '2026-03-01T00:00:00.000Z' });

assert.deepEqual(
recentNotes([n1, n2, n3], 2).map((n) => n.id),
['2', '3'],
);

assert.deepEqual(
commandPaletteResults('', [n1, n2], [n1]).map((n) => n.id),
['2', '1'],
'empty query uses recent notes, not fuse matches',
);
assert.deepEqual(
commandPaletteResults(' x ', [n1, n2], [n1]).map((n) => n.id),
['1'],
'non-empty query uses fuse matches',
);

assert.equal(cycleIndex(0, 1, 0), 0);
assert.equal(cycleIndex(0, 1, 3), 1);
assert.equal(cycleIndex(2, 1, 3), 0);
assert.equal(cycleIndex(0, -1, 3), 2);

console.log('note-utils palette helpers: ok');
31 changes: 31 additions & 0 deletions src/lib/note-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,34 @@ export function countWordsInNoteContent(content: string | null | undefined): num

return content.split(/\s+/).filter((w) => w.length > 0).length;
}

/** Recent notes for empty command palette (newest first). */
export function recentNotes(notes: Note[], limit = 10): Note[] {
return [...notes]
.sort((a, b) => {
const tb = Date.parse(b.updated_at || '') || 0;
const ta = Date.parse(a.updated_at || '') || 0;
return tb - ta;
})
.slice(0, limit);
}

/**
* Results to show in the command palette: Fuse matches when querying,
* otherwise the most recently updated notes.
*/
export function commandPaletteResults(
query: string,
notes: Note[],
fuseMatches: Note[],
recentLimit = 10,
): Note[] {
if (query.trim()) return fuseMatches;
return recentNotes(notes, recentLimit);
}

/** Safe circular index for arrow-key selection (no-op on empty lists). */
export function cycleIndex(current: number, delta: number, length: number): number {
if (length <= 0) return 0;
return (current + delta + length) % length;
}
Loading