Z ZPI 文档 v0.3.8
介绍页

源码

packages/desktop/src/renderer/ShortcutSettings.tsx

tsx404 行查看原文
// Layout and key search adapted from ZCode settings/ShortcutSettingsSection (Apache-2.0).
import * as Dialog from "@radix-ui/react-dialog";
import { ActionHint } from "@zpi-dev/ui";
import { Keyboard, Pencil, RotateCcw, Search, Trash2, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import {
  defaultShortcuts,
  formatShortcut,
  type ShortcutCommand,
  type ShortcutSettings as ShortcutPreferences,
  shortcutCommands,
  shortcutFromEvent,
  shortcutLabelParts,
  validateShortcuts,
} from "../shared/shortcuts.ts";
import { SettingsHelp } from "./SettingsHelp.tsx";
import { ShortcutRecordingRequest } from "./shortcut-recording.ts";
import { matchesShortcutSearch, shortcutScopeLabels } from "./shortcut-settings-model.ts";
import { report, useStore } from "./store.ts";
import { unwrap } from "./unwrap.ts";
import { userErrorMessage } from "./user-error.ts";

type Capture = ShortcutCommand | "key-search";

export function ShortcutSettings() {
  const saved = useStore((state) => state.settings?.shortcuts);
  const platform = window.zpi.platform;
  const defaults = defaultShortcuts(platform);
  const [draft, setDraft] = useState<ShortcutPreferences>(() => saved ?? defaults);
  const [recording, setRecording] = useState<Capture>();
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState("");
  const [notice, setNotice] = useState("");
  const [query, setQuery] = useState("");
  const [keySearch, setKeySearch] = useState<string | null>(null);
  const [recordError, setRecordError] = useState("");
  const [preview, setPreview] = useState<string | null>(null);
  const [confirmReset, setConfirmReset] = useState(false);
  const [capturePending, setCapturePending] = useState(false);
  const mounted = useRef(true);
  const captureRequest = useRef(new ShortcutRecordingRequest());
  const searchInput = useRef<HTMLInputElement>(null);
  const recordingButton = useRef<HTMLButtonElement>(null);
  const exclusive = Boolean(recording) || capturePending;
  const finishRecording = () => {
    captureRequest.current.cancel();
    setRecording(undefined);
    setCapturePending(false);
    setRecordError("");
    setPreview(null);
    void window.zpi
      .setShortcutCapture(false)
      .then(unwrap)
      .catch((error) => {
        if (mounted.current) setError(userErrorMessage(error));
        else report(error);
      });
  };
  useEffect(() => {
    mounted.current = true;
    window.addEventListener("blur", finishRecording);
    return () => {
      mounted.current = false;
      captureRequest.current.cancel();
      window.removeEventListener("blur", finishRecording);
      void window.zpi.setShortcutCapture(false).then(unwrap).catch(report);
    };
  }, []);
  const beginRecording = async (command: Capture) => {
    setError("");
    setNotice("");
    setRecordError("");
    setPreview(null);
    if (recording === command) return;
    setRecording(undefined);
    setCapturePending(true);
    try {
      const current = await captureRequest.current.start(() =>
        window.zpi.setShortcutCapture(true).then(unwrap),
      );
      if (!mounted.current || !current) return;
      setCapturePending(false);
      setRecording(command);
    } catch (error) {
      if (mounted.current) {
        setCapturePending(false);
        setError(userErrorMessage(error));
      }
    }
  };
  useEffect(() => {
    if (!recording) return;
    // Move away from editable text before capturing a combination, including IME input.
    (recording === "key-search" ? searchInput.current : recordingButton.current)?.focus();
  }, [recording]);
  useEffect(() => {
    if (!exclusive) return;
    const keydown = (event: KeyboardEvent) => {
      event.preventDefault();
      event.stopPropagation();
      if (event.isComposing || event.keyCode === 229 || event.repeat) return;
      if (event.key === "Escape") {
        finishRecording();
        return;
      }
      if (!recording) return;
      if (recording === "key-search" && event.key === "Backspace") {
        setKeySearch(null);
        return;
      }
      if (["Control", "Meta", "Alt", "Shift"].includes(event.key)) {
        setRecordError("");
        setPreview(null);
        return;
      }
      const binding = shortcutFromEvent(event, platform, recording === "key-search");
      if (recording === "key-search") {
        if (!binding || (!event.metaKey && !event.ctrlKey && !event.altKey && !/^F\d+$/.test(binding)))
          return;
        setKeySearch(binding);
        finishRecording();
        return;
      }
      if (!binding) {
        setRecordError("请选择带有修饰键的有效组合。");
        return;
      }
      const next = { ...draft, bindings: { ...draft.bindings, [recording]: binding } };
      const invalid = validateShortcuts(next, platform);
      if (invalid) {
        setPreview(binding);
        setRecordError(invalid);
        return;
      }
      setDraft(next);
      finishRecording();
    };
    window.addEventListener("keydown", keydown, true);
    return () => window.removeEventListener("keydown", keydown, true);
  }, [exclusive, recording, draft, platform]);
  const validation = validateShortcuts(draft, platform);
  const changed = JSON.stringify(draft) !== JSON.stringify(saved ?? defaults);
  const customized = shortcutCommands.some(({ id }) => draft.bindings[id] !== defaults.bindings[id]);
  const save = async () => {
    if (validation || busy || exclusive) return;
    setBusy(true);
    setError("");
    setNotice("");
    try {
      const settings = unwrap(await window.zpi.updateShortcuts(draft));
      useStore.setState({ settings });
      setDraft(settings.shortcuts);
      setNotice("快捷键已保存。");
    } catch (error) {
      setError(userErrorMessage(error));
    } finally {
      setBusy(false);
    }
  };
  const visible = shortcutCommands.filter(({ id, label }) =>
    matchesShortcutSearch(label, draft.bindings[id], query, keySearch, platform),
  );
  return (
    <div className="shortcut-settings" data-shortcut-recording={exclusive}>
      <div className="shortcut-settings-heading">
        <div className="settings-heading-title">
          <h1>键盘快捷键</h1>
          <SettingsHelp
            label="快捷键"
            text="点击键位或编辑图标录入,Esc 取消。更改后点击保存;全局快捷键注册失败时保留原设置。"
          />
        </div>
        <label className="shortcut-enable">
          <span>启用快捷键</span>
          <span className="settings-switch">
            <input
              type="checkbox"
              role="switch"
              aria-label="启用快捷键"
              aria-checked={draft.enabled}
              checked={draft.enabled}
              disabled={busy || exclusive}
              onChange={(event) => {
                setDraft({ ...draft, enabled: event.target.checked });
                setNotice("");
              }}
            />
            <span aria-hidden="true" />
          </span>
        </label>
      </div>
      <div className="shortcut-search-bar">
        <div className="shortcut-search">
          <Search size={16} aria-hidden="true" />
          <input
            ref={searchInput}
            aria-label="搜索命令或快捷键"
            placeholder={recording === "key-search" ? "请按下组合键…" : "搜索命令或快捷键"}
            value={keySearch ? formatShortcut(keySearch, platform) : query}
            readOnly={recording === "key-search" || keySearch !== null}
            disabled={busy}
            data-key-filter={keySearch !== null}
            onChange={(event) => setQuery(event.target.value)}
            onBlur={(event) => {
              if (
                recording === "key-search" &&
                !(
                  event.relatedTarget instanceof Element &&
                  event.relatedTarget.closest(".shortcut-key-search")
                )
              )
                finishRecording();
            }}
          />
          {(query || keySearch) && (
            <button
              className="shortcut-search-clear"
              aria-label="清空搜索"
              title="清空搜索"
              disabled={busy || exclusive}
              onClick={() => {
                setQuery("");
                setKeySearch(null);
                searchInput.current?.focus();
              }}
            >
              <X size={14} />
            </button>
          )}
          <ActionHint label="按组合键搜索(Esc 取消)" appearance="control">
            <button
              className="shortcut-key-search"
              aria-label="按组合键搜索"
              aria-pressed={recording === "key-search"}
              disabled={busy || capturePending}
              onClick={() =>
                recording === "key-search" ? finishRecording() : void beginRecording("key-search")
              }
            >
              <Keyboard size={16} />
            </button>
          </ActionHint>
        </div>
        <button
          className="shortcut-reset"
          disabled={busy || exclusive || !customized}
          onClick={() => setConfirmReset(true)}
        >
          <RotateCcw size={16} />
          全部恢复默认
        </button>
      </div>
      <div className="shortcut-table-scroll">
        <table className="shortcut-table" aria-label="快捷键绑定" data-disabled={!draft.enabled}>
          <thead>
            <tr className="shortcut-table-header">
              {["命令", "按键绑定", "作用域", "操作"].map((label) => (
                <th key={label} scope="col">
                  {label}
                </th>
              ))}
            </tr>
          </thead>
          <tbody>
            {visible.map(({ id, label, scope }) => {
              const active = recording === id;
              const [scopeLabel, scopeHint] = shortcutScopeLabels[scope];
              return (
                <tr
                  className="shortcut-setting-row"
                  key={id}
                  data-shortcut-command={id}
                  data-customized={draft.bindings[id] !== defaults.bindings[id]}
                >
                  <td className="shortcut-command" title={label}>
                    {label}
                  </td>
                  <td className="shortcut-binding-cell">
                    <div className="shortcut-binding">
                      {active && <Keyboard size={16} aria-hidden="true" />}
                      <button
                        ref={active ? recordingButton : undefined}
                        className={`shortcut-recorder ${active ? "recording" : ""}`}
                        aria-label={`录入${label}快捷键`}
                        aria-pressed={active}
                        aria-invalid={active && Boolean(recordError)}
                        aria-describedby={active ? `shortcut-hint-${id}` : undefined}
                        disabled={busy || capturePending}
                        onClick={() => void beginRecording(id)}
                        onBlur={() => {
                          if (active) finishRecording();
                        }}
                      >
                        {active ? (
                          <kbd>{preview ? formatShortcut(preview, platform) : "请按快捷键…"}</kbd>
                        ) : (
                          shortcutLabelParts(draft.bindings[id], platform).map((part, index) => (
                            <kbd key={`${part}-${index}`}>{part}</kbd>
                          ))
                        )}
                      </button>
                      {!active && (
                        <ActionHint label={`编辑${label}快捷键`} appearance="control">
                          <button
                            className="shortcut-edit"
                            aria-label={`编辑${label}快捷键`}
                            disabled={busy || capturePending}
                            onClick={() => void beginRecording(id)}
                          >
                            <Pencil size={14} />
                          </button>
                        </ActionHint>
                      )}
                    </div>
                    {active && (
                      <span
                        id={`shortcut-hint-${id}`}
                        role={recordError ? "alert" : undefined}
                        className={recordError ? "shortcut-record-error" : "shortcut-record-hint"}
                      >
                        {recordError || "按下新组合,Esc 取消"}
                      </span>
                    )}
                  </td>
                  <td className="shortcut-scope" title={scopeHint}>
                    {scopeLabel}
                  </td>
                  <td>
                    <ActionHint label={`清除${label}绑定(保留命令)`} appearance="control">
                      <button
                        className="shortcut-clear"
                        aria-label={`清除${label}快捷键`}
                        disabled={busy || exclusive || draft.bindings[id] === null}
                        onClick={() => {
                          setDraft({ ...draft, bindings: { ...draft.bindings, [id]: null } });
                          setNotice("");
                          setError("");
                        }}
                      >
                        <Trash2 size={16} />
                      </button>
                    </ActionHint>
                  </td>
                </tr>
              );
            })}
            {visible.length === 0 && (
              <tr className="shortcut-empty">
                <td colSpan={4}>没有匹配的命令或快捷键</td>
              </tr>
            )}
          </tbody>
        </table>
      </div>
      <div className="shortcut-settings-footer">
        <button
          className="primary"
          disabled={busy || exclusive || Boolean(validation) || !changed}
          onClick={() => void save()}
        >
          {busy ? "保存中…" : "保存快捷键"}
        </button>
      </div>
      {(error || validation) && (
        <div role="alert" className="model-config-error">
          {error || validation}
        </div>
      )}
      {notice && (
        <p role="status" className="shortcut-notice">
          {notice}
        </p>
      )}
      <Dialog.Root open={confirmReset} onOpenChange={setConfirmReset}>
        <Dialog.Portal>
          <Dialog.Overlay className="model-config-backdrop" />
          <Dialog.Content className="provider-delete-dialog shortcut-reset-dialog">
            <header>
              <Dialog.Title>全部恢复默认快捷键?</Dialog.Title>
              <Dialog.Description>
                所有自定义绑定将恢复为 ZPI 默认键位。启用状态保持不变,点击保存后生效。
              </Dialog.Description>
            </header>
            <footer>
              <Dialog.Close className="secondary">取消</Dialog.Close>
              <button
                className="primary"
                onClick={() => {
                  setDraft({ ...defaults, enabled: draft.enabled });
                  setError("");
                  setNotice("已恢复默认,保存后生效。");
                  setConfirmReset(false);
                }}
              >
                恢复默认
              </button>
            </footer>
          </Dialog.Content>
        </Dialog.Portal>
      </Dialog.Root>
    </div>
  );
}