Files
writer-work-flow/apps/web/components/command/CommandPalette.tsx
Yaojia Wang c6651b74b9 fix(web): stale-closure + focus trap + 去边界强转 + a11y/性能打磨 + 类型化客户端
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 强类型。
2026-06-21 19:32:49 +02:00

160 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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;
}
// 命令面板⌘KUX §7键盘触发的快速导航/动作。
// 焦点管理打开自动聚焦输入框、Esc 关闭、上下选择、回车执行)+ a11yrole=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 trapTab/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>
);
}