308 lines
9.0 KiB
TypeScript
308 lines
9.0 KiB
TypeScript
import { useCallback, useEffect } from 'react';
|
|
import { useLocation, useNavigate } from 'react-router-dom';
|
|
import type { ExecutorConfig } from 'shared/types';
|
|
|
|
// Define available keyboard shortcuts
|
|
export interface KeyboardShortcut {
|
|
key: string;
|
|
description: string;
|
|
action: (context?: KeyboardShortcutContext) => void;
|
|
requiresModifier?: boolean;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
export interface KeyboardShortcutContext {
|
|
navigate?: ReturnType<typeof useNavigate>;
|
|
closeDialog?: () => void;
|
|
onC?: () => void;
|
|
currentPath?: string;
|
|
hasOpenDialog?: boolean;
|
|
location?: ReturnType<typeof useLocation>;
|
|
stopExecution?: () => void;
|
|
newAttempt?: () => void;
|
|
onEnter?: () => void;
|
|
ignoreEscape?: boolean;
|
|
}
|
|
|
|
// Centralized shortcut definitions
|
|
export const createKeyboardShortcuts = (
|
|
context: KeyboardShortcutContext
|
|
): Record<string, KeyboardShortcut> => ({
|
|
Escape: {
|
|
key: 'Escape',
|
|
description: 'Go back or close dialog',
|
|
action: () => {
|
|
if (context.ignoreEscape) {
|
|
return;
|
|
}
|
|
|
|
// If there's an open dialog, close it
|
|
if (context.hasOpenDialog && context.closeDialog) {
|
|
context.closeDialog();
|
|
return;
|
|
}
|
|
|
|
// Otherwise, navigate back
|
|
if (context.navigate) {
|
|
const currentPath =
|
|
context.currentPath || context.location?.pathname || '/';
|
|
|
|
// Navigate back based on current path
|
|
if (
|
|
currentPath.includes('/tasks/') &&
|
|
!currentPath.endsWith('/tasks')
|
|
) {
|
|
// From task details, go back to project tasks
|
|
const projectPath = currentPath.split('/tasks/')[0] + '/tasks';
|
|
context.navigate(projectPath);
|
|
} else if (
|
|
currentPath.includes('/projects/') &&
|
|
currentPath.includes('/tasks')
|
|
) {
|
|
// From project tasks, go back to projects
|
|
context.navigate('/projects');
|
|
} else if (currentPath !== '/' && currentPath !== '/projects') {
|
|
// Default: go to projects page
|
|
context.navigate('/projects');
|
|
}
|
|
}
|
|
},
|
|
},
|
|
Enter: {
|
|
key: 'Enter',
|
|
description: 'Enter or submit',
|
|
action: () => {
|
|
if (context.onEnter) {
|
|
context.onEnter();
|
|
}
|
|
},
|
|
},
|
|
KeyC: {
|
|
key: 'c',
|
|
description: 'Create new task',
|
|
action: () => {
|
|
if (context.onC) {
|
|
context.onC();
|
|
}
|
|
},
|
|
},
|
|
KeyS: {
|
|
key: 's',
|
|
description: 'Stop all executions',
|
|
action: () => {
|
|
context.stopExecution && context.stopExecution();
|
|
},
|
|
},
|
|
KeyN: {
|
|
key: 'n',
|
|
description: 'Create new task attempt',
|
|
action: () => {
|
|
context.newAttempt && context.newAttempt();
|
|
},
|
|
},
|
|
});
|
|
|
|
// Hook to register global keyboard shortcuts
|
|
export function useKeyboardShortcuts(context: KeyboardShortcutContext) {
|
|
const shortcuts = createKeyboardShortcuts(context);
|
|
|
|
const handleKeyDown = useCallback(
|
|
(event: KeyboardEvent) => {
|
|
// Don't trigger shortcuts when typing in input fields
|
|
const target = event.target as HTMLElement;
|
|
if (
|
|
target.tagName === 'INPUT' ||
|
|
target.tagName === 'TEXTAREA' ||
|
|
target.isContentEditable
|
|
) {
|
|
return;
|
|
}
|
|
|
|
// Don't trigger shortcuts when modifier keys are pressed (except for specific shortcuts)
|
|
if (event.ctrlKey || event.metaKey || event.altKey) {
|
|
return;
|
|
}
|
|
|
|
const shortcut = shortcuts[event.code] || shortcuts[event.key];
|
|
|
|
if (shortcut && !shortcut.disabled) {
|
|
event.preventDefault();
|
|
shortcut.action(context);
|
|
}
|
|
},
|
|
[shortcuts, context]
|
|
);
|
|
|
|
useEffect(() => {
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
}, [handleKeyDown]);
|
|
|
|
return shortcuts;
|
|
}
|
|
|
|
// Hook for dialog-specific keyboard shortcuts
|
|
export function useDialogKeyboardShortcuts(onClose: () => void) {
|
|
useEffect(() => {
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault();
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
}, [onClose]);
|
|
}
|
|
|
|
// Kanban board keyboard navigation hook
|
|
export function useKanbanKeyboardNavigation({
|
|
focusedTaskId,
|
|
setFocusedTaskId,
|
|
focusedStatus,
|
|
setFocusedStatus,
|
|
groupedTasks,
|
|
filteredTasks,
|
|
allTaskStatuses,
|
|
onViewTaskDetails,
|
|
preserveIndexOnColumnSwitch = false,
|
|
}: {
|
|
focusedTaskId: string | null;
|
|
setFocusedTaskId: (id: string | null) => void;
|
|
focusedStatus: string | null;
|
|
setFocusedStatus: (status: string | null) => void;
|
|
groupedTasks: Record<string, any[]>;
|
|
filteredTasks: unknown[];
|
|
allTaskStatuses: string[];
|
|
onViewTaskDetails?: (task: any) => void;
|
|
preserveIndexOnColumnSwitch?: boolean;
|
|
}) {
|
|
useEffect(() => {
|
|
function handleKeyDown(e: KeyboardEvent) {
|
|
// Don't handle if typing in input, textarea, or select
|
|
const tag = (e.target as HTMLElement)?.tagName;
|
|
if (
|
|
tag === 'INPUT' ||
|
|
tag === 'TEXTAREA' ||
|
|
tag === 'SELECT' ||
|
|
(e.target as HTMLElement)?.isContentEditable
|
|
)
|
|
return;
|
|
if (!focusedTaskId || !focusedStatus) return;
|
|
const currentColumn = groupedTasks[focusedStatus];
|
|
const currentIndex = currentColumn.findIndex(
|
|
(t: any) => t.id === focusedTaskId
|
|
);
|
|
let newStatus = focusedStatus;
|
|
let newTaskId = focusedTaskId;
|
|
if (e.key === 'ArrowDown') {
|
|
if (currentIndex < currentColumn.length - 1) {
|
|
newTaskId = currentColumn[currentIndex + 1].id;
|
|
}
|
|
} else if (e.key === 'ArrowUp') {
|
|
if (currentIndex > 0) {
|
|
newTaskId = currentColumn[currentIndex - 1].id;
|
|
}
|
|
} else if (e.key === 'ArrowRight') {
|
|
let colIdx = allTaskStatuses.indexOf(focusedStatus);
|
|
while (colIdx < allTaskStatuses.length - 1) {
|
|
colIdx++;
|
|
const nextStatus = allTaskStatuses[colIdx];
|
|
if (groupedTasks[nextStatus] && groupedTasks[nextStatus].length > 0) {
|
|
newStatus = nextStatus;
|
|
if (preserveIndexOnColumnSwitch) {
|
|
const nextCol = groupedTasks[nextStatus];
|
|
const idx = Math.min(currentIndex, nextCol.length - 1);
|
|
newTaskId = nextCol[idx].id;
|
|
} else {
|
|
newTaskId = groupedTasks[nextStatus][0].id;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
} else if (e.key === 'ArrowLeft') {
|
|
let colIdx = allTaskStatuses.indexOf(focusedStatus);
|
|
while (colIdx > 0) {
|
|
colIdx--;
|
|
const prevStatus = allTaskStatuses[colIdx];
|
|
if (groupedTasks[prevStatus] && groupedTasks[prevStatus].length > 0) {
|
|
newStatus = prevStatus;
|
|
if (preserveIndexOnColumnSwitch) {
|
|
const prevCol = groupedTasks[prevStatus];
|
|
const idx = Math.min(currentIndex, prevCol.length - 1);
|
|
newTaskId = prevCol[idx].id;
|
|
} else {
|
|
newTaskId = groupedTasks[prevStatus][0].id;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
} else if ((e.key === 'Enter' || e.key === ' ') && onViewTaskDetails) {
|
|
const task = filteredTasks.find((t: any) => t.id === focusedTaskId);
|
|
if (task) {
|
|
onViewTaskDetails(task);
|
|
}
|
|
} else {
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
setFocusedTaskId(newTaskId);
|
|
setFocusedStatus(newStatus);
|
|
}
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [
|
|
focusedTaskId,
|
|
focusedStatus,
|
|
groupedTasks,
|
|
filteredTasks,
|
|
onViewTaskDetails,
|
|
allTaskStatuses,
|
|
setFocusedTaskId,
|
|
setFocusedStatus,
|
|
preserveIndexOnColumnSwitch,
|
|
]);
|
|
}
|
|
|
|
// Hook for cycling through profile variants with Left Shift + Tab
|
|
export function useVariantCyclingShortcut({
|
|
currentProfile,
|
|
selectedVariant,
|
|
setSelectedVariant,
|
|
}: {
|
|
currentProfile: ExecutorConfig | null | undefined;
|
|
selectedVariant: string | null;
|
|
setSelectedVariant: (variant: string | null) => void;
|
|
}) {
|
|
useEffect(() => {
|
|
if (!currentProfile || Object.keys(currentProfile).length === 0) {
|
|
return;
|
|
}
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
// Check for Left Shift + Tab
|
|
if (e.shiftKey && e.key === 'Tab') {
|
|
e.preventDefault();
|
|
|
|
// Build the variant cycle: variant1 → variant2 → ... → variant1
|
|
const variants = currentProfile;
|
|
const variantLabels = Object.keys(variants);
|
|
|
|
// Find current index and cycle to next
|
|
const currentIndex = variantLabels.findIndex(
|
|
(v) => v === selectedVariant
|
|
);
|
|
const nextIndex = (currentIndex + 1) % variantLabels.length;
|
|
const nextVariant = variantLabels[nextIndex];
|
|
|
|
setSelectedVariant(nextVariant);
|
|
}
|
|
};
|
|
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
}, [currentProfile, selectedVariant, setSelectedVariant]);
|
|
}
|