Add custom keyboard shortcuts (#749)

* Add custom keyboard shortcuts

* fixup! Add custom keyboard shortcuts
This commit is contained in:
Chris 2025-12-28 21:58:21 +02:00 committed by GitHub
parent 8c839199e1
commit 703f6fe506
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 718 additions and 56 deletions

View file

@ -0,0 +1,19 @@
'use strict';
/**
* Migration to add keyboard_shortcuts JSON column to users table.
* This stores user-configurable keyboard shortcuts for quick actions.
*/
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.safeAddColumn('users', 'keyboard_shortcuts', {
type: Sequelize.JSON,
allowNull: true,
defaultValue: null,
});
},
async down(queryInterface) {
await queryInterface.safeRemoveColumn('users', 'keyboard_shortcuts');
},
};

View file

@ -212,6 +212,13 @@ module.exports = (sequelize) => {
},
},
},
keyboard_shortcuts: {
type: DataTypes.JSON,
allowNull: true,
defaultValue: null,
comment:
'User-configurable keyboard shortcuts for quick actions',
},
email_verified: {
type: DataTypes.BOOLEAN,
allowNull: false,

View file

@ -130,6 +130,7 @@ router.get('/profile', async (req, res) => {
'productivity_assistant_enabled',
'next_task_suggestion_enabled',
'notification_preferences',
'keyboard_shortcuts',
],
});
@ -188,6 +189,7 @@ router.patch('/profile', async (req, res) => {
pomodoro_enabled,
ui_settings,
notification_preferences,
keyboard_shortcuts,
currentPassword,
newPassword,
} = req.body;
@ -227,6 +229,8 @@ router.patch('/profile', async (req, res) => {
if (ui_settings !== undefined) allowedUpdates.ui_settings = ui_settings;
if (notification_preferences !== undefined)
allowedUpdates.notification_preferences = notification_preferences;
if (keyboard_shortcuts !== undefined)
allowedUpdates.keyboard_shortcuts = keyboard_shortcuts;
// Validate first_day_of_week if provided
if (first_day_of_week !== undefined) {
@ -292,6 +296,7 @@ router.patch('/profile', async (req, res) => {
'next_task_suggestion_enabled',
'pomodoro_enabled',
'notification_preferences',
'keyboard_shortcuts',
],
});

View file

@ -25,6 +25,8 @@ import {
} from './utils/projectsService';
import { isAuthError } from './utils/authUtils';
import { useLocation, useNavigate } from 'react-router-dom';
import { getApiPath } from './config/paths';
import { KeyboardShortcutsConfig } from './utils/keyboardShortcutsService';
interface LayoutProps {
currentUser: User;
@ -58,6 +60,25 @@ const Layout: React.FC<LayoutProps> = ({
const [selectedNote, setSelectedNote] = useState<Note | null>(null);
const [selectedArea, setSelectedArea] = useState<Area | null>(null);
const [selectedTag, setSelectedTag] = useState<Tag | null>(null);
const [keyboardShortcuts, setKeyboardShortcuts] = useState<KeyboardShortcutsConfig | null>(null);
// Fetch keyboard shortcuts from profile
useEffect(() => {
const fetchKeyboardShortcuts = async () => {
try {
const response = await fetch(getApiPath('profile'));
if (response.ok) {
const data = await response.json();
if (data.keyboard_shortcuts) {
setKeyboardShortcuts(data.keyboard_shortcuts);
}
}
} catch (error) {
console.error('Error fetching keyboard shortcuts:', error);
}
};
fetchKeyboardShortcuts();
}, []);
const {
notesStore: { notes, isLoading: isNotesLoading, isError: isNotesError },
@ -354,6 +375,7 @@ const Layout: React.FC<LayoutProps> = ({
notes={notes}
areas={areas}
tags={tags}
keyboardShortcuts={keyboardShortcuts}
/>
<div
className={`flex-1 flex items-center justify-center bg-gray-100 dark:bg-gray-800 transition-all duration-300 ease-in-out ${mainContentMarginLeft}`}
@ -392,6 +414,7 @@ const Layout: React.FC<LayoutProps> = ({
notes={notes}
areas={areas}
tags={tags}
keyboardShortcuts={keyboardShortcuts}
/>
<div
className={`flex-1 flex flex-col items-center justify-center bg-gray-100 dark:bg-gray-800 transition-all duration-300 ease-in-out ${mainContentMarginLeft}`}
@ -430,6 +453,7 @@ const Layout: React.FC<LayoutProps> = ({
notes={notes}
areas={areas}
tags={tags}
keyboardShortcuts={keyboardShortcuts}
/>
<div

View file

@ -16,6 +16,7 @@ import {
KeyIcon,
CheckIcon,
BellIcon,
CommandLineIcon,
} from '@heroicons/react/24/outline';
import TelegramIcon from '../Shared/Icons/TelegramIcon';
import { useToast } from '../Shared/ToastContext';
@ -42,6 +43,8 @@ import ProductivityTab from './tabs/ProductivityTab';
import TelegramTab from './tabs/TelegramTab';
import AiTab from './tabs/AiTab';
import NotificationsTab from './tabs/NotificationsTab';
import KeyboardShortcutsTab from './tabs/KeyboardShortcutsTab';
import { getDefaultConfig } from '../../utils/keyboardShortcutsService';
import type {
ProfileSettingsProps,
Profile,
@ -84,6 +87,7 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({
'telegram',
'ai',
'notifications',
'keyboard-shortcuts',
];
return section && validTabs.includes(section) ? section : 'general';
}, [location.search]);
@ -116,6 +120,7 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({
next_task_suggestion_enabled: true,
pomodoro_enabled: true,
notification_preferences: null,
keyboard_shortcuts: null,
currentPassword: '',
newPassword: '',
confirmPassword: '',
@ -512,6 +517,8 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({
: true,
notification_preferences:
data.notification_preferences || null,
keyboard_shortcuts:
data.keyboard_shortcuts || getDefaultConfig(),
});
if (data.telegram_bot_token) {
@ -1115,6 +1122,11 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({
name: t('profile.tabs.ai', 'AI Features'),
icon: <LightBulbIcon className="w-5 h-5" />,
},
{
id: 'keyboard-shortcuts',
name: t('profile.tabs.keyboardShortcuts', 'Shortcuts'),
icon: <CommandLineIcon className="w-5 h-5" />,
},
];
return (
@ -1295,6 +1307,17 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({
}
/>
<KeyboardShortcutsTab
isActive={activeTab === 'keyboard-shortcuts'}
config={formData.keyboard_shortcuts}
onChange={(config) =>
setFormData((prev) => ({
...prev,
keyboard_shortcuts: config,
}))
}
/>
<div className="flex justify-end dark:border-gray-700">
<button
type="submit"

View file

@ -0,0 +1,307 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { CommandLineIcon, ArrowPathIcon } from '@heroicons/react/24/outline';
import {
KeyboardShortcut,
KeyboardShortcutsConfig,
ShortcutAction,
SHORTCUT_LABELS,
formatShortcutDisplay,
validateShortcuts,
getDefaultShortcuts,
getDefaultConfig,
shortcutToString,
} from '../../../utils/keyboardShortcutsService';
interface KeyboardShortcutsTabProps {
isActive: boolean;
config: KeyboardShortcutsConfig | null | undefined;
onChange: (config: KeyboardShortcutsConfig) => void;
}
const SHORTCUT_ACTIONS: ShortcutAction[] = [
'inbox',
'task',
'project',
'note',
'area',
'tag',
];
const KeyboardShortcutsTab: React.FC<KeyboardShortcutsTabProps> = ({
isActive,
config,
onChange,
}) => {
const { t } = useTranslation();
const [editingAction, setEditingAction] = useState<ShortcutAction | null>(null);
const [isRecording, setIsRecording] = useState(false);
const [tempShortcut, setTempShortcut] = useState<KeyboardShortcut | null>(null);
// Ensure we always have a valid config
const activeConfig = config || getDefaultConfig();
const shortcuts = activeConfig.shortcuts || getDefaultShortcuts();
// Validation
const validation = validateShortcuts(shortcuts);
const handleEditClick = (action: ShortcutAction) => {
const current = shortcuts.find(s => s.action === action);
setEditingAction(action);
setTempShortcut(current || null);
setIsRecording(false);
};
const handleCancelEdit = () => {
setEditingAction(null);
setTempShortcut(null);
setIsRecording(false);
};
const handleSaveEdit = () => {
if (!editingAction || !tempShortcut) return;
const newShortcuts = shortcuts.map(s =>
s.action === editingAction ? tempShortcut : s
);
onChange({
...activeConfig,
shortcuts: newShortcuts,
});
setEditingAction(null);
setTempShortcut(null);
setIsRecording(false);
};
const handleStartRecording = () => {
setIsRecording(true);
};
// Handle keyboard recording
useEffect(() => {
if (!isRecording || !editingAction) return;
const handleKeyDown = (event: KeyboardEvent) => {
event.preventDefault();
event.stopPropagation();
// Ignore if only modifier keys are pressed
const key = event.key.toLowerCase();
if (['control', 'alt', 'shift', 'meta'].includes(key)) {
return;
}
// Require at least one modifier
if (!event.ctrlKey && !event.altKey && !event.shiftKey && !event.metaKey) {
return;
}
const newShortcut: KeyboardShortcut = {
action: editingAction,
key: key.length === 1 ? key : key,
modifiers: {
ctrl: event.ctrlKey,
alt: event.altKey,
shift: event.shiftKey,
meta: event.metaKey,
},
};
setTempShortcut(newShortcut);
setIsRecording(false);
};
document.addEventListener('keydown', handleKeyDown, true);
return () => {
document.removeEventListener('keydown', handleKeyDown, true);
};
}, [isRecording, editingAction]);
const handleResetToDefaults = () => {
onChange(getDefaultConfig());
};
const handleToggleEnabled = () => {
onChange({
...activeConfig,
enabled: !activeConfig.enabled,
});
};
const getShortcutForAction = (action: ShortcutAction): KeyboardShortcut | undefined => {
return shortcuts.find(s => s.action === action);
};
// Check if a shortcut would create a duplicate
const wouldCreateDuplicate = (newShortcut: KeyboardShortcut): string | null => {
const newKey = shortcutToString(newShortcut);
for (const existing of shortcuts) {
if (existing.action !== newShortcut.action && shortcutToString(existing) === newKey) {
return existing.action;
}
}
return null;
};
if (!isActive) return null;
return (
<div>
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-6 flex items-center">
<CommandLineIcon className="w-6 h-6 mr-3 text-purple-500" />
{t('profile.keyboardShortcuts', 'Keyboard Shortcuts')}
</h3>
{/* Enable/Disable Toggle */}
<div className="flex items-center justify-between py-4 border-b border-gray-200 dark:border-gray-700 mb-6">
<div>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
{t('profile.shortcuts.enableShortcuts', 'Enable Keyboard Shortcuts')}
</label>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t(
'profile.shortcuts.enableDescription',
'Turn keyboard shortcuts on or off globally.'
)}
</p>
</div>
<div
className={`relative inline-block w-12 h-6 transition-colors duration-200 ease-in-out rounded-full cursor-pointer ${
activeConfig.enabled
? 'bg-blue-500'
: 'bg-gray-300 dark:bg-gray-600'
}`}
onClick={handleToggleEnabled}
>
<span
className={`absolute left-0 top-0 bottom-0 m-1 w-4 h-4 transition-transform duration-200 ease-in-out transform bg-white rounded-full ${
activeConfig.enabled ? 'translate-x-6' : 'translate-x-0'
}`}
></span>
</div>
</div>
{/* Validation Warnings */}
{!validation.valid && (
<div className="mb-6 p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
{t('profile.shortcuts.duplicateWarning', 'Duplicate shortcuts detected:')}
</p>
<ul className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 list-disc list-inside">
{validation.duplicates.map((dup, idx) => (
<li key={idx}>{dup}</li>
))}
</ul>
</div>
)}
{/* Shortcuts List */}
<div className="space-y-3 mb-6">
{SHORTCUT_ACTIONS.map((action) => {
const shortcut = getShortcutForAction(action);
const isEditing = editingAction === action;
const label = SHORTCUT_LABELS[action];
return (
<div
key={action}
className="flex items-center justify-between py-3 px-4 bg-gray-50 dark:bg-gray-800 rounded-lg"
>
<div className="flex-1">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{t(label.labelKey, label.defaultLabel)}
</span>
</div>
{isEditing ? (
<div className="flex items-center space-x-2">
{isRecording ? (
<div className="px-3 py-1.5 bg-blue-100 dark:bg-blue-900/30 border-2 border-blue-400 dark:border-blue-600 rounded text-blue-700 dark:text-blue-300 text-sm font-mono animate-pulse">
{t('profile.shortcuts.pressKeys', 'Press keys...')}
</div>
) : tempShortcut ? (
<div className="flex items-center space-x-2">
<kbd className="px-3 py-1.5 text-sm font-mono bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded shadow-sm text-gray-700 dark:text-gray-300">
{formatShortcutDisplay(tempShortcut)}
</kbd>
{wouldCreateDuplicate(tempShortcut) && (
<span className="text-xs text-yellow-600 dark:text-yellow-400">
{t('profile.shortcuts.duplicateWith', 'Conflicts with {{action}}', {
action: wouldCreateDuplicate(tempShortcut),
})}
</span>
)}
</div>
) : null}
<button
type="button"
onClick={handleStartRecording}
className="px-3 py-1.5 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
>
{t('profile.shortcuts.record', 'Record')}
</button>
<button
type="button"
onClick={handleSaveEdit}
disabled={!tempShortcut}
className="px-3 py-1.5 text-xs bg-green-600 text-white rounded hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('common.save', 'Save')}
</button>
<button
type="button"
onClick={handleCancelEdit}
className="px-3 py-1.5 text-xs bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-200 rounded hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors"
>
{t('common.cancel', 'Cancel')}
</button>
</div>
) : (
<div className="flex items-center space-x-3">
{shortcut && (
<kbd className="px-3 py-1.5 text-sm font-mono bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded shadow-sm text-gray-700 dark:text-gray-300">
{formatShortcutDisplay(shortcut)}
</kbd>
)}
<button
type="button"
onClick={() => handleEditClick(action)}
className="px-3 py-1.5 text-xs bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-500 transition-colors"
>
{t('common.edit', 'Edit')}
</button>
</div>
)}
</div>
);
})}
</div>
{/* Reset to Defaults */}
<div className="flex justify-end pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
onClick={handleResetToDefaults}
className="flex items-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<ArrowPathIcon className="w-4 h-4 mr-2" />
{t('profile.shortcuts.resetToDefaults', 'Reset to Defaults')}
</button>
</div>
{/* Info Box */}
<div className="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<p className="text-xs text-blue-700 dark:text-blue-300">
{t(
'profile.shortcuts.info',
'Keyboard shortcuts help you navigate quickly. Changes are saved when you click "Save Changes" at the bottom of the page. Shortcuts are disabled when typing in text fields.'
)}
</p>
</div>
</div>
);
};
export default KeyboardShortcutsTab;

View file

@ -1,3 +1,5 @@
import type { KeyboardShortcutsConfig } from '../../utils/keyboardShortcutsService';
export interface ProfileSettingsProps {
currentUser: { uid: string; email: string };
isDarkMode?: boolean;
@ -58,6 +60,7 @@ export interface Profile {
next_task_suggestion_enabled: boolean;
pomodoro_enabled: boolean;
notification_preferences?: NotificationPreferences | null;
keyboard_shortcuts?: KeyboardShortcutsConfig | null;
}
export interface TelegramBotInfo {

View file

@ -12,6 +12,7 @@ import SidebarProjects from './Sidebar/SidebarProjects';
import SidebarTags from './Sidebar/SidebarTags';
import SidebarViews from './Sidebar/SidebarViews';
import { getFeatureFlags, FeatureFlags } from '../utils/featureFlags';
import { KeyboardShortcutsConfig } from '../utils/keyboardShortcutsService';
interface SidebarProps {
isSidebarOpen: boolean;
@ -28,6 +29,7 @@ interface SidebarProps {
notes: Note[];
areas: Area[];
tags: Tag[];
keyboardShortcuts?: KeyboardShortcutsConfig | null;
}
const Sidebar: React.FC<SidebarProps> = ({
@ -45,6 +47,7 @@ const Sidebar: React.FC<SidebarProps> = ({
notes,
areas,
tags,
keyboardShortcuts,
}) => {
const navigate = useNavigate();
const location = useLocation();
@ -149,6 +152,7 @@ const Sidebar: React.FC<SidebarProps> = ({
openNoteModal={openNoteModal}
openAreaModal={openAreaModal}
openTagModal={openTagModal}
keyboardShortcuts={keyboardShortcuts}
/>
</div>
)}

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import {
SunIcon,
@ -17,6 +17,14 @@ import { Note } from '../../entities/Note';
import { Area } from '../../entities/Area';
import { useTelegramStatus } from '../../contexts/TelegramStatusContext';
import { getApiPath } from '../../config/paths';
import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts';
import {
KeyboardShortcutsConfig,
ShortcutAction,
formatShortcutDisplay,
getDefaultShortcuts,
getShortcutByAction,
} from '../../utils/keyboardShortcutsService';
interface SidebarFooterProps {
currentUser: { email: string };
@ -31,6 +39,7 @@ interface SidebarFooterProps {
openNoteModal: (note: Note | null) => void;
openAreaModal: (area: Area | null) => void;
openTagModal: (tag: any | null) => void;
keyboardShortcuts?: KeyboardShortcutsConfig | null;
}
const SidebarFooter: React.FC<SidebarFooterProps> = ({
@ -43,6 +52,7 @@ const SidebarFooter: React.FC<SidebarFooterProps> = ({
openNoteModal,
openAreaModal,
openTagModal,
keyboardShortcuts,
}) => {
const { t } = useTranslation();
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
@ -51,6 +61,13 @@ const SidebarFooter: React.FC<SidebarFooterProps> = ({
const [version, setVersion] = useState<string>('v0.86');
const navigate = useNavigate();
// Get shortcuts config, using defaults if not provided
const shortcuts = useMemo(() => {
return keyboardShortcuts?.shortcuts || getDefaultShortcuts();
}, [keyboardShortcuts]);
const shortcutsEnabled = keyboardShortcuts?.enabled ?? true;
const toggleDropdown = () => {
setIsDropdownOpen(!isDropdownOpen);
};
@ -89,49 +106,6 @@ const SidebarFooter: React.FC<SidebarFooterProps> = ({
});
}, []);
// Handle keyboard shortcuts
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// Check for Ctrl + Shift key combinations only (not Cmd/Meta key)
if (event.ctrlKey && event.shiftKey && !event.metaKey) {
switch (event.key.toLowerCase()) {
case 'i':
event.preventDefault();
handleDropdownSelect('Inbox');
break;
case 't':
event.preventDefault();
handleDropdownSelect('Task');
break;
case 'p':
event.preventDefault();
handleDropdownSelect('Project');
break;
case 'n':
event.preventDefault();
handleDropdownSelect('Note');
break;
case 'a':
event.preventDefault();
handleDropdownSelect('Area');
break;
case 'g':
event.preventDefault();
handleDropdownSelect('Tag');
break;
default:
break;
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, []);
const handleDropdownSelect = (type: string) => {
switch (type) {
case 'Inbox':
@ -161,44 +135,65 @@ const SidebarFooter: React.FC<SidebarFooterProps> = ({
setIsDropdownOpen(false);
};
// Use the keyboard shortcuts hook
useKeyboardShortcuts(
shortcuts,
{
inbox: () => handleDropdownSelect('Inbox'),
task: () => handleDropdownSelect('Task'),
project: () => handleDropdownSelect('Project'),
note: () => handleDropdownSelect('Note'),
area: () => handleDropdownSelect('Area'),
tag: () => handleDropdownSelect('Tag'),
},
shortcutsEnabled
);
// Helper to get the display string for a shortcut action
const getShortcutDisplay = (action: ShortcutAction): string => {
const shortcut = getShortcutByAction(shortcuts, action);
return shortcut ? formatShortcutDisplay(shortcut) : '';
};
const dropdownItems = [
{
label: 'Inbox',
translationKey: 'dropdown.inbox',
icon: <InboxIcon className="h-5 w-5 mr-2" />,
shortcut: 'Ctrl+Shift+I',
action: 'inbox' as ShortcutAction,
},
{
label: 'Task',
translationKey: 'dropdown.task',
icon: <CheckIcon className="h-5 w-5 mr-2" />,
shortcut: 'Ctrl+Shift+T',
action: 'task' as ShortcutAction,
},
{
label: 'Project',
translationKey: 'dropdown.project',
icon: <FolderIcon className="h-5 w-5 mr-2" />,
shortcut: 'Ctrl+Shift+P',
action: 'project' as ShortcutAction,
},
{
label: 'Note',
translationKey: 'dropdown.note',
icon: <BookOpenIcon className="h-5 w-5 mr-2" />,
shortcut: 'Ctrl+Shift+N',
action: 'note' as ShortcutAction,
},
{
label: 'Area',
translationKey: 'dropdown.area',
icon: <Squares2X2Icon className="h-5 w-5 mr-2" />,
shortcut: 'Ctrl+Shift+A',
action: 'area' as ShortcutAction,
},
{
label: 'Tag',
translationKey: 'dropdown.tag',
icon: <TagIcon className="h-5 w-5 mr-2" />,
shortcut: 'Ctrl+Shift+G',
action: 'tag' as ShortcutAction,
},
];
return (
<div className="mt-auto p-3">
{/* Version Display */}
@ -237,7 +232,7 @@ const SidebarFooter: React.FC<SidebarFooterProps> = ({
label,
translationKey,
icon,
shortcut,
action,
}) => (
<button
key={label}
@ -256,15 +251,12 @@ const SidebarFooter: React.FC<SidebarFooterProps> = ({
)}
</div>
<span
className="bg-gray-100 dark:bg-gray-700 px-1 py-0.5 rounded text-xs font-mono text-gray-500 dark:text-gray-400 opacity-60"
className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded text-xs font-mono text-gray-500 dark:text-gray-400"
style={{
fontSize: '10px',
}}
>
^ + Shift +{' '}
{shortcut
.split('+')
.pop()}
{getShortcutDisplay(action)}
</span>
</button>
)

View file

@ -0,0 +1,75 @@
import { useEffect, useCallback, useMemo } from 'react';
import {
KeyboardShortcut,
ShortcutAction,
matchesShortcut,
isInputElement,
getDefaultShortcuts,
} from '../utils/keyboardShortcutsService';
type ShortcutHandlers = Partial<Record<ShortcutAction, () => void>>;
/**
* Hook to register and handle keyboard shortcuts
*
* @param shortcuts - Array of keyboard shortcuts to listen for
* @param handlers - Object mapping actions to handler functions
* @param enabled - Whether shortcuts are currently enabled
*
* @example
* useKeyboardShortcuts(
* userShortcuts,
* {
* inbox: () => navigate('/inbox'),
* task: () => openTaskModal(),
* },
* true
* );
*/
export const useKeyboardShortcuts = (
shortcuts: KeyboardShortcut[] | undefined,
handlers: ShortcutHandlers,
enabled: boolean = true
) => {
// Use defaults if no shortcuts provided
const activeShortcuts = useMemo(
() => shortcuts || getDefaultShortcuts(),
[shortcuts]
);
// Memoize the handler to prevent unnecessary re-renders
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
// Skip if shortcuts are disabled
if (!enabled) return;
// Skip if user is typing in an input field
if (isInputElement(event.target)) return;
// Check each shortcut for a match
for (const shortcut of activeShortcuts) {
if (matchesShortcut(event, shortcut)) {
const handler = handlers[shortcut.action];
if (handler) {
event.preventDefault();
handler();
}
break;
}
}
},
[activeShortcuts, handlers, enabled]
);
useEffect(() => {
if (!enabled) return;
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [handleKeyDown, enabled]);
};
export default useKeyboardShortcuts;

View file

@ -0,0 +1,184 @@
// Keyboard Shortcuts Service
// Provides types, defaults, and utility functions for keyboard shortcuts
export type ShortcutAction = 'inbox' | 'task' | 'project' | 'note' | 'area' | 'tag';
export interface KeyboardShortcut {
action: ShortcutAction;
key: string;
modifiers: {
alt: boolean;
shift: boolean;
ctrl: boolean;
meta: boolean;
};
}
export interface KeyboardShortcutsConfig {
shortcuts: KeyboardShortcut[];
enabled: boolean;
}
// Default shortcuts using Alt+Shift to avoid browser conflicts
export const DEFAULT_SHORTCUTS: KeyboardShortcut[] = [
{ action: 'inbox', key: 'i', modifiers: { alt: true, shift: true, ctrl: false, meta: false } },
{ action: 'task', key: 't', modifiers: { alt: true, shift: true, ctrl: false, meta: false } },
{ action: 'project', key: 'p', modifiers: { alt: true, shift: true, ctrl: false, meta: false } },
{ action: 'note', key: 'n', modifiers: { alt: true, shift: true, ctrl: false, meta: false } },
{ action: 'area', key: 'a', modifiers: { alt: true, shift: true, ctrl: false, meta: false } },
{ action: 'tag', key: 'g', modifiers: { alt: true, shift: true, ctrl: false, meta: false } },
];
export const SHORTCUT_LABELS: Record<ShortcutAction, { labelKey: string; defaultLabel: string }> = {
inbox: { labelKey: 'profile.shortcuts.actions.inbox', defaultLabel: 'Create new Inbox item' },
task: { labelKey: 'profile.shortcuts.actions.task', defaultLabel: 'Create new Task' },
project: { labelKey: 'profile.shortcuts.actions.project', defaultLabel: 'Create new Project' },
note: { labelKey: 'profile.shortcuts.actions.note', defaultLabel: 'Create new Note' },
area: { labelKey: 'profile.shortcuts.actions.area', defaultLabel: 'Create new Area' },
tag: { labelKey: 'profile.shortcuts.actions.tag', defaultLabel: 'Create new Tag' },
};
/**
* Detects if the user is on a Mac platform
*/
export const isMac = (): boolean => {
if (typeof navigator === 'undefined') return false;
return /Mac|iPhone|iPad|iPod/.test(navigator.platform);
};
/**
* Formats a shortcut for display
* Always uses full text format: "Ctrl + Shift + T"
*/
export const formatShortcutDisplay = (shortcut: KeyboardShortcut): string => {
const parts: string[] = [];
if (shortcut.modifiers.ctrl) {
parts.push('Ctrl');
}
if (shortcut.modifiers.alt) {
parts.push('Alt');
}
if (shortcut.modifiers.shift) {
parts.push('Shift');
}
if (shortcut.modifiers.meta) {
parts.push(isMac() ? 'Cmd' : 'Win');
}
parts.push(shortcut.key.toUpperCase());
return parts.join(' + ');
};
/**
* Parses a keyboard event into a normalized format
*/
export const parseKeyboardEvent = (event: KeyboardEvent) => {
return {
key: event.key.toLowerCase(),
modifiers: {
alt: event.altKey,
shift: event.shiftKey,
ctrl: event.ctrlKey,
meta: event.metaKey,
},
};
};
/**
* Checks if a keyboard event matches a shortcut configuration
*/
export const matchesShortcut = (
event: KeyboardEvent,
shortcut: KeyboardShortcut
): boolean => {
const parsed = parseKeyboardEvent(event);
return (
parsed.key === shortcut.key.toLowerCase() &&
parsed.modifiers.alt === shortcut.modifiers.alt &&
parsed.modifiers.shift === shortcut.modifiers.shift &&
parsed.modifiers.ctrl === shortcut.modifiers.ctrl &&
parsed.modifiers.meta === shortcut.modifiers.meta
);
};
/**
* Checks if an element is an input field where shortcuts should be disabled
*/
export const isInputElement = (element: EventTarget | null): boolean => {
if (!element || !(element instanceof HTMLElement)) return false;
const tagName = element.tagName.toUpperCase();
return (
tagName === 'INPUT' ||
tagName === 'TEXTAREA' ||
element.isContentEditable
);
};
/**
* Validates shortcuts for duplicates
* Returns an array of conflict descriptions
*/
export const validateShortcuts = (shortcuts: KeyboardShortcut[]): {
valid: boolean;
duplicates: string[];
} => {
const seen = new Map<string, string>();
const duplicates: string[] = [];
for (const shortcut of shortcuts) {
const key = formatShortcutDisplay(shortcut);
if (seen.has(key)) {
duplicates.push(`${key} (${shortcut.action} and ${seen.get(key)})`);
} else {
seen.set(key, shortcut.action);
}
}
return {
valid: duplicates.length === 0,
duplicates,
};
};
/**
* Returns a fresh copy of default shortcuts
*/
export const getDefaultShortcuts = (): KeyboardShortcut[] => {
return JSON.parse(JSON.stringify(DEFAULT_SHORTCUTS));
};
/**
* Returns default config with defaults
*/
export const getDefaultConfig = (): KeyboardShortcutsConfig => {
return {
shortcuts: getDefaultShortcuts(),
enabled: true,
};
};
/**
* Finds a shortcut by action
*/
export const getShortcutByAction = (
shortcuts: KeyboardShortcut[],
action: ShortcutAction
): KeyboardShortcut | undefined => {
return shortcuts.find(s => s.action === action);
};
/**
* Creates a shortcut string for comparison (used for duplicate detection)
*/
export const shortcutToString = (shortcut: KeyboardShortcut): string => {
const mods = [];
if (shortcut.modifiers.ctrl) mods.push('ctrl');
if (shortcut.modifiers.alt) mods.push('alt');
if (shortcut.modifiers.shift) mods.push('shift');
if (shortcut.modifiers.meta) mods.push('meta');
mods.sort();
return `${mods.join('+')}+${shortcut.key.toLowerCase()}`;
};

View file

@ -310,6 +310,25 @@
"productivityAssistant": "Productivity Assistant",
"productivityAssistantDescription": "Show productivity insights that help identify stalled projects, vague tasks, and workflow improvements on your Today page.",
"enableProductivityAssistant": "Enable Productivity Insights",
"keyboardShortcuts": "Keyboard Shortcuts",
"shortcuts": {
"enableShortcuts": "Enable Keyboard Shortcuts",
"enableDescription": "Turn keyboard shortcuts on or off globally.",
"duplicateWarning": "Duplicate shortcuts detected:",
"pressKeys": "Press keys...",
"duplicateWith": "Conflicts with {{action}}",
"record": "Record",
"resetToDefaults": "Reset to Defaults",
"info": "Keyboard shortcuts help you navigate quickly. Changes are saved when you click \"Save Changes\" at the bottom of the page. Shortcuts are disabled when typing in text fields.",
"actions": {
"inbox": "Create new Inbox item",
"task": "Create new Task",
"project": "Create new Project",
"note": "Create new Note",
"area": "Create new Area",
"tag": "Create new Tag"
}
},
"nextTaskSuggestion": "Next Task Suggestion",
"nextTaskSuggestionDescription": "Automatically suggest the next best task to work on when you have nothing in progress, prioritizing due today tasks, then suggested tasks from your Today page.",
"enableNextTaskSuggestion": "Enable Next Task Suggestions",