165 lines
5.2 KiB
TypeScript
165 lines
5.2 KiB
TypeScript
import { useState, useRef, useEffect, useCallback } from 'react'
|
|
import type { VaultEntry } from '../types'
|
|
import { NoteSearchList } from './NoteSearchList'
|
|
import { useNoteSearch } from '../hooks/useNoteSearch'
|
|
import { translate, type AppLocale } from '../lib/i18n'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Plus } from '@phosphor-icons/react'
|
|
import type { NoteSearchResult } from '../hooks/useNoteSearch'
|
|
|
|
interface QuickOpenPaletteProps {
|
|
open: boolean
|
|
entries: VaultEntry[]
|
|
isLoading?: boolean
|
|
onSelect: (entry: VaultEntry) => void
|
|
onCreateNote?: (title: string) => unknown
|
|
onClose: () => void
|
|
locale?: AppLocale
|
|
}
|
|
|
|
interface QuickOpenCreateActionProps {
|
|
title: string
|
|
onCreate: () => void
|
|
locale: AppLocale
|
|
}
|
|
|
|
function quickOpenEmptyMessage(isLoading: boolean, locale: AppLocale): string {
|
|
return isLoading ? translate(locale, 'status.vault.reloading') : translate(locale, 'noteList.empty.noMatching')
|
|
}
|
|
|
|
function QuickOpenCreateAction({ title, onCreate, locale }: QuickOpenCreateActionProps) {
|
|
return (
|
|
<div className="border-t border-border p-2">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
className="h-9 w-full justify-start gap-2 px-2 text-sm"
|
|
onClick={onCreate}
|
|
>
|
|
<Plus size={14} className="shrink-0" />
|
|
<span className="truncate">{translate(locale, 'noteList.quickOpenCreate', { title })}</span>
|
|
</Button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function useQuickOpenCreateAction({
|
|
query,
|
|
isLoading,
|
|
resultCount,
|
|
onCreateNote,
|
|
onClose,
|
|
}: {
|
|
query: string
|
|
isLoading: boolean
|
|
resultCount: number
|
|
onCreateNote?: (title: string) => unknown
|
|
onClose: () => void
|
|
}) {
|
|
const title = query.trim()
|
|
const canCreate = Boolean(onCreateNote && title && !isLoading && resultCount === 0)
|
|
const create = useCallback(() => {
|
|
if (!canCreate) return
|
|
onCreateNote?.(title)
|
|
onClose()
|
|
}, [canCreate, title, onCreateNote, onClose])
|
|
|
|
return { canCreate, create, title }
|
|
}
|
|
|
|
function useQuickOpenKeyboard({
|
|
open,
|
|
results,
|
|
selectedIndex,
|
|
onSelect,
|
|
onClose,
|
|
handleKeyDown,
|
|
createFromQuery,
|
|
}: {
|
|
open: boolean
|
|
results: NoteSearchResult[]
|
|
selectedIndex: number
|
|
onSelect: (entry: VaultEntry) => void
|
|
onClose: () => void
|
|
handleKeyDown: (e: KeyboardEvent) => void
|
|
createFromQuery: () => void
|
|
}) {
|
|
useEffect(() => {
|
|
if (!open) return
|
|
const handler = (e: KeyboardEvent) => {
|
|
handleKeyDown(e)
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault()
|
|
onClose()
|
|
} else if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
const selected = results.at(selectedIndex)
|
|
if (selected) {
|
|
onSelect(selected.entry)
|
|
onClose()
|
|
} else {
|
|
createFromQuery()
|
|
}
|
|
}
|
|
}
|
|
window.addEventListener('keydown', handler)
|
|
return () => window.removeEventListener('keydown', handler)
|
|
}, [open, results, selectedIndex, onSelect, onClose, handleKeyDown, createFromQuery])
|
|
}
|
|
|
|
export function QuickOpenPalette({ open, entries, isLoading = false, onSelect, onCreateNote, onClose, locale = 'en' }: QuickOpenPaletteProps) {
|
|
const [query, setQuery] = useState('')
|
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
const { results, selectedIndex, setSelectedIndex, handleKeyDown } = useNoteSearch(entries, query)
|
|
const createAction = useQuickOpenCreateAction({ query, isLoading, resultCount: results.length, onCreateNote, onClose })
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- reset on dialog open
|
|
setQuery('')
|
|
setSelectedIndex(0)
|
|
setTimeout(() => inputRef.current?.focus(), 50)
|
|
}
|
|
}, [open, setSelectedIndex])
|
|
|
|
useQuickOpenKeyboard({ open, results, selectedIndex, onSelect, onClose, handleKeyDown, createFromQuery: createAction.create })
|
|
|
|
if (!open) return null
|
|
|
|
return (
|
|
<div
|
|
data-testid="quick-open-palette"
|
|
className="fixed inset-0 z-[1000] flex justify-center bg-[var(--shadow-dialog)] pt-[15vh]"
|
|
onClick={onClose}
|
|
>
|
|
<div
|
|
className="flex w-[500px] max-w-[90vw] max-h-[400px] flex-col self-start overflow-hidden rounded-xl border border-[var(--border-dialog)] bg-popover shadow-[0_8px_32px_var(--shadow-dialog)]"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<Input
|
|
ref={inputRef}
|
|
className="h-auto rounded-none border-0 border-b border-border px-4 py-3 text-[15px] shadow-none focus-visible:ring-0"
|
|
type="text"
|
|
placeholder={translate(locale, 'noteList.searchPlaceholder')}
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
/>
|
|
<NoteSearchList
|
|
items={results}
|
|
selectedIndex={selectedIndex}
|
|
getItemKey={(item) => item.entry.path}
|
|
onItemClick={(item) => {
|
|
onSelect(item.entry)
|
|
onClose()
|
|
}}
|
|
onItemHover={(i) => setSelectedIndex(i)}
|
|
emptyMessage={quickOpenEmptyMessage(isLoading, locale)}
|
|
className="flex-1 overflow-y-auto"
|
|
/>
|
|
{createAction.canCreate && <QuickOpenCreateAction title={createAction.title} onCreate={createAction.create} locale={locale} />}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|