Files
dbx/apps/desktop/src/lib/editor/shortcutRegistry.ts
T

657 lines
18 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.
import { isMacShortcutPlatform, parseShortcutStrokes, shortcutDisplayParts } from "@/lib/editor/shortcutDisplay";
export type ShortcutActionId =
| "executeSql"
| "executeSqlInNewResultTab"
| "formatSql"
| "expandSelectStar"
| "toggleLineComment"
| "toggleBlockComment"
| "saveSql"
| "acceptCompletion"
| "triggerCompletion"
| "indentMore"
| "indentLess"
| "insertLineBelow"
| "joinLines"
| "duplicateLine"
| "deleteLine"
| "moveLineUp"
| "moveLineDown"
| "copyLineUp"
| "copyLineDown"
| "undo"
| "redo"
| "selectAll"
| "extendSelection"
| "uppercaseSelection"
| "lowercaseSelection"
| "exPasteSqlInCondition"
| "toggleFold"
| "editTableStructure"
| "copyCurrentRow"
| "deleteCurrentRow"
| "goToColumn"
| "goToFirstPage"
| "goToPreviousPage"
| "goToNextPage"
| "goToLastPage"
| "newQuery"
| "openSettings"
| "closeTab"
| "closeOtherTabs"
| "focusSearch"
| "quickOpen"
| "navigateTabHistoryBack"
| "navigateTabHistoryForward"
| "tabSwitcher"
| "switchToPreviousTab"
| "switchToNextTab"
| "switchToTab1"
| "switchToTab2"
| "switchToTab3"
| "switchToTab4"
| "switchToTab5"
| "switchToTab6"
| "switchToTab7"
| "switchToTab8"
| "switchToTab9"
| "zoomInUi"
| "zoomOutUi"
| "resetUiZoom"
| "find"
| "replace"
| "refreshData"
| "toggleResultsPane"
| "toggleTranspose"
| "cancelSearch"
| "toggleSidebar"
| "toggleZenMode"
| "copySidebarSelection"
| "pasteSidebarSelection"
| "editSidebarConnection"
| "openDataInNewTab"
| "viewTableDdl"
| "sendSelectionToAi"
| "sqlIntentionActions";
export type ShortcutScope = "global" | "editor" | "grid" | "search" | "sidebar";
export interface ShortcutDefinition {
id: ShortcutActionId;
labelKey: string;
scope: ShortcutScope;
defaultShortcut: string;
inputKind?: "keyboard" | "modifier-only";
}
export type ShortcutSettings = Record<ShortcutActionId, string>;
// closeOtherTabs 的平台相关默认键。Windows/Linux 不用 Alt+Mod(= Ctrl+Alt,
// 与国际键盘 AltGr 字符输入冲突),也不用 Ctrl+Shift+W(浏览器保留的关窗键,
// Web 形态不可拦截);Shift+Alt+W 无浏览器保留冲突(Firefox accesskey
// 同为 Alt+Shift+字母,属正常应用快捷键区)。
// 已知取舍:Windows 的 Alt+Shift 布局切换只在单独按下并释放时触发,
// Alt+Shift+字母会正常送达应用,多语言用户如遇干扰可自定义改键。
// macOS 的 ⌥⌘W 无上述问题
export function closeOtherTabsDefaultShortcut(platform = globalThis.navigator?.platform || ""): string {
return isMacShortcutPlatform(platform) ? "Alt+Mod+W" : "Shift+Alt+W";
}
export function tabNavigationHistoryDefaultShortcut(direction: "back" | "forward", platform = globalThis.navigator?.platform || ""): string {
const modifier = isMacShortcutPlatform(platform) ? "Ctrl" : "Mod";
const key = direction === "back" ? "ArrowLeft" : "ArrowRight";
return `${modifier}+Alt+${key}`;
}
const PLATFORM_DEFAULT_SHORTCUTS: Partial<Record<ShortcutActionId, ReadonlySet<string>>> = {
closeOtherTabs: new Set(["Alt+Mod+W", "Shift+Alt+W"]),
navigateTabHistoryBack: new Set(["Ctrl+Alt+ArrowLeft", "Mod+Alt+ArrowLeft"]),
navigateTabHistoryForward: new Set(["Ctrl+Alt+ArrowRight", "Mod+Alt+ArrowRight"]),
};
const LEGACY_CLOSE_TAB_DEFAULT = "Meta+W";
const LEGACY_COPY_CURRENT_ROW_DEFAULT = "Mod+D";
const TAB_NAVIGATION_HISTORY_ACTIONS: ShortcutActionId[] = ["navigateTabHistoryBack", "navigateTabHistoryForward"];
export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
{
id: "executeSql",
labelKey: "settings.shortcutExecuteSql",
scope: "editor",
defaultShortcut: "Mod+Enter",
},
{
id: "executeSqlInNewResultTab",
labelKey: "settings.shortcutExecuteSqlInNewResultTab",
scope: "editor",
defaultShortcut: "Mod+\\",
},
{
id: "formatSql",
labelKey: "settings.shortcutFormatSql",
scope: "editor",
defaultShortcut: "Shift+Mod+F",
},
{
id: "expandSelectStar",
labelKey: "settings.shortcutExpandSelectStar",
scope: "editor",
defaultShortcut: "Mod+Shift+X",
},
{
id: "toggleLineComment",
labelKey: "settings.shortcutToggleLineComment",
scope: "editor",
defaultShortcut: "Mod+/",
},
{
id: "toggleBlockComment",
labelKey: "settings.shortcutToggleBlockComment",
scope: "editor",
defaultShortcut: "Shift+Alt+A",
},
{
id: "saveSql",
labelKey: "settings.shortcutSaveSql",
scope: "editor",
defaultShortcut: "Mod+S",
},
{
id: "acceptCompletion",
labelKey: "settings.shortcutAcceptCompletion",
scope: "editor",
defaultShortcut: "Tab",
},
{
id: "triggerCompletion",
labelKey: "settings.shortcutTriggerCompletion",
scope: "editor",
defaultShortcut: "Alt+/",
},
{
id: "indentMore",
labelKey: "settings.shortcutIndentMore",
scope: "editor",
defaultShortcut: "",
},
{
id: "indentLess",
labelKey: "settings.shortcutIndentLess",
scope: "editor",
defaultShortcut: "Shift+Tab",
},
{
id: "insertLineBelow",
labelKey: "settings.shortcutInsertLineBelow",
scope: "editor",
defaultShortcut: "Shift+Enter",
},
{
id: "joinLines",
labelKey: "settings.shortcutJoinLines",
scope: "editor",
defaultShortcut: "Mod+J",
},
{
id: "duplicateLine",
labelKey: "settings.shortcutDuplicateLine",
scope: "editor",
defaultShortcut: "Mod+D",
},
{
id: "deleteLine",
labelKey: "settings.shortcutDeleteLine",
scope: "editor",
defaultShortcut: "Shift+Mod+K",
},
{
id: "moveLineUp",
labelKey: "settings.shortcutMoveLineUp",
scope: "editor",
defaultShortcut: "Alt+ArrowUp",
},
{
id: "moveLineDown",
labelKey: "settings.shortcutMoveLineDown",
scope: "editor",
defaultShortcut: "Alt+ArrowDown",
},
{
id: "copyLineUp",
labelKey: "settings.shortcutCopyLineUp",
scope: "editor",
defaultShortcut: "Shift+Alt+ArrowUp",
},
{
id: "copyLineDown",
labelKey: "settings.shortcutCopyLineDown",
scope: "editor",
defaultShortcut: "Shift+Alt+ArrowDown",
},
{
id: "undo",
labelKey: "settings.shortcutUndo",
scope: "editor",
defaultShortcut: "Mod+Z",
},
{
id: "redo",
labelKey: "settings.shortcutRedo",
scope: "editor",
defaultShortcut: "Shift+Mod+Z",
},
{
id: "selectAll",
labelKey: "settings.shortcutSelectAll",
scope: "editor",
defaultShortcut: "Mod+A",
},
{
id: "extendSelection",
labelKey: "settings.shortcutExtendSelection",
scope: "editor",
defaultShortcut: "Alt+W",
},
{
id: "uppercaseSelection",
labelKey: "settings.shortcutUppercaseSelection",
scope: "editor",
defaultShortcut: "Shift+Alt+U",
},
{
id: "lowercaseSelection",
labelKey: "settings.shortcutLowercaseSelection",
scope: "editor",
defaultShortcut: "Shift+Alt+L",
},
{
id: "exPasteSqlInCondition",
labelKey: "settings.shortcutExPasteSqlInCondition",
scope: "editor",
defaultShortcut: "",
},
{
id: "toggleFold",
labelKey: "settings.shortcutToggleFold",
scope: "editor",
defaultShortcut: "Mod+.",
},
{
id: "editTableStructure",
labelKey: "settings.shortcutEditTableStructure",
scope: "grid",
defaultShortcut: "Mod+D",
},
{
id: "copyCurrentRow",
labelKey: "settings.shortcutCopyCurrentRow",
scope: "grid",
defaultShortcut: "",
},
{
id: "deleteCurrentRow",
labelKey: "settings.shortcutDeleteCurrentRow",
scope: "grid",
defaultShortcut: "Delete",
},
{
id: "goToColumn",
labelKey: "settings.shortcutGoToColumn",
scope: "grid",
defaultShortcut: "",
},
{
id: "goToFirstPage",
labelKey: "settings.shortcutGoToFirstPage",
scope: "grid",
defaultShortcut: "",
},
{
id: "goToPreviousPage",
labelKey: "settings.shortcutGoToPreviousPage",
scope: "grid",
defaultShortcut: "",
},
{
id: "goToNextPage",
labelKey: "settings.shortcutGoToNextPage",
scope: "grid",
defaultShortcut: "",
},
{
id: "goToLastPage",
labelKey: "settings.shortcutGoToLastPage",
scope: "grid",
defaultShortcut: "",
},
{
id: "newQuery",
labelKey: "settings.shortcutNewQuery",
scope: "global",
defaultShortcut: "Mod+T",
},
{
id: "openSettings",
labelKey: "settings.shortcutOpenSettings",
scope: "global",
defaultShortcut: "Mod+,",
},
{
id: "closeTab",
labelKey: "settings.shortcutCloseTab",
scope: "global",
defaultShortcut: "Mod+W",
},
{
id: "closeOtherTabs",
labelKey: "contextMenu.closeOtherTabs",
scope: "global",
defaultShortcut: closeOtherTabsDefaultShortcut(),
},
{
id: "focusSearch",
labelKey: "settings.shortcutFocusSearch",
scope: "global",
defaultShortcut: "Mod+F",
},
{
id: "quickOpen",
labelKey: "settings.shortcutQuickOpen",
scope: "global",
defaultShortcut: "Mod+P",
},
{
id: "navigateTabHistoryBack",
labelKey: "settings.shortcutNavigateTabHistoryBack",
scope: "global",
defaultShortcut: tabNavigationHistoryDefaultShortcut("back"),
},
{
id: "navigateTabHistoryForward",
labelKey: "settings.shortcutNavigateTabHistoryForward",
scope: "global",
defaultShortcut: tabNavigationHistoryDefaultShortcut("forward"),
},
{
id: "tabSwitcher",
labelKey: "settings.shortcutTabSwitcher",
scope: "global",
defaultShortcut: "Ctrl+Tab",
},
{
id: "switchToPreviousTab",
labelKey: "settings.shortcutSwitchToPreviousTab",
scope: "global",
defaultShortcut: "Shift+Mod+[",
},
{
id: "switchToNextTab",
labelKey: "settings.shortcutSwitchToNextTab",
scope: "global",
defaultShortcut: "Shift+Mod+]",
},
{
id: "switchToTab1",
labelKey: "settings.shortcutSwitchToTab1",
scope: "global",
defaultShortcut: "Mod+1",
},
{
id: "switchToTab2",
labelKey: "settings.shortcutSwitchToTab2",
scope: "global",
defaultShortcut: "Mod+2",
},
{
id: "switchToTab3",
labelKey: "settings.shortcutSwitchToTab3",
scope: "global",
defaultShortcut: "Mod+3",
},
{
id: "switchToTab4",
labelKey: "settings.shortcutSwitchToTab4",
scope: "global",
defaultShortcut: "Mod+4",
},
{
id: "switchToTab5",
labelKey: "settings.shortcutSwitchToTab5",
scope: "global",
defaultShortcut: "Mod+5",
},
{
id: "switchToTab6",
labelKey: "settings.shortcutSwitchToTab6",
scope: "global",
defaultShortcut: "Mod+6",
},
{
id: "switchToTab7",
labelKey: "settings.shortcutSwitchToTab7",
scope: "global",
defaultShortcut: "Mod+7",
},
{
id: "switchToTab8",
labelKey: "settings.shortcutSwitchToTab8",
scope: "global",
defaultShortcut: "Mod+8",
},
{
id: "switchToTab9",
labelKey: "settings.shortcutSwitchToTab9",
scope: "global",
defaultShortcut: "Mod+9",
},
{
id: "zoomInUi",
labelKey: "settings.shortcutZoomInUi",
scope: "global",
defaultShortcut: "Mod+=",
},
{
id: "zoomOutUi",
labelKey: "settings.shortcutZoomOutUi",
scope: "global",
defaultShortcut: "Mod+-",
},
{
id: "resetUiZoom",
labelKey: "settings.shortcutResetUiZoom",
scope: "global",
defaultShortcut: "Mod+0",
},
{
id: "find",
labelKey: "settings.shortcutFind",
scope: "editor",
defaultShortcut: "Mod+F",
},
{
id: "replace",
labelKey: "settings.shortcutReplace",
scope: "editor",
defaultShortcut: "Mod+R",
},
{
id: "refreshData",
labelKey: "settings.shortcutRefreshData",
scope: "global",
defaultShortcut: "F5",
},
{
id: "toggleResultsPane",
labelKey: "settings.shortcutToggleResultsPane",
scope: "global",
defaultShortcut: "",
},
{
id: "toggleTranspose",
labelKey: "settings.shortcutToggleTranspose",
scope: "grid",
defaultShortcut: "Tab",
},
{
id: "cancelSearch",
labelKey: "settings.shortcutCancelSearch",
scope: "search",
defaultShortcut: "Escape",
},
{
id: "toggleSidebar",
labelKey: "settings.shortcutToggleSidebar",
scope: "global",
defaultShortcut: "Mod+B",
},
{
id: "toggleZenMode",
labelKey: "settings.shortcutToggleZenMode",
scope: "global",
defaultShortcut: "Shift+Mod+F12",
},
{
id: "copySidebarSelection",
labelKey: "settings.shortcutCopySidebarSelection",
scope: "sidebar",
defaultShortcut: "Mod+C",
},
{
id: "pasteSidebarSelection",
labelKey: "settings.shortcutPasteSidebarSelection",
scope: "sidebar",
defaultShortcut: "Mod+V",
},
{
id: "editSidebarConnection",
labelKey: "settings.shortcutEditSidebarConnection",
scope: "sidebar",
defaultShortcut: "Mod+E",
},
{
id: "openDataInNewTab",
labelKey: "settings.shortcutOpenDataInNewTab",
scope: "sidebar",
defaultShortcut: "Alt",
inputKind: "modifier-only",
},
{
id: "viewTableDdl",
labelKey: "settings.shortcutViewTableDdl",
scope: "sidebar",
defaultShortcut: "Shift+Mod+D",
},
{
id: "sendSelectionToAi",
labelKey: "settings.shortcutSendSelectionToAi",
scope: "editor",
defaultShortcut: "Mod+Shift+A",
},
{
id: "sqlIntentionActions",
labelKey: "settings.shortcutSqlIntentionActions",
scope: "editor",
defaultShortcut: "Shift+Mod+Enter",
},
];
export const DEFAULT_SHORTCUT_SETTINGS: ShortcutSettings = Object.fromEntries(SHORTCUT_DEFINITIONS.map((definition) => [definition.id, definition.defaultShortcut])) as ShortcutSettings;
const modifierOnlyShortcuts = new Set(["Alt", "Shift", "Mod", "Ctrl", "Meta"]);
export function normalizeModifierOnlyShortcut(shortcut: string, fallback = ""): string {
const normalized = shortcut.trim() === "Control" ? "Ctrl" : shortcut.trim();
if (normalized === "") return "";
return modifierOnlyShortcuts.has(normalized) ? normalized : fallback;
}
function hasExplicitShortcut(settings: Partial<ShortcutSettings> | undefined, actionId: ShortcutActionId): boolean {
return !!settings && Object.prototype.hasOwnProperty.call(settings, actionId) && typeof settings[actionId] === "string";
}
function shortcutsUseSameKeys(first: string, second: string, platform = globalThis.navigator?.platform || ""): boolean {
return !!first && !!second && formatShortcut(first, platform).toLowerCase() === formatShortcut(second, platform).toLowerCase();
}
function shortcutDefaultForPlatform(definition: ShortcutDefinition, platform: string): string {
if (definition.id === "closeOtherTabs") return closeOtherTabsDefaultShortcut(platform);
if (definition.id === "navigateTabHistoryBack") return tabNavigationHistoryDefaultShortcut("back", platform);
if (definition.id === "navigateTabHistoryForward") return tabNavigationHistoryDefaultShortcut("forward", platform);
return definition.defaultShortcut;
}
export function needsTabNavigationHistoryShortcutMigration(settings?: Partial<ShortcutSettings>): boolean {
return !!settings && TAB_NAVIGATION_HISTORY_ACTIONS.some((actionId) => !hasExplicitShortcut(settings, actionId));
}
export function normalizeShortcutSettings(settings?: Partial<ShortcutSettings>, platform = globalThis.navigator?.platform || ""): ShortcutSettings {
const normalized = Object.fromEntries(
SHORTCUT_DEFINITIONS.map((definition) => {
const configuredValue = settings?.[definition.id];
const platformDefault = shortcutDefaultForPlatform(definition, platform);
let configured = typeof configuredValue === "string" ? configuredValue : platformDefault;
// 云同步会把另一平台的默认值当作显式配置带过来。平台默认集合内的值视为
// 未自定义,按本机平台重新解析;用户真正自定义的其他组合原样保留
const platformDefaults = PLATFORM_DEFAULT_SHORTCUTS[definition.id];
if (platformDefaults?.has(configured)) {
configured = platformDefault;
}
// Meta+W was the old macOS-only default. Treat that exact value as a
// legacy default so existing Windows/Linux settings adopt Ctrl+W.
if (definition.id === "closeTab" && configured === LEGACY_CLOSE_TAB_DEFAULT) {
configured = definition.defaultShortcut;
}
const normalized = definition.inputKind === "modifier-only" ? normalizeModifierOnlyShortcut(configured, definition.defaultShortcut) : configured;
return [definition.id, normalized];
}),
) as ShortcutSettings;
if (!hasExplicitShortcut(settings, "editTableStructure") && settings?.copyCurrentRow === LEGACY_COPY_CURRENT_ROW_DEFAULT) {
normalized.editTableStructure = LEGACY_COPY_CURRENT_ROW_DEFAULT;
normalized.copyCurrentRow = "";
}
for (const actionId of TAB_NAVIGATION_HISTORY_ACTIONS) {
if (hasExplicitShortcut(settings, actionId)) continue;
const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === actionId);
if (!definition) continue;
const defaultShortcut = normalized[actionId];
const occupiedByExistingAction = SHORTCUT_DEFINITIONS.some((item) => item.id !== actionId && item.scope === definition.scope && hasExplicitShortcut(settings, item.id) && shortcutsUseSameKeys(normalized[item.id], defaultShortcut, platform));
if (occupiedByExistingAction) normalized[actionId] = "";
}
return normalized;
}
export function shortcutToCodeMirrorKey(shortcut: string): string {
return parseShortcutStrokes(shortcut)
.map((parts) =>
parts
.map((part) => (part.length === 1 ? part.toLowerCase() : part))
.map((part) => (part === "Plus" ? "+" : part))
.join("-"),
)
.join(" ");
}
export function formatShortcut(shortcut: string, platform = globalThis.navigator?.platform || ""): string {
const isMac = platform.toLowerCase().includes("mac");
return shortcutDisplayParts(shortcut, platform)
.map((part) => {
if (part === "Mod") return isMac ? "Cmd" : "Ctrl";
if (part === "Meta") return isMac ? "Cmd" : "Meta";
if (part === "Plus") return "+";
return part;
})
.join("+");
}
export function findShortcutConflict(actionId: ShortcutActionId, shortcut: string, shortcuts: ShortcutSettings, platform = globalThis.navigator?.platform || ""): ShortcutActionId | null {
if (!shortcut) return null;
const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === actionId);
if (!definition) return null;
const conflict = SHORTCUT_DEFINITIONS.find((item) => item.id !== actionId && item.scope === definition.scope && shortcutsUseSameKeys(shortcuts[item.id], shortcut, platform));
return conflict?.id ?? null;
}