322 lines
10 KiB
TypeScript
322 lines
10 KiB
TypeScript
import type { MutableRefObject } from 'react'
|
|
import type { SidebarFilter } from '../types'
|
|
import {
|
|
APP_COMMAND_DEFINITIONS,
|
|
type AppCommandId,
|
|
type AppCommandDefinition,
|
|
} from './appCommandCatalog'
|
|
import type { ViewMode } from './useViewMode'
|
|
import type { NoteListMultiSelectionCommands } from '../components/note-list/multiSelectionCommands'
|
|
|
|
export const APP_COMMAND_EVENT_NAME = 'laputa:dispatch-command'
|
|
|
|
export {
|
|
APP_COMMAND_IDS,
|
|
findShortcutCommandId,
|
|
findShortcutCommandIdForEvent,
|
|
isAppCommandId,
|
|
isNativeMenuCommandId,
|
|
} from './appCommandCatalog'
|
|
export type { AppCommandDefinition, AppCommandId, AppCommandShortcutCombo } from './appCommandCatalog'
|
|
|
|
export type AppCommandDispatchSource =
|
|
| 'direct'
|
|
| 'renderer-keyboard'
|
|
| 'native-menu'
|
|
| 'app-event'
|
|
|
|
type SuppressedShortcutSource = Extract<AppCommandDispatchSource, 'renderer-keyboard'>
|
|
|
|
export interface AppCommandHandlers {
|
|
onSetViewMode: (mode: ViewMode) => void
|
|
onCreateNote: () => void
|
|
onCreateType?: () => void
|
|
onQuickOpen: () => void
|
|
onSave: () => void
|
|
onOpenSettings: () => void
|
|
onToggleInspector: () => void
|
|
onCommandPalette: () => void
|
|
onZoomIn: () => void
|
|
onZoomOut: () => void
|
|
onZoomReset: () => void
|
|
onToggleOrganized?: (path: string) => void
|
|
onToggleFavorite?: (path: string) => void
|
|
onArchiveNote: (path: string) => void
|
|
onDeleteNote: (path: string) => void
|
|
onFindInNote?: () => void
|
|
onReplaceInNote?: () => void
|
|
onPastePlainText: () => void
|
|
onSearch: () => void
|
|
onToggleRawEditor?: () => void
|
|
onToggleDiff?: () => void
|
|
onToggleAIChat?: () => void
|
|
onToggleTableOfContents?: () => void
|
|
onGoBack?: () => void
|
|
onGoForward?: () => void
|
|
onCheckForUpdates?: () => void
|
|
onSelectFilter?: (filter: SidebarFilter) => void
|
|
onOpenVault?: () => void
|
|
onRemoveActiveVault?: () => void
|
|
onRestoreGettingStarted?: () => void
|
|
onAddRemote?: () => void
|
|
onCommitPush?: () => void
|
|
onPull?: () => void
|
|
onResolveConflicts?: () => void
|
|
onViewChanges?: () => void
|
|
onInstallMcp?: () => void
|
|
onOpenInNewWindow?: () => void
|
|
onReloadVault?: () => void
|
|
onRepairVault?: () => void
|
|
onRestoreDeletedNote?: () => void
|
|
activeTabPathRef: MutableRefObject<string | null>
|
|
multiSelectionCommandRef?: MutableRefObject<NoteListMultiSelectionCommands | null>
|
|
}
|
|
|
|
type SimpleHandlerKey = keyof Pick<
|
|
AppCommandHandlers,
|
|
| 'onOpenSettings'
|
|
| 'onCheckForUpdates'
|
|
| 'onCreateNote'
|
|
| 'onCreateType'
|
|
| 'onQuickOpen'
|
|
| 'onSave'
|
|
| 'onFindInNote'
|
|
| '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 = keyof Pick<
|
|
AppCommandHandlers,
|
|
'onToggleOrganized' | 'onToggleFavorite' | 'onArchiveNote' | 'onDeleteNote'
|
|
>
|
|
|
|
type SimpleHandlerExecutor = (handlers: AppCommandHandlers) => void
|
|
type ActiveTabHandlerExecutor = (handlers: AppCommandHandlers, path: string) => void
|
|
|
|
const SIMPLE_HANDLER_EXECUTORS: readonly [SimpleHandlerKey, SimpleHandlerExecutor][] = [
|
|
['onOpenSettings', (handlers) => handlers.onOpenSettings()],
|
|
['onCheckForUpdates', (handlers) => handlers.onCheckForUpdates?.()],
|
|
['onCreateNote', (handlers) => handlers.onCreateNote()],
|
|
['onCreateType', (handlers) => handlers.onCreateType?.()],
|
|
['onQuickOpen', (handlers) => handlers.onQuickOpen()],
|
|
['onSave', (handlers) => handlers.onSave()],
|
|
['onFindInNote', (handlers) => handlers.onFindInNote?.()],
|
|
['onReplaceInNote', (handlers) => handlers.onReplaceInNote?.()],
|
|
['onPastePlainText', (handlers) => handlers.onPastePlainText()],
|
|
['onSearch', (handlers) => handlers.onSearch()],
|
|
['onToggleRawEditor', (handlers) => handlers.onToggleRawEditor?.()],
|
|
['onToggleDiff', (handlers) => handlers.onToggleDiff?.()],
|
|
['onToggleInspector', (handlers) => handlers.onToggleInspector()],
|
|
['onToggleAIChat', (handlers) => handlers.onToggleAIChat?.()],
|
|
['onToggleTableOfContents', (handlers) => handlers.onToggleTableOfContents?.()],
|
|
['onCommandPalette', (handlers) => handlers.onCommandPalette()],
|
|
['onZoomIn', (handlers) => handlers.onZoomIn()],
|
|
['onZoomOut', (handlers) => handlers.onZoomOut()],
|
|
['onZoomReset', (handlers) => handlers.onZoomReset()],
|
|
['onGoBack', (handlers) => handlers.onGoBack?.()],
|
|
['onGoForward', (handlers) => handlers.onGoForward?.()],
|
|
['onOpenVault', (handlers) => handlers.onOpenVault?.()],
|
|
['onRemoveActiveVault', (handlers) => handlers.onRemoveActiveVault?.()],
|
|
['onRestoreGettingStarted', (handlers) => handlers.onRestoreGettingStarted?.()],
|
|
['onAddRemote', (handlers) => handlers.onAddRemote?.()],
|
|
['onCommitPush', (handlers) => handlers.onCommitPush?.()],
|
|
['onPull', (handlers) => handlers.onPull?.()],
|
|
['onResolveConflicts', (handlers) => handlers.onResolveConflicts?.()],
|
|
['onViewChanges', (handlers) => handlers.onViewChanges?.()],
|
|
['onInstallMcp', (handlers) => handlers.onInstallMcp?.()],
|
|
['onReloadVault', (handlers) => handlers.onReloadVault?.()],
|
|
['onRepairVault', (handlers) => handlers.onRepairVault?.()],
|
|
['onOpenInNewWindow', (handlers) => handlers.onOpenInNewWindow?.()],
|
|
['onRestoreDeletedNote', (handlers) => handlers.onRestoreDeletedNote?.()],
|
|
]
|
|
|
|
const ACTIVE_TAB_HANDLER_EXECUTORS: readonly [ActiveTabHandlerKey, ActiveTabHandlerExecutor][] = [
|
|
['onToggleOrganized', (handlers, path) => handlers.onToggleOrganized?.(path)],
|
|
['onToggleFavorite', (handlers, path) => handlers.onToggleFavorite?.(path)],
|
|
['onArchiveNote', (handlers, path) => handlers.onArchiveNote(path)],
|
|
['onDeleteNote', (handlers, path) => handlers.onDeleteNote(path)],
|
|
]
|
|
|
|
function runSimpleHandler(handler: SimpleHandlerKey, handlers: AppCommandHandlers): void {
|
|
const executor = SIMPLE_HANDLER_EXECUTORS.find(([key]) => key === handler)?.[1]
|
|
executor?.(handlers)
|
|
}
|
|
|
|
function runActiveTabHandler(handler: ActiveTabHandlerKey, handlers: AppCommandHandlers, path: string): void {
|
|
const executor = ACTIVE_TAB_HANDLER_EXECUTORS.find(([key]) => key === handler)?.[1]
|
|
executor?.(handlers, path)
|
|
}
|
|
|
|
const SHORTCUT_ECHO_DEDUPE_WINDOW_MS = 150
|
|
let lastCommandDispatch:
|
|
| {
|
|
id: AppCommandId
|
|
source: AppCommandDispatchSource
|
|
timestamp: number
|
|
}
|
|
| null = null
|
|
|
|
let lastSuppressedShortcutCommand:
|
|
| {
|
|
id: AppCommandId
|
|
source: SuppressedShortcutSource
|
|
timestamp: number
|
|
}
|
|
| null = null
|
|
|
|
function now(): number {
|
|
return globalThis.performance?.now?.() ?? Date.now()
|
|
}
|
|
|
|
function isShortcutEchoPair(a: AppCommandDispatchSource, b: AppCommandDispatchSource): boolean {
|
|
return (
|
|
(a === 'renderer-keyboard' && b === 'native-menu') ||
|
|
(a === 'native-menu' && b === 'renderer-keyboard')
|
|
)
|
|
}
|
|
|
|
function shouldSuppressDuplicateCommand(
|
|
id: AppCommandId,
|
|
source: AppCommandDispatchSource,
|
|
currentTimestamp: number,
|
|
): boolean {
|
|
if (!lastCommandDispatch || lastCommandDispatch.id !== id) return false
|
|
if (!isShortcutEchoPair(source, lastCommandDispatch.source)) return false
|
|
return currentTimestamp - lastCommandDispatch.timestamp <= SHORTCUT_ECHO_DEDUPE_WINDOW_MS
|
|
}
|
|
|
|
function shouldSuppressShortcutEchoAfterKeyboardYield(
|
|
id: AppCommandId,
|
|
source: AppCommandDispatchSource,
|
|
currentTimestamp: number,
|
|
): boolean {
|
|
if (source !== 'native-menu') return false
|
|
if (!lastSuppressedShortcutCommand || lastSuppressedShortcutCommand.id !== id) return false
|
|
return currentTimestamp - lastSuppressedShortcutCommand.timestamp <= SHORTCUT_ECHO_DEDUPE_WINDOW_MS
|
|
}
|
|
|
|
function dispatchActiveTabCommand(
|
|
pathRef: MutableRefObject<string | null>,
|
|
handler: (path: string) => void,
|
|
): boolean {
|
|
const path = pathRef.current
|
|
if (!path) return false
|
|
handler(path)
|
|
return true
|
|
}
|
|
|
|
function dispatchMultiSelectionCommand(
|
|
selectionRef: MutableRefObject<NoteListMultiSelectionCommands | null> | undefined,
|
|
handler: ActiveTabHandlerKey,
|
|
): boolean | null {
|
|
const selection = selectionRef?.current
|
|
if (!selection || selection.selectedPaths.length <= 1) return null
|
|
|
|
if (handler === 'onDeleteNote') {
|
|
selection.deleteSelected?.()
|
|
return !!selection.deleteSelected
|
|
}
|
|
|
|
if (handler === 'onToggleOrganized') {
|
|
selection.organizeSelected?.()
|
|
return !!selection.organizeSelected
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
function dispatchDefinition(
|
|
definition: AppCommandDefinition,
|
|
handlers: AppCommandHandlers,
|
|
): boolean {
|
|
switch (definition.route.kind) {
|
|
case 'view-mode':
|
|
handlers.onSetViewMode(definition.route.value)
|
|
return true
|
|
case 'filter':
|
|
handlers.onSelectFilter?.(definition.route.value)
|
|
return true
|
|
case 'handler': {
|
|
runSimpleHandler(definition.route.handler as SimpleHandlerKey, handlers)
|
|
return true
|
|
}
|
|
case 'active-tab-handler': {
|
|
const handler = definition.route.handler
|
|
const multiSelectionResult = dispatchMultiSelectionCommand(
|
|
handlers.multiSelectionCommandRef,
|
|
handler as ActiveTabHandlerKey,
|
|
)
|
|
if (multiSelectionResult !== null) {
|
|
return multiSelectionResult
|
|
}
|
|
|
|
return dispatchActiveTabCommand(
|
|
handlers.activeTabPathRef,
|
|
(path) => runActiveTabHandler(handler as ActiveTabHandlerKey, handlers, path),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
export function dispatchAppCommand(id: AppCommandId, handlers: AppCommandHandlers): boolean {
|
|
return executeAppCommand(id, handlers, 'direct')
|
|
}
|
|
|
|
export function executeAppCommand(
|
|
id: AppCommandId,
|
|
handlers: AppCommandHandlers,
|
|
source: AppCommandDispatchSource,
|
|
): boolean {
|
|
const timestamp = now()
|
|
if (shouldSuppressShortcutEchoAfterKeyboardYield(id, source, timestamp)) {
|
|
return false
|
|
}
|
|
if (shouldSuppressDuplicateCommand(id, source, timestamp)) {
|
|
return false
|
|
}
|
|
|
|
const definition = Reflect.get(APP_COMMAND_DEFINITIONS, id) as AppCommandDefinition
|
|
const dispatched = dispatchDefinition(definition, handlers)
|
|
if (dispatched) {
|
|
lastCommandDispatch = { id, source, timestamp }
|
|
}
|
|
return dispatched
|
|
}
|
|
|
|
export function recordSuppressedShortcutCommand(
|
|
id: AppCommandId,
|
|
source: SuppressedShortcutSource = 'renderer-keyboard',
|
|
): void {
|
|
lastSuppressedShortcutCommand = { id, source, timestamp: now() }
|
|
}
|
|
|
|
export function resetAppCommandDispatchStateForTests(): void {
|
|
lastCommandDispatch = null
|
|
lastSuppressedShortcutCommand = null
|
|
}
|