Files
tolaria/src/hooks/appCommandCatalog.ts
2026-05-26 14:56:39 +02:00

433 lines
13 KiB
TypeScript

import appCommandManifest from '../shared/appCommandManifest.json' with { type: 'json' }
import type { SidebarFilter } from '../types'
import { isMac } from '../utils/platform'
import type { ViewMode } from './useViewMode'
type AppCommandKey = keyof typeof appCommandManifest.commands
type ShortcutEventLike = Pick<KeyboardEvent, 'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey' | 'key' | 'code'>
export type AppCommandShortcutCombo =
| 'command-or-ctrl'
| 'command-or-ctrl-shift'
| 'command-shift'
export type AppCommandDeterministicQaMode =
| 'renderer-shortcut-event'
| 'native-menu-command'
export interface AppCommandDeterministicQaDefinition {
preferredMode: AppCommandDeterministicQaMode
supportsRendererShortcutEvent: boolean
supportsNativeMenuCommand: boolean
requiresManualNativeAcceleratorQa: boolean
}
export interface AppCommandShortcutEventOptions {
preferControl?: boolean
}
export type AppCommandShortcutEventInit = Pick<
KeyboardEventInit,
'altKey' | 'bubbles' | 'cancelable' | 'code' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey'
>
type SimpleHandlerKey =
| 'onOpenSettings'
| 'onCheckForUpdates'
| 'onCreateNote'
| 'onCreateType'
| 'onQuickOpen'
| 'onSave'
| 'onFindInNote'
| 'onUndo'
| 'onRedo'
| 'onReplaceInNote'
| 'onPastePlainText'
| 'onSearch'
| 'onToggleRawEditor'
| 'onToggleDiff'
| 'onToggleInspector'
| 'onToggleAIChat'
| 'onToggleTableOfContents'
| 'onCommandPalette'
| 'onZoomIn'
| 'onZoomOut'
| 'onZoomReset'
| 'onGoBack'
| 'onGoForward'
| 'onOpenVault'
| 'onRemoveActiveVault'
| 'onRestoreGettingStarted'
| 'onAddRemote'
| 'onCommitPush'
| 'onPull'
| 'onResolveConflicts'
| 'onViewChanges'
| 'onInstallMcp'
| 'onReloadVault'
| 'onRepairVault'
| 'onOpenInNewWindow'
| 'onRestoreDeletedNote'
type ActiveTabHandlerKey =
| 'onToggleOrganized'
| 'onToggleFavorite'
| 'onArchiveNote'
| 'onDeleteNote'
type AppCommandRoute =
| { kind: 'view-mode'; value: ViewMode }
| { kind: 'filter'; value: SidebarFilter }
| { kind: 'handler'; handler: SimpleHandlerKey }
| { kind: 'active-tab-handler'; handler: ActiveTabHandlerKey }
interface AppCommandShortcutDefinition {
combo: AppCommandShortcutCombo
key: string
aliases?: string[]
code?: string
display: string
}
interface AppCommandManifestShortcutDefinition extends AppCommandShortcutDefinition {
accelerator: string
requiresManualNativeAcceleratorQa?: boolean
}
interface AppCommandManifestDefinition {
id: string
route: AppCommandRoute
menuOwned: boolean
shortcut?: AppCommandManifestShortcutDefinition
preferredShortcutQaMode?: AppCommandDeterministicQaMode
}
export interface AppCommandDefinition {
route: AppCommandRoute
menuOwned: boolean
shortcut?: AppCommandShortcutDefinition
preferredShortcutQaMode?: AppCommandDeterministicQaMode
}
type PlatformLabel = string | {
macos?: string
windows?: string
linux?: string
default: string
}
type AppCommandMenuManifestItem =
| { kind: 'separator' }
| {
kind: 'command'
command: AppCommandKey
id?: string
label: PlatformLabel
accelerator?: string | null
enabled?: boolean
}
| {
kind: 'menu-event'
id: string
label: PlatformLabel
accelerator?: string | null
enabled?: boolean
}
interface AppCommandMenuManifestSection {
label: string
items: AppCommandMenuManifestItem[]
}
export type AppCommandMenuItem =
| { kind: 'separator' }
| {
kind: 'command'
commandId: string
menuItemId: string
label: string
shortcut?: string
enabled?: boolean
}
type AppCommandMenuStateGroupReference =
| { command: AppCommandKey }
| { id: string }
type AppCommandMenuStateGroupName = keyof typeof appCommandManifest.menuStateGroups
const APP_COMMAND_MANIFEST_COMMANDS = appCommandManifest.commands as Record<AppCommandKey, AppCommandManifestDefinition>
const APP_COMMAND_MANIFEST_MENUS = appCommandManifest.menus as AppCommandMenuManifestSection[]
const APP_COMMAND_MANIFEST_APP_MENU = appCommandManifest.appMenu as AppCommandMenuManifestItem[]
const APP_COMMAND_MANIFEST_STATE_GROUPS = appCommandManifest.menuStateGroups as Record<
AppCommandMenuStateGroupName,
AppCommandMenuStateGroupReference[]
>
export const APP_COMMAND_IDS = Object.fromEntries(
Object.entries(APP_COMMAND_MANIFEST_COMMANDS).map(([key, command]) => [key, command.id]),
) as { readonly [K in AppCommandKey]: string }
export type AppCommandId = (typeof APP_COMMAND_IDS)[AppCommandKey]
const APP_COMMAND_SET = new Set<string>(Object.values(APP_COMMAND_IDS))
function toShortcutDefinition(
shortcut: AppCommandManifestShortcutDefinition | undefined,
): AppCommandShortcutDefinition | undefined {
if (!shortcut) return undefined
return {
combo: shortcut.combo,
key: shortcut.key,
aliases: shortcut.aliases,
code: shortcut.code,
display: shortcut.display,
}
}
export const APP_COMMAND_DEFINITIONS = Object.fromEntries(
Object.values(APP_COMMAND_MANIFEST_COMMANDS).map((command) => [
command.id,
{
route: command.route,
menuOwned: command.menuOwned,
shortcut: toShortcutDefinition(command.shortcut),
preferredShortcutQaMode: command.preferredShortcutQaMode,
},
]),
) as Record<AppCommandId, AppCommandDefinition>
function resolvePlatformLabel(label: PlatformLabel): string {
if (typeof label === 'string') return label
if (isMac() && label.macos) return label.macos
return label.default
}
function formatAcceleratorDisplay(accelerator: string): string {
const commandPrefix = isMac() ? '⌘' : 'Ctrl+'
const commandShiftPrefix = isMac() ? '⌘⇧' : 'Ctrl+Shift+'
return accelerator
.replaceAll('CmdOrCtrl+Shift+', commandShiftPrefix)
.replaceAll('CmdOrCtrl+', commandPrefix)
.replaceAll('Backspace', isMac() ? '⌫' : 'Backspace')
.replaceAll('Delete', isMac() ? '⌦' : 'Delete')
.replaceAll('Left', isMac() ? '←' : 'Left')
.replaceAll('Right', isMac() ? '→' : 'Right')
.replaceAll('Enter', isMac() ? '↵' : 'Enter')
}
function menuShortcutForCommand(
item: Extract<AppCommandMenuManifestItem, { kind: 'command' }>,
command: AppCommandManifestDefinition,
): string | undefined {
if (typeof item.accelerator === 'string') return formatAcceleratorDisplay(item.accelerator)
if (command.shortcut) return formatShortcutDisplay(command.shortcut)
return undefined
}
function toMenuItem(item: AppCommandMenuManifestItem): AppCommandMenuItem {
if (item.kind === 'separator') return { kind: 'separator' }
if (item.kind === 'menu-event') {
return {
kind: 'command',
commandId: item.id,
menuItemId: item.id,
label: resolvePlatformLabel(item.label),
shortcut: typeof item.accelerator === 'string'
? formatAcceleratorDisplay(item.accelerator)
: undefined,
enabled: item.enabled,
}
}
const command = Reflect.get(APP_COMMAND_MANIFEST_COMMANDS, item.command) as AppCommandManifestDefinition
return {
kind: 'command',
commandId: command.id,
menuItemId: item.id ?? command.id,
label: resolvePlatformLabel(item.label),
shortcut: menuShortcutForCommand(item, command),
enabled: item.enabled,
}
}
function menuCommandIds(items: AppCommandMenuItem[]): string[] {
return items.flatMap(item => item.kind === 'command' ? [item.commandId] : [])
}
export const APP_COMMAND_MENU_SECTIONS = APP_COMMAND_MANIFEST_MENUS.map(section => ({
label: section.label,
items: section.items.map(toMenuItem),
}))
const APP_COMMAND_APP_MENU_ITEMS = APP_COMMAND_MANIFEST_APP_MENU.map(toMenuItem)
export const APP_COMMAND_MENU_STATE_GROUPS = Object.fromEntries(
Object.entries(APP_COMMAND_MANIFEST_STATE_GROUPS).map(([name, references]) => [
name,
references.map(reference => 'command' in reference
? (Reflect.get(APP_COMMAND_MANIFEST_COMMANDS, reference.command) as AppCommandManifestDefinition).id
: reference.id),
]),
) as Record<AppCommandMenuStateGroupName, string[]>
const NATIVE_MENU_COMMAND_SET = new Set<string>(
[
...APP_COMMAND_MENU_SECTIONS.flatMap(section => menuCommandIds(section.items)),
...menuCommandIds(APP_COMMAND_APP_MENU_ITEMS),
].filter(id => APP_COMMAND_SET.has(id)),
)
const MANUAL_NATIVE_ACCELERATOR_QA_COMMAND_SET = new Set<AppCommandId>(
Object.values(APP_COMMAND_MANIFEST_COMMANDS)
.filter(command => command.shortcut?.requiresManualNativeAcceleratorQa)
.map(command => command.id),
)
const shortcutKeyMaps = {
'command-or-ctrl': new Map<string, AppCommandId>(),
'command-or-ctrl-shift': new Map<string, AppCommandId>(),
'command-shift': new Map<string, AppCommandId>(),
} satisfies Record<AppCommandShortcutCombo, Map<string, AppCommandId>>
const shortcutCodeMaps = {
'command-or-ctrl': new Map<string, AppCommandId>(),
'command-or-ctrl-shift': new Map<string, AppCommandId>(),
'command-shift': new Map<string, AppCommandId>(),
} satisfies Record<AppCommandShortcutCombo, Map<string, AppCommandId>>
const COMMAND_ONLY_COMBOS: readonly AppCommandShortcutCombo[] = ['command-or-ctrl']
const COMMAND_SHIFT_COMBOS: readonly AppCommandShortcutCombo[] = ['command-shift', 'command-or-ctrl-shift']
const COMMAND_OR_CTRL_SHIFT_COMBOS: readonly AppCommandShortcutCombo[] = ['command-or-ctrl-shift']
const NO_SHORTCUT_COMBOS: readonly AppCommandShortcutCombo[] = []
function normalizeShortcutKey(key: string): string {
return key.length === 1 ? key.toLowerCase() : key
}
function isPlatformRedoAlternate(event: ShortcutEventLike): boolean {
return !isMac()
&& !event.altKey
&& !event.metaKey
&& event.ctrlKey
&& !event.shiftKey
&& normalizeShortcutKey(event.key) === 'y'
}
for (const [id, definition] of Object.entries(APP_COMMAND_DEFINITIONS) as Array<[AppCommandId, AppCommandDefinition]>) {
const shortcut = definition.shortcut
if (!shortcut) continue
const shortcutKeyMap = Reflect.get(shortcutKeyMaps, shortcut.combo) as Map<string, AppCommandId>
shortcutKeyMap.set(normalizeShortcutKey(shortcut.key), id)
for (const alias of shortcut.aliases ?? []) {
shortcutKeyMap.set(normalizeShortcutKey(alias), id)
}
if (shortcut.code) {
const shortcutCodeMap = Reflect.get(shortcutCodeMaps, shortcut.combo) as Map<string, AppCommandId>
shortcutCodeMap.set(shortcut.code, id)
}
}
export function isAppCommandId(value: string): value is AppCommandId {
return APP_COMMAND_SET.has(value)
}
export function isNativeMenuCommandId(value: string): value is AppCommandId {
return NATIVE_MENU_COMMAND_SET.has(value)
}
export function getDeterministicShortcutQaDefinition(
id: AppCommandId,
): AppCommandDeterministicQaDefinition | null {
const definition = Reflect.get(APP_COMMAND_DEFINITIONS, id) as AppCommandDefinition
if (!definition.shortcut) return null
return {
preferredMode:
definition.preferredShortcutQaMode
?? (definition.menuOwned ? 'native-menu-command' : 'renderer-shortcut-event'),
supportsRendererShortcutEvent: true,
supportsNativeMenuCommand: definition.menuOwned,
requiresManualNativeAcceleratorQa: MANUAL_NATIVE_ACCELERATOR_QA_COMMAND_SET.has(id),
}
}
export function getShortcutEventInit(
id: AppCommandId,
options: AppCommandShortcutEventOptions = {},
): AppCommandShortcutEventInit | null {
const shortcut = (Reflect.get(APP_COMMAND_DEFINITIONS, id) as AppCommandDefinition).shortcut
if (!shortcut) return null
const useControl = options.preferControl ?? false
return {
key: shortcut.key,
code: shortcut.code,
altKey: false,
bubbles: true,
cancelable: true,
ctrlKey: useControl,
metaKey: !useControl,
shiftKey: shortcut.combo !== 'command-or-ctrl',
}
}
export function shortcutCombosForEvent({
altKey,
ctrlKey,
metaKey,
shiftKey,
}: Pick<ShortcutEventLike, 'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey'>): readonly AppCommandShortcutCombo[] {
if (altKey || (!metaKey && !ctrlKey)) return NO_SHORTCUT_COMBOS
if (isMac() && ctrlKey) return NO_SHORTCUT_COMBOS
if (shiftKey) {
return metaKey && !ctrlKey ? COMMAND_SHIFT_COMBOS : COMMAND_OR_CTRL_SHIFT_COMBOS
}
return COMMAND_ONLY_COMBOS
}
export function findShortcutCommandId(
combo: AppCommandShortcutCombo,
key: string,
code?: string,
): AppCommandId | null {
if (code) {
const codeMatch = (Reflect.get(shortcutCodeMaps, combo) as Map<string, AppCommandId>).get(code)
if (codeMatch) return codeMatch
}
return (Reflect.get(shortcutKeyMaps, combo) as Map<string, AppCommandId>).get(normalizeShortcutKey(key)) ?? null
}
export function findShortcutCommandIdForEvent(event: ShortcutEventLike): AppCommandId | null {
if (isPlatformRedoAlternate(event)) return APP_COMMAND_IDS.editRedo
for (const combo of shortcutCombosForEvent(event)) {
const commandId = findShortcutCommandId(combo, event.key, event.code)
if (commandId) return commandId
}
return null
}
export function formatShortcutDisplay(
shortcut: Pick<AppCommandShortcutDefinition, 'display'>,
): string {
if (isMac()) return shortcut.display
return shortcut.display
.replaceAll('⌘⇧', 'Ctrl+Shift+')
.replaceAll('⌘', 'Ctrl+')
.replaceAll('⌫', 'Backspace')
.replaceAll('⌦', 'Delete')
.replaceAll('←', 'Left')
.replaceAll('→', 'Right')
.replaceAll('↵', 'Enter')
}
export function getAppCommandShortcutDisplay(id: AppCommandId): string | undefined {
const shortcut = (Reflect.get(APP_COMMAND_DEFINITIONS, id) as AppCommandDefinition).shortcut
return shortcut ? formatShortcutDisplay(shortcut) : undefined
}