Z ZPI 文档 v0.3.8
介绍页

源码

packages/desktop/src/shared/shortcuts.ts

typescript326 行查看原文
/** The same accelerator grammar is used by settings, DOM events and Electron. */
export const shortcutCommands = [
  { id: "toggle-window", label: "显示或隐藏窗口", scope: "global" },
  { id: "toggle-sidebar", label: "显示或隐藏左侧栏", scope: "app" },
  { id: "pane-full-view", label: "进入或退出完整视图", scope: "app" },
  { id: "toggle-pane", label: "显示或隐藏右侧面板", scope: "app" },
  { id: "focus-composer", label: "聚焦任务输入框", scope: "app" },
  { id: "send-next", label: "立即发送消息", scope: "composer" },
  { id: "configure-environment", label: "配置本地环境", scope: "draft-composer" },
  { id: "select-model", label: "选择模型", scope: "composer" },
  { id: "task-menu", label: "打开或关闭任务菜单", scope: "task" },
  { id: "pin-1", label: "切换到置顶任务 1", scope: "app" },
  { id: "pin-2", label: "切换到置顶任务 2", scope: "app" },
  { id: "pin-3", label: "切换到置顶任务 3", scope: "app" },
  { id: "pin-4", label: "切换到置顶任务 4", scope: "app" },
  { id: "pin-5", label: "切换到置顶任务 5", scope: "app" },
  { id: "new-task", label: "新建任务", scope: "app" },
  { id: "search", label: "搜索任务", scope: "app" },
  { id: "find", label: "查找当前对话", scope: "app" },
  { id: "stop-run", label: "停止当前回复", scope: "app" },
] as const;
export type ShortcutCommand = (typeof shortcutCommands)[number]["id"];
export type GlobalShortcutCommand = Extract<(typeof shortcutCommands)[number], { scope: "global" }>["id"];
export interface ShortcutSettings {
  enabled: boolean;
  bindings: Record<ShortcutCommand, string | null>;
}
export interface ShortcutKeyEvent {
  key: string;
  code?: string;
  ctrlKey?: boolean;
  metaKey?: boolean;
  altKey?: boolean;
  shiftKey?: boolean;
  repeat?: boolean;
  isComposing?: boolean;
}
const modifiers = ["Control", "Alt", "Shift", "Command"] as const;
const aliases: Record<string, string> = {
  ctrl: "Control",
  control: "Control",
  alt: "Alt",
  option: "Alt",
  shift: "Shift",
  cmd: "Command",
  command: "Command",
  meta: "Command",
  super: "Command",
  esc: "Escape",
  escape: "Escape",
  return: "Enter",
  enter: "Enter",
  space: "Space",
  tab: "Tab",
  backspace: "Backspace",
  delete: "Delete",
  del: "Delete",
  insert: "Insert",
  home: "Home",
  end: "End",
  pageup: "PageUp",
  pagedown: "PageDown",
  up: "Up",
  arrowup: "Up",
  down: "Down",
  arrowdown: "Down",
  left: "Left",
  arrowleft: "Left",
  right: "Right",
  arrowright: "Right",
  plus: "Plus",
};
const punctuation: Record<string, string> = {
  Backquote: "`",
  Minus: "-",
  Equal: "=",
  BracketLeft: "[",
  BracketRight: "]",
  Backslash: "\\",
  Semicolon: ";",
  Quote: "'",
  Comma: ",",
  Period: ".",
  Slash: "/",
};
function keyName(value: string): string | undefined {
  const alias = aliases[value.toLowerCase()];
  if (alias) return alias;
  if (/^[a-z0-9]$/i.test(value)) return value.toUpperCase();
  if (/^F(?:[1-9]|1\d|2[0-4])$/i.test(value)) return value.toUpperCase();
  if (Object.values(punctuation).includes(value)) return value;
  return undefined;
}
export function normalizeShortcut(binding: string, platform: string): string | undefined {
  if (typeof binding !== "string" || binding.length > 100) return undefined;
  const parts = binding
    .trim()
    .split("+")
    .map((part) => part.trim());
  if (parts.length === 0 || parts.some((part) => !part)) return undefined;
  const held = new Set<string>();
  let key: string | undefined;
  for (const part of parts) {
    const normalized = /^(cmdorctrl|commandorcontrol)$/i.test(part)
      ? platform === "darwin"
        ? "Command"
        : "Control"
      : keyName(part);
    if (!normalized) return undefined;
    if ((modifiers as readonly string[]).includes(normalized)) {
      if (held.has(normalized) || key) return undefined;
      held.add(normalized);
    } else {
      if (key) return undefined;
      key = normalized;
    }
  }
  return key ? [...modifiers.filter((modifier) => held.has(modifier)), key].join("+") : undefined;
}
export function defaultShortcuts(platform: string): ShortcutSettings {
  const primary = platform === "darwin" ? "Command" : "Control";
  return {
    enabled: true,
    bindings: {
      "toggle-window": platform === "darwin" ? "Control+Command+E" : "Control+Alt+E",
      "toggle-sidebar": `${primary}+[`,
      "toggle-pane": `${primary}+]`,
      "pane-full-view": platform === "darwin" ? "Shift+Command+F" : "Control+Shift+F",
      "focus-composer": `${primary}+I`,
      "send-next": `${primary}+Enter`,
      "configure-environment": `${primary}+J`,
      "select-model": `${primary}+L`,
      "task-menu": `${primary}+U`,
      "pin-1": `${primary}+1`,
      "pin-2": `${primary}+2`,
      "pin-3": `${primary}+3`,
      "pin-4": `${primary}+4`,
      "pin-5": `${primary}+5`,
      "new-task": `${primary}+N`,
      search: `${primary}+K`,
      find: `${primary}+F`,
      "stop-run": "Escape",
    },
  };
}
/** Returns a recordable accelerator; validation explains any reserved combination. */
export function shortcutFromEvent(
  event: ShortcutKeyEvent,
  platform: string,
  allowAltLetter = false,
): string | null {
  // Key search can opt into recording physical Option-letter combinations.
  const altLetter = allowAltLetter && event.altKey && /^Key[A-Z]$/.test(event.code ?? "");
  if (
    event.isComposing ||
    event.repeat ||
    ["Process", "Unidentified"].includes(event.key) ||
    (event.key === "Dead" && !altLetter)
  )
    return null;
  let key: string | undefined;
  if (/^Key[A-Z]$/.test(event.code ?? "")) key = event.code?.slice(3);
  else if (/^Digit\d$/.test(event.code ?? "")) key = event.code?.slice(5);
  else if (event.code === "Space") key = "Space";
  else if (event.code && Object.hasOwn(punctuation, event.code)) key = punctuation[event.code];
  else key = event.key === " " ? "Space" : event.key === "+" ? "Plus" : keyName(event.key);
  if (!key || (modifiers as readonly string[]).includes(key)) return null;
  return (
    normalizeShortcut(
      [
        ...(event.ctrlKey ? ["Control"] : []),
        ...(event.altKey ? ["Alt"] : []),
        ...(event.shiftKey ? ["Shift"] : []),
        ...(event.metaKey ? ["Command"] : []),
        key,
      ].join("+"),
      platform,
    ) ?? null
  );
}
export function shortcutForEvent(
  event: ShortcutKeyEvent,
  platform: string,
  settings: ShortcutSettings,
): ShortcutCommand | undefined {
  if (!settings.enabled) return undefined;
  const binding = shortcutFromEvent(event, platform, true);
  if (!binding) return undefined;
  return shortcutCommands.find(({ id }) => {
    const saved = settings.bindings[id];
    return event.key !== "Dead" && saved !== null && normalizeShortcut(saved, platform) === binding;
  })?.id;
}
export function shortcutLabelParts(binding: string | null, platform: string): string[] {
  if (binding === null) return ["未设置"];
  const normalized = normalizeShortcut(binding, platform);
  if (!normalized) return [binding];
  const symbols: Record<string, string> =
    platform === "darwin"
      ? { Control: "⌃", Alt: "⌥", Shift: "⇧", Command: "⌘", Enter: "↵", Escape: "Esc", Space: "Space" }
      : { Control: "Ctrl", Command: "Win", Escape: "Esc", Plus: "+" };
  return normalized.split("+").map((key) => symbols[key] ?? key);
}
export function formatShortcut(binding: string | null, platform: string): string {
  return shortcutLabelParts(binding, platform).join(platform === "darwin" ? "" : "+");
}
function reservedShortcut(binding: string, command: ShortcutCommand, platform: string): string | undefined {
  const parts = binding.split("+");
  const key = parts.pop() ?? "";
  const held = new Set(parts);
  if (["F5", "F6", "F11", "F12"].includes(key)) return "此组合保留给浏览器或系统操作";
  if (!held.size) {
    if (key === "Escape" && command === "stop-run") return undefined;
    if (/^F(?:[1-9]|1\d|2[0-4])$/.test(key)) return undefined;
    return "请使用带修饰键的组合,Escape 仅用于停止回复";
  }
  if (
    [
      "Tab",
      "Backspace",
      "Delete",
      "Insert",
      "Home",
      "End",
      "PageUp",
      "PageDown",
      "Up",
      "Down",
      "Left",
      "Right",
    ].includes(key)
  )
    return "此组合保留给系统导航或文本编辑";
  if (key === "Escape") return "Escape 组合保留给系统或弹窗操作";
  if (!held.has("Control") && !held.has("Command") && !/^F\d+$/.test(key))
    return "请包含 Ctrl 或 Command,避免影响正常输入";
  const primary = platform === "darwin" ? "Command" : "Control";
  if (
    held.has(primary) &&
    [
      "A",
      "C",
      "X",
      "V",
      "Z",
      "Y",
      "W",
      "Q",
      "H",
      "M",
      "O",
      "P",
      "S",
      "R",
      ",",
      "+",
      "Plus",
      "-",
      "=",
      "0",
    ].includes(key)
  )
    return "此组合保留给编辑、窗口或浏览器操作";
  if (
    platform === "darwin" &&
    held.has("Control") &&
    !held.has("Command") &&
    !held.has("Alt") &&
    ["A", "B", "C", "D", "E", "F", "H", "K", "L", "N", "O", "P", "T", "U", "V", "W", "Y", "Z"].includes(key)
  )
    return "此组合保留给原生文本编辑和终端";
  if (
    (held.has("Alt") && key === "F4") ||
    (key === "I" && held.has(primary) && held.has(platform === "darwin" ? "Alt" : "Shift")) ||
    (platform === "darwin" && held.has("Control") && held.has("Command") && key === "F") ||
    (held.has("Command") && platform !== "darwin") ||
    (key === "Space" && (held.has("Command") || held.has("Control"))) ||
    (platform === "darwin" && held.has("Command") && held.has("Shift") && ["3", "4", "5"].includes(key))
  )
    return "此组合保留给系统操作";
  return undefined;
}
/** An error string is suitable for inline display; undefined means valid. */
export function validateShortcuts(input: unknown, platform: string): string | undefined {
  if (!input || typeof input !== "object" || Array.isArray(input)) return "快捷键设置无效";
  const settings = input as Partial<ShortcutSettings>;
  if (
    Object.keys(settings).some((key) => !["enabled", "bindings"].includes(key)) ||
    typeof settings.enabled !== "boolean" ||
    !settings.bindings ||
    typeof settings.bindings !== "object" ||
    Array.isArray(settings.bindings)
  )
    return "快捷键设置无效";
  const ids = shortcutCommands.map(({ id }) => id);
  if (Object.keys(settings.bindings).some((id) => !(ids as string[]).includes(id)))
    return "包含未知快捷键命令";
  const seen = new Map<string, string>();
  for (const { id, label } of shortcutCommands) {
    const value = settings.bindings[id];
    if (value === null) continue;
    if (typeof value !== "string") return `${label}:快捷键无效`;
    const binding = normalizeShortcut(value, platform);
    if (!binding) return `${label}:快捷键格式无效`;
    const reserved = reservedShortcut(binding, id, platform);
    if (reserved) return `${label}:${reserved}`;
    const duplicate = seen.get(binding);
    if (duplicate) return `${label}:与「${duplicate}」的快捷键冲突`;
    seen.set(binding, label);
  }
  return undefined;
}
export function normalizeShortcuts(settings: ShortcutSettings, platform: string): ShortcutSettings {
  const error = validateShortcuts(settings, platform);
  if (error) throw new Error(`invalid_input: ${error}`);
  return {
    enabled: settings.enabled,
    bindings: Object.fromEntries(
      shortcutCommands.map(({ id }) => [
        id,
        settings.bindings[id] === null ? null : normalizeShortcut(settings.bindings[id], platform),
      ]),
    ) as ShortcutSettings["bindings"],
  };
}