"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { DragEvent as ReactDragEvent } from "react"; import { useRouter, useSelectedLayoutSegments } from "next/navigation"; import { CalendarClock, ChevronsUpDown, Copy, Layers, Search } from "lucide-react"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"; import { cn } from "@/lib/utils"; import { useSearchPaletteStore, type ReferenceInsertMode, type SearchPaletteMode } from "@/store/search-palette"; import type { DocumentSearchResult, DocumentSearchRequest, DocumentSearchTimeRange } from "@/types/search"; import { useDocumentSearch } from "@/hooks/use-document-search"; import { useReferenceComposer } from "@/hooks/use-reference-composer"; import { recordRecentPage } from "@/lib/search/record-recent"; import { PageHoverCard } from "@/components/reference/page-hover-card"; import { buildSearchRequest } from "@/lib/search/request"; import { resolveSearchOpenMode } from "@/lib/search/shortcuts"; interface SearchPaletteProps { workspaceId: string | null; } const TIME_RANGE_LABEL: Record = { any: "全部时间", "7d": "最近 7 天", "30d": "最近 30 天", }; const formatDate = (value: string | null) => { if (!value) return "未知时间"; const date = new Date(value); return date.toLocaleString(); }; export function SearchPalette({ workspaceId }: SearchPaletteProps) { const router = useRouter(); const segments = useSelectedLayoutSegments(); const activeDocumentId = segments?.[1] ?? null; const inputRef = useRef(null); const { open, mode, query, filters, timeRange, referenceMode, alias, recent, setRecent, openSearch, openReference, close, setQuery, toggleFilter, setTimeRange, setTimeField, setCustomRange, setReferenceMode, setAlias, rememberResult, } = useSearchPaletteStore(); const [highlightedIndex, setHighlightedIndex] = useState(0); const { insertReference } = useReferenceComposer({ workspaceId, sourcePageId: activeDocumentId ?? null, }); const [resultTab, setResultTab] = useState<"all" | "recent">(recent.length > 0 ? "recent" : "all"); const [referenceFiltersOpen, setReferenceFiltersOpen] = useState(mode === "reference"); const updateCustomRange = useCallback( (patch: { from?: string; to?: string }) => { const next = { ...(filters.customRange ?? {}), ...patch }; if (!next.from && !next.to) { setCustomRange(null); } else { setCustomRange(next); } }, [filters.customRange, setCustomRange], ); const clearCustomRange = useCallback(() => setCustomRange(null), [setCustomRange]); const requestPayload = useMemo( () => buildSearchRequest({ workspaceId, activeDocumentId, query, filters, timeRange, }), [workspaceId, activeDocumentId, query, filters, timeRange], ); const searchQueryEnabled = Boolean(open && workspaceId); const { data, isLoading, isFetching, error } = useDocumentSearch(requestPayload, searchQueryEnabled); useEffect(() => { if (data?.recent) { setRecent(data.recent); } }, [data?.recent, setRecent]); /* eslint-disable react-hooks/set-state-in-effect */ useEffect(() => { if (mode === "reference") { setReferenceFiltersOpen(true); } else { setReferenceFiltersOpen(false); } }, [mode]); /* eslint-enable react-hooks/set-state-in-effect */ const searchResults = data?.results; const remoteResults = useMemo(() => searchResults ?? [], [searchResults]); const showRecent = !query.trim() && recent.length > 0; const enableRecentTab = showRecent; const effectiveTab = enableRecentTab ? resultTab : "all"; const activeResults = effectiveTab === "recent" ? recent : remoteResults; useEffect(() => { if (!open) { return undefined; } inputRef.current?.focus(); const frame = requestAnimationFrame(() => setHighlightedIndex(0)); return () => cancelAnimationFrame(frame); }, [open]); useEffect(() => { if (highlightedIndex >= activeResults.length) { const frame = requestAnimationFrame(() => setHighlightedIndex(activeResults.length > 0 ? activeResults.length - 1 : 0), ); return () => cancelAnimationFrame(frame); } return undefined; }, [activeResults.length, highlightedIndex]); useEffect(() => { const handleGlobalHotkey = (event: KeyboardEvent) => { if ((event.metaKey || event.ctrlKey) && !event.shiftKey && event.key.toLowerCase() === "p") { event.preventDefault(); openSearch(); } if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === "r") { event.preventDefault(); openReference(); } }; window.addEventListener("keydown", handleGlobalHotkey); return () => window.removeEventListener("keydown", handleGlobalHotkey); }, [openSearch, openReference]); const handleOpenResult = useCallback( (result: DocumentSearchResult, openMode: "main" | "new-window" | "sidebar") => { if (openMode === "new-window") { window.open(result.publicPath, "_blank", "noopener,noreferrer"); } else if (openMode === "sidebar") { window.open(`${result.publicPath}?preview=sidebar`, "_blank", "noopener,noreferrer"); } else { router.push(result.publicPath); } void recordRecentPage(workspaceId, result.id); rememberResult(result); close(); }, [router, workspaceId, rememberResult, close], ); const handleInsertReference = useCallback( (result: DocumentSearchResult, overrideMode?: ReferenceInsertMode) => { const run = async () => { try { const effectiveMode = overrideMode ?? referenceMode; await insertReference(result, { mode: effectiveMode, alias: effectiveMode === "inline" ? alias : undefined, }); rememberResult(result); close(); } catch (error) { console.error(error); } }; void run(); }, [alias, close, insertReference, referenceMode, rememberResult], ); const handleCopyReference = useCallback((result: DocumentSearchResult) => { if (typeof window === "undefined") { return; } const payload = `((${result.id}))`; if (navigator?.clipboard) { navigator.clipboard .writeText(payload) .then(() => window.alert("块引用已复制")) .catch(() => { window.prompt("复制失败,请手动复制引用内容", payload); }); } else { window.prompt("复制块引用", payload); } }, []); useEffect(() => { if (!open) { return; } const handleKey = (event: KeyboardEvent) => { if (event.key === "ArrowDown") { event.preventDefault(); setHighlightedIndex((prev) => Math.min(activeResults.length - 1, prev + 1)); } else if (event.key === "ArrowUp") { event.preventDefault(); setHighlightedIndex((prev) => Math.max(0, prev - 1)); } else if (event.key === "Enter") { event.preventDefault(); if (activeResults.length === 0) { return; } const result = activeResults[highlightedIndex] ?? activeResults[0]; if (!result) return; if (mode === "search") { const openMode = resolveSearchOpenMode(event); handleOpenResult(result, openMode); } else { handleInsertReference(result); } } else if (event.key === "Escape") { event.preventDefault(); close(); } }; window.addEventListener("keydown", handleKey); return () => window.removeEventListener("keydown", handleKey); }, [open, activeResults, highlightedIndex, mode, handleOpenResult, handleInsertReference, close]); const pending = isLoading || isFetching; const dialogTitle = mode === "search" ? "页面搜索面板" : "引用选择面板"; return ( !next && close()}> {dialogTitle}
{ const nextValue = event.target.value; if (nextValue.trim().length > 0 && resultTab !== "all") { setResultTab("all"); } setQuery(nextValue); }} placeholder={mode === "search" ? "搜索页面标题、正文或 OCR 内容..." : "选择要引用的页面"} className="h-11 w-full rounded-xl border border-[#e2e8f0] bg-white pl-9 text-sm" />
{(Object.keys(TIME_RANGE_LABEL) as DocumentSearchTimeRange[]).map((range) => ( setTimeRange(range)} className={cn(range === timeRange && "bg-[#eef2ff] text-[#2563eb]")} > {TIME_RANGE_LABEL[range]} ))} setTimeField("updated")} className={cn(filters.timeField === "updated" && "bg-[#eef2ff] text-[#2563eb]")} > 按编辑时间 setTimeField("created")} className={cn(filters.timeField === "created" && "bg-[#eef2ff] text-[#2563eb]")} > 按创建时间
toggleFilter("titleOnly")} /> toggleFilter("exact")} /> toggleFilter("onlyCurrentPage")} /> toggleFilter("includeOcr")} /> {mode === "reference" && ( )}
{mode === "reference" && referenceFiltersOpen && (
setReferenceMode("inline")} /> setReferenceMode("embed")} /> {referenceMode === "inline" && ( setAlias(event.target.value)} placeholder="引用别名" className="h-8 w-40 text-xs" /> )} 引用模式将同步到 [[ / # 快捷键
)}
自定义时间 updateCustomRange({ from: event.target.value || undefined })} className="h-8 w-36 text-xs" /> updateCustomRange({ to: event.target.value || undefined })} className="h-8 w-36 text-xs" />
{enableRecentTab && (
setResultTab("recent")} disabled={!enableRecentTab} /> setResultTab("all")} />
)} {pending ? (
检索中,请稍候...
) : error ? (
{(error as Error).message}
) : activeResults.length === 0 ? (
{effectiveTab === "recent" ? "暂无最近访问记录" : "暂无匹配结果"}
) : (
{activeResults.map((result, index) => ( handleOpenResult(result, "main")} onReference={() => handleInsertReference(result)} onEmbed={() => handleInsertReference(result, "embed")} onCopy={() => handleCopyReference(result)} /> ))}
)}
{mode === "search" ? (
Enter 打开 · Ctrl/Cmd+Enter 新窗口 · Alt+Enter 右侧预览 Esc 关闭
) : (
{referenceMode === "inline" ? "插入行内引用(链接高亮)" : "插入一个嵌入的页面块"},可先输入别名 Esc 关闭
)}
); } function ResultRow({ result, highlighted, mode, onOpen, onReference, onEmbed, onCopy, }: { result: DocumentSearchResult; highlighted: boolean; mode: SearchPaletteMode; onOpen: () => void; onReference: () => void; onEmbed: () => void; onCopy: () => void; }) { const handleActivate = () => { if (mode === "search") { onOpen(); } else { onReference(); } }; const handleDragStart = (event: ReactDragEvent) => { if (mode !== "reference") return; if (event.dataTransfer) { event.dataTransfer.setData("text/plain", `((${result.id}))`); event.dataTransfer.effectAllowed = "copy"; } }; const content = (
{ if (event.key === "Enter") { event.preventDefault(); handleActivate(); } }} className={cn( "cursor-pointer px-4 py-3 transition-colors hover:bg-[#eef2ff]", highlighted && "bg-[#e3ecff]", )} draggable={mode === "reference"} onDragStart={handleDragStart} >
{result.title || "无标题"} {result.matchField !== "recent" && ( {result.matchField === "title" ? "标题匹配" : "正文匹配"} )} {result.hasOcr && ( OCR )}
最近编辑:{formatDate(result.updatedAt)} · 创建时间:{formatDate(result.createdAt)}
{mode === "reference" && (
也可直接拖拽引用到编辑器
)}
); return ( {content} ); } interface FilterToggleProps { label: string; active: boolean; onClick: () => void; disabled?: boolean; } function FilterToggle({ label, active, onClick, disabled }: FilterToggleProps) { return ( ); } interface TabButtonProps { label: string; active: boolean; onClick: () => void; disabled?: boolean; } function TabButton({ label, active, onClick, disabled }: TabButtonProps) { return ( ); } interface ReferenceModeToggleProps { label: string; active: boolean; onClick: () => void; } function ReferenceModeToggle({ label, active, onClick }: ReferenceModeToggleProps) { return ( ); }