P1-6 useStyleLearn/useKimiOauth 修 stale-closure 依赖。 P1-7 CommandPalette/Drawer 加 focus trap(新 lib/a11y/focusTrap)。 P1-8 SSE 解析与 server.ts 去 as 强转改类型守卫/运行时校验。 P2 删冗余强转、开 noUncheckedIndexedAccess、Toast 清理+稳定 key、列表 key 稳定化、 rel=noopener、useMemo 大文本、ConflictCard role=group、useInjection 加锁、 client.test 真断言。 codegen 重生成 schema.d.ts + 消费 DimensionEntry/ReviewConflictView 强类型。
160 lines
4.8 KiB
TypeScript
160 lines
4.8 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
|
||
import {
|
||
filterCommands,
|
||
globalCommands,
|
||
moveHighlight,
|
||
projectCommands,
|
||
type Command,
|
||
} from "@/lib/command/palette";
|
||
import { handleTabTrap } from "@/lib/a11y/focusTrap";
|
||
|
||
// 从 pathname 抽取当前 projectId(/projects/<id>/...)。
|
||
function projectIdFromPath(pathname: string): string | null {
|
||
const m = pathname.match(/^\/projects\/([^/]+)/);
|
||
return m?.[1] ?? null;
|
||
}
|
||
|
||
interface CommandPaletteProps {
|
||
pathname: string;
|
||
}
|
||
|
||
// 命令面板(⌘K,UX §7):键盘触发的快速导航/动作。
|
||
// 焦点管理(打开自动聚焦输入框、Esc 关闭、上下选择、回车执行)+ a11y(role=dialog/listbox)。
|
||
export function CommandPalette({ pathname }: CommandPaletteProps) {
|
||
const router = useRouter();
|
||
const [open, setOpen] = useState(false);
|
||
const [query, setQuery] = useState("");
|
||
const [highlight, setHighlight] = useState(0);
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
const dialogRef = useRef<HTMLDivElement>(null);
|
||
|
||
const projectId = projectIdFromPath(pathname);
|
||
const commands = useMemo<Command[]>(
|
||
() =>
|
||
projectId
|
||
? [...projectCommands(projectId), ...globalCommands()]
|
||
: globalCommands(),
|
||
[projectId],
|
||
);
|
||
const results = useMemo(
|
||
() => filterCommands(commands, query),
|
||
[commands, query],
|
||
);
|
||
|
||
const close = useCallback((): void => {
|
||
setOpen(false);
|
||
setQuery("");
|
||
setHighlight(0);
|
||
}, []);
|
||
|
||
const run = useCallback(
|
||
(cmd: Command | undefined): void => {
|
||
if (!cmd) return;
|
||
close();
|
||
if (cmd.href) router.push(cmd.href);
|
||
},
|
||
[close, router],
|
||
);
|
||
|
||
// ⌘K / Ctrl+K 全局开关。
|
||
useEffect(() => {
|
||
const onKey = (e: KeyboardEvent): void => {
|
||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||
e.preventDefault();
|
||
setOpen((prev) => !prev);
|
||
}
|
||
};
|
||
window.addEventListener("keydown", onKey);
|
||
return () => window.removeEventListener("keydown", onKey);
|
||
}, []);
|
||
|
||
// 打开时聚焦输入框;切查询重置高亮。
|
||
useEffect(() => {
|
||
if (open) inputRef.current?.focus();
|
||
}, [open]);
|
||
useEffect(() => {
|
||
setHighlight(0);
|
||
}, [query]);
|
||
|
||
if (!open) return null;
|
||
|
||
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 (
|
||
<div
|
||
className="fixed inset-0 z-[60] flex items-start justify-center bg-black/30 pt-[15vh] motion-safe:transition-opacity"
|
||
onClick={close}
|
||
>
|
||
<div
|
||
ref={dialogRef}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label="命令面板"
|
||
className="w-full max-w-lg overflow-hidden rounded-lg border border-line bg-panel shadow-paper"
|
||
onClick={(e) => e.stopPropagation()}
|
||
onKeyDown={(e) => {
|
||
// focus trap:Tab/Shift+Tab 在对话框内循环,不逃逸到背景(WCAG 2.1.2)。
|
||
if (dialogRef.current) handleTabTrap(dialogRef.current, e);
|
||
}}
|
||
>
|
||
<input
|
||
ref={inputRef}
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
onKeyDown={onInputKey}
|
||
placeholder="搜索命令或页面…(写本章 / 审稿 / 生成角色 / 跳转伏笔 / 搜设定)"
|
||
aria-label="命令搜索"
|
||
aria-controls="command-list"
|
||
className="w-full border-b border-line bg-bg px-4 py-3 text-sm text-ink outline-none"
|
||
/>
|
||
<ul
|
||
id="command-list"
|
||
role="listbox"
|
||
aria-label="命令列表"
|
||
className="max-h-80 overflow-auto py-1"
|
||
>
|
||
{results.length === 0 ? (
|
||
<li className="px-4 py-3 text-sm text-ink-soft">无匹配命令</li>
|
||
) : (
|
||
results.map((cmd, i) => (
|
||
<li
|
||
key={cmd.id}
|
||
role="option"
|
||
aria-selected={i === highlight}
|
||
onMouseEnter={() => 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"
|
||
}`}
|
||
>
|
||
<span>{cmd.title}</span>
|
||
<span className="text-xs text-ink-soft">{cmd.group}</span>
|
||
</li>
|
||
))
|
||
)}
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|