"use client"; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react"; import { useRouter } from "next/navigation"; import { filterCommands, globalCommands, moveHighlight, projectCommands, type Command, type ToolCommandInfo, } from "@/lib/command/palette"; import { handleTabTrap } from "@/lib/a11y/focusTrap"; import { api } from "@/lib/api/client"; import { useBodyScrollLock } from "@/lib/ui/useBodyScrollLock"; import { focusRing, overlayScrim } from "@/lib/ui/variants"; // 命令面板开关的极简外部 store:⌘K 与可见入口(顶栏按钮 / 移动抽屉)共享同一开态。 // 用 useSyncExternalStore 订阅,避免把 open 提升到 RootLayout context 造成全树重渲染。 let paletteOpen = false; const paletteListeners = new Set<() => void>(); function emitPalette(): void { for (const listener of paletteListeners) listener(); } export function setCommandPaletteOpen(next: boolean): void { if (paletteOpen === next) return; paletteOpen = next; emitPalette(); } export function openCommandPalette(): void { setCommandPaletteOpen(true); } export function toggleCommandPalette(): void { setCommandPaletteOpen(!paletteOpen); } function subscribePalette(callback: () => void): () => void { paletteListeners.add(callback); return () => paletteListeners.delete(callback); } function getPaletteSnapshot(): boolean { return paletteOpen; } // SSR 快照恒为 false(命令面板永不在服务端渲染开态),避免水合不一致。 export function useCommandPaletteOpen(): boolean { return useSyncExternalStore( subscribePalette, getPaletteSnapshot, () => false, ); } const LIST_ID = "command-list"; const optionId = (cmd: Command): string => `command-option-${cmd.id}`; // 从 pathname 抽取当前 projectId(/projects//...)。 function projectIdFromPath(pathname: string): string | null { const m = pathname.match(/^\/projects\/([^/]+)/); return m?.[1] ?? null; } interface CommandPaletteProps { pathname: string; } // 命令面板(⌘K,UX §7):键盘触发的快速导航/动作。 // WAI-ARIA combobox/listbox:input role=combobox(aria-expanded/controls/activedescendant), // 列表 role=listbox,每项稳定 id + role=option。焦点管理:打开聚焦输入框、关闭归还触发元素。 export function CommandPalette({ pathname }: CommandPaletteProps) { const router = useRouter(); const open = useCommandPaletteOpen(); const [query, setQuery] = useState(""); const [highlight, setHighlight] = useState(0); const inputRef = useRef(null); const dialogRef = useRef(null); // 打开前的焦点元素:关闭时归还焦点(WCAG 2.4.3)。 const restoreFocusRef = useRef(null); // 工具箱生成器(命令面板发现性):首次打开惰性拉取一次,失败静默降级为空。 const [tools, setTools] = useState([]); const toolsLoadedRef = useRef(false); const projectId = projectIdFromPath(pathname); const commands = useMemo( () => projectId ? [...projectCommands(projectId, tools), ...globalCommands()] : globalCommands(), [projectId, tools], ); const results = useMemo( () => filterCommands(commands, query), [commands, query], ); const close = useCallback((): void => { setCommandPaletteOpen(false); setQuery(""); setHighlight(0); }, []); const run = useCallback( (cmd: Command | undefined): void => { if (!cmd) return; close(); if (cmd.href) router.push(cmd.href); }, [close, router], ); useBodyScrollLock(open); // ⌘K / Ctrl+K 全局开关。 useEffect(() => { const onKey = (e: KeyboardEvent): void => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault(); toggleCommandPalette(); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, []); // 打开时记住先前焦点并聚焦输入框;关闭后把焦点归还触发元素。 useEffect(() => { if (open) { restoreFocusRef.current = document.activeElement as HTMLElement | null; inputRef.current?.focus(); return; } restoreFocusRef.current?.focus(); restoreFocusRef.current = null; }, [open]); // 首次打开(项目内)惰性拉工具箱生成器列表,供生成 action-gen- 命令。 useEffect(() => { if (!open || !projectId || toolsLoadedRef.current) return; toolsLoadedRef.current = true; let cancelled = false; void (async () => { try { const { data } = await api.GET("/skills/toolbox"); if (cancelled || !data?.tools) return; setTools( data.tools .filter((t) => !t.is_legacy) .map((t) => ({ key: t.key, title: t.title, genre: t.genre })), ); } catch { // 静默降级:命令面板照常工作,仅缺生成器动作。 } })(); return () => { cancelled = true; }; }, [open, projectId]); useEffect(() => { setHighlight(0); }, [query]); if (!open) return null; const activeCmd = results[highlight]; const onInputKey = (e: React.KeyboardEvent): void => { if (e.key === "Escape") { e.preventDefault(); close(); } else if (e.key === "ArrowDown") { e.preventDefault(); setHighlight((h) => moveHighlight(h, 1, results.length)); } else if (e.key === "ArrowUp") { e.preventDefault(); setHighlight((h) => moveHighlight(h, -1, results.length)); } else if (e.key === "Enter") { e.preventDefault(); run(results[highlight]); } }; return (
e.stopPropagation()} onKeyDown={(e) => { // focus trap:Tab/Shift+Tab 在对话框内循环,不逃逸到背景(WCAG 2.1.2)。 if (dialogRef.current) handleTabTrap(dialogRef.current, e); }} > setQuery(e.target.value)} onKeyDown={onInputKey} placeholder="搜索命令或页面…(写本章 / 审稿 / 生成角色 / 跳转伏笔 / 搜设定)" role="combobox" aria-label="命令搜索" aria-expanded={true} aria-controls={LIST_ID} aria-activedescendant={activeCmd ? optionId(activeCmd) : undefined} aria-autocomplete="list" className={`w-full border-b border-line bg-bg px-4 py-3 text-sm text-ink ${focusRing}`} />
    {results.length === 0 ? (
  • 无匹配命令
  • ) : ( results.map((cmd, i) => (
  • setHighlight(i)} onClick={() => run(cmd)} className={`flex cursor-pointer items-center justify-between px-4 py-2 text-sm ${ i === highlight ? "bg-[var(--color-cinnabar-wash)] text-cinnabar" : "text-ink" }`} > {cmd.title} {cmd.group}
  • )) )}
); }