Add custom keyboard shortcuts (#749)
* Add custom keyboard shortcuts * fixup! Add custom keyboard shortcuts
This commit is contained in:
parent
8c839199e1
commit
703f6fe506
12 changed files with 718 additions and 56 deletions
19
backend/migrations/20260201000002-add-keyboard-shortcuts.js
Normal file
19
backend/migrations/20260201000002-add-keyboard-shortcuts.js
Normal 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');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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: {
|
email_verified: {
|
||||||
type: DataTypes.BOOLEAN,
|
type: DataTypes.BOOLEAN,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,7 @@ router.get('/profile', async (req, res) => {
|
||||||
'productivity_assistant_enabled',
|
'productivity_assistant_enabled',
|
||||||
'next_task_suggestion_enabled',
|
'next_task_suggestion_enabled',
|
||||||
'notification_preferences',
|
'notification_preferences',
|
||||||
|
'keyboard_shortcuts',
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -188,6 +189,7 @@ router.patch('/profile', async (req, res) => {
|
||||||
pomodoro_enabled,
|
pomodoro_enabled,
|
||||||
ui_settings,
|
ui_settings,
|
||||||
notification_preferences,
|
notification_preferences,
|
||||||
|
keyboard_shortcuts,
|
||||||
currentPassword,
|
currentPassword,
|
||||||
newPassword,
|
newPassword,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
@ -227,6 +229,8 @@ router.patch('/profile', async (req, res) => {
|
||||||
if (ui_settings !== undefined) allowedUpdates.ui_settings = ui_settings;
|
if (ui_settings !== undefined) allowedUpdates.ui_settings = ui_settings;
|
||||||
if (notification_preferences !== undefined)
|
if (notification_preferences !== undefined)
|
||||||
allowedUpdates.notification_preferences = notification_preferences;
|
allowedUpdates.notification_preferences = notification_preferences;
|
||||||
|
if (keyboard_shortcuts !== undefined)
|
||||||
|
allowedUpdates.keyboard_shortcuts = keyboard_shortcuts;
|
||||||
|
|
||||||
// Validate first_day_of_week if provided
|
// Validate first_day_of_week if provided
|
||||||
if (first_day_of_week !== undefined) {
|
if (first_day_of_week !== undefined) {
|
||||||
|
|
@ -292,6 +296,7 @@ router.patch('/profile', async (req, res) => {
|
||||||
'next_task_suggestion_enabled',
|
'next_task_suggestion_enabled',
|
||||||
'pomodoro_enabled',
|
'pomodoro_enabled',
|
||||||
'notification_preferences',
|
'notification_preferences',
|
||||||
|
'keyboard_shortcuts',
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ import {
|
||||||
} from './utils/projectsService';
|
} from './utils/projectsService';
|
||||||
import { isAuthError } from './utils/authUtils';
|
import { isAuthError } from './utils/authUtils';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { getApiPath } from './config/paths';
|
||||||
|
import { KeyboardShortcutsConfig } from './utils/keyboardShortcutsService';
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
currentUser: User;
|
currentUser: User;
|
||||||
|
|
@ -58,6 +60,25 @@ const Layout: React.FC<LayoutProps> = ({
|
||||||
const [selectedNote, setSelectedNote] = useState<Note | null>(null);
|
const [selectedNote, setSelectedNote] = useState<Note | null>(null);
|
||||||
const [selectedArea, setSelectedArea] = useState<Area | null>(null);
|
const [selectedArea, setSelectedArea] = useState<Area | null>(null);
|
||||||
const [selectedTag, setSelectedTag] = useState<Tag | 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 {
|
const {
|
||||||
notesStore: { notes, isLoading: isNotesLoading, isError: isNotesError },
|
notesStore: { notes, isLoading: isNotesLoading, isError: isNotesError },
|
||||||
|
|
@ -354,6 +375,7 @@ const Layout: React.FC<LayoutProps> = ({
|
||||||
notes={notes}
|
notes={notes}
|
||||||
areas={areas}
|
areas={areas}
|
||||||
tags={tags}
|
tags={tags}
|
||||||
|
keyboardShortcuts={keyboardShortcuts}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={`flex-1 flex items-center justify-center bg-gray-100 dark:bg-gray-800 transition-all duration-300 ease-in-out ${mainContentMarginLeft}`}
|
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}
|
notes={notes}
|
||||||
areas={areas}
|
areas={areas}
|
||||||
tags={tags}
|
tags={tags}
|
||||||
|
keyboardShortcuts={keyboardShortcuts}
|
||||||
/>
|
/>
|
||||||
<div
|
<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}`}
|
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}
|
notes={notes}
|
||||||
areas={areas}
|
areas={areas}
|
||||||
tags={tags}
|
tags={tags}
|
||||||
|
keyboardShortcuts={keyboardShortcuts}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
KeyIcon,
|
KeyIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
BellIcon,
|
BellIcon,
|
||||||
|
CommandLineIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import TelegramIcon from '../Shared/Icons/TelegramIcon';
|
import TelegramIcon from '../Shared/Icons/TelegramIcon';
|
||||||
import { useToast } from '../Shared/ToastContext';
|
import { useToast } from '../Shared/ToastContext';
|
||||||
|
|
@ -42,6 +43,8 @@ import ProductivityTab from './tabs/ProductivityTab';
|
||||||
import TelegramTab from './tabs/TelegramTab';
|
import TelegramTab from './tabs/TelegramTab';
|
||||||
import AiTab from './tabs/AiTab';
|
import AiTab from './tabs/AiTab';
|
||||||
import NotificationsTab from './tabs/NotificationsTab';
|
import NotificationsTab from './tabs/NotificationsTab';
|
||||||
|
import KeyboardShortcutsTab from './tabs/KeyboardShortcutsTab';
|
||||||
|
import { getDefaultConfig } from '../../utils/keyboardShortcutsService';
|
||||||
import type {
|
import type {
|
||||||
ProfileSettingsProps,
|
ProfileSettingsProps,
|
||||||
Profile,
|
Profile,
|
||||||
|
|
@ -84,6 +87,7 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({
|
||||||
'telegram',
|
'telegram',
|
||||||
'ai',
|
'ai',
|
||||||
'notifications',
|
'notifications',
|
||||||
|
'keyboard-shortcuts',
|
||||||
];
|
];
|
||||||
return section && validTabs.includes(section) ? section : 'general';
|
return section && validTabs.includes(section) ? section : 'general';
|
||||||
}, [location.search]);
|
}, [location.search]);
|
||||||
|
|
@ -116,6 +120,7 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({
|
||||||
next_task_suggestion_enabled: true,
|
next_task_suggestion_enabled: true,
|
||||||
pomodoro_enabled: true,
|
pomodoro_enabled: true,
|
||||||
notification_preferences: null,
|
notification_preferences: null,
|
||||||
|
keyboard_shortcuts: null,
|
||||||
currentPassword: '',
|
currentPassword: '',
|
||||||
newPassword: '',
|
newPassword: '',
|
||||||
confirmPassword: '',
|
confirmPassword: '',
|
||||||
|
|
@ -512,6 +517,8 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({
|
||||||
: true,
|
: true,
|
||||||
notification_preferences:
|
notification_preferences:
|
||||||
data.notification_preferences || null,
|
data.notification_preferences || null,
|
||||||
|
keyboard_shortcuts:
|
||||||
|
data.keyboard_shortcuts || getDefaultConfig(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data.telegram_bot_token) {
|
if (data.telegram_bot_token) {
|
||||||
|
|
@ -1115,6 +1122,11 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({
|
||||||
name: t('profile.tabs.ai', 'AI Features'),
|
name: t('profile.tabs.ai', 'AI Features'),
|
||||||
icon: <LightBulbIcon className="w-5 h-5" />,
|
icon: <LightBulbIcon className="w-5 h-5" />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'keyboard-shortcuts',
|
||||||
|
name: t('profile.tabs.keyboardShortcuts', 'Shortcuts'),
|
||||||
|
icon: <CommandLineIcon className="w-5 h-5" />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex justify-end dark:border-gray-700">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|
|
||||||
307
frontend/components/Profile/tabs/KeyboardShortcutsTab.tsx
Normal file
307
frontend/components/Profile/tabs/KeyboardShortcutsTab.tsx
Normal 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;
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import type { KeyboardShortcutsConfig } from '../../utils/keyboardShortcutsService';
|
||||||
|
|
||||||
export interface ProfileSettingsProps {
|
export interface ProfileSettingsProps {
|
||||||
currentUser: { uid: string; email: string };
|
currentUser: { uid: string; email: string };
|
||||||
isDarkMode?: boolean;
|
isDarkMode?: boolean;
|
||||||
|
|
@ -58,6 +60,7 @@ export interface Profile {
|
||||||
next_task_suggestion_enabled: boolean;
|
next_task_suggestion_enabled: boolean;
|
||||||
pomodoro_enabled: boolean;
|
pomodoro_enabled: boolean;
|
||||||
notification_preferences?: NotificationPreferences | null;
|
notification_preferences?: NotificationPreferences | null;
|
||||||
|
keyboard_shortcuts?: KeyboardShortcutsConfig | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TelegramBotInfo {
|
export interface TelegramBotInfo {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import SidebarProjects from './Sidebar/SidebarProjects';
|
||||||
import SidebarTags from './Sidebar/SidebarTags';
|
import SidebarTags from './Sidebar/SidebarTags';
|
||||||
import SidebarViews from './Sidebar/SidebarViews';
|
import SidebarViews from './Sidebar/SidebarViews';
|
||||||
import { getFeatureFlags, FeatureFlags } from '../utils/featureFlags';
|
import { getFeatureFlags, FeatureFlags } from '../utils/featureFlags';
|
||||||
|
import { KeyboardShortcutsConfig } from '../utils/keyboardShortcutsService';
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
isSidebarOpen: boolean;
|
isSidebarOpen: boolean;
|
||||||
|
|
@ -28,6 +29,7 @@ interface SidebarProps {
|
||||||
notes: Note[];
|
notes: Note[];
|
||||||
areas: Area[];
|
areas: Area[];
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
|
keyboardShortcuts?: KeyboardShortcutsConfig | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Sidebar: React.FC<SidebarProps> = ({
|
const Sidebar: React.FC<SidebarProps> = ({
|
||||||
|
|
@ -45,6 +47,7 @@ const Sidebar: React.FC<SidebarProps> = ({
|
||||||
notes,
|
notes,
|
||||||
areas,
|
areas,
|
||||||
tags,
|
tags,
|
||||||
|
keyboardShortcuts,
|
||||||
}) => {
|
}) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
@ -149,6 +152,7 @@ const Sidebar: React.FC<SidebarProps> = ({
|
||||||
openNoteModal={openNoteModal}
|
openNoteModal={openNoteModal}
|
||||||
openAreaModal={openAreaModal}
|
openAreaModal={openAreaModal}
|
||||||
openTagModal={openTagModal}
|
openTagModal={openTagModal}
|
||||||
|
keyboardShortcuts={keyboardShortcuts}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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 { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
SunIcon,
|
SunIcon,
|
||||||
|
|
@ -17,6 +17,14 @@ import { Note } from '../../entities/Note';
|
||||||
import { Area } from '../../entities/Area';
|
import { Area } from '../../entities/Area';
|
||||||
import { useTelegramStatus } from '../../contexts/TelegramStatusContext';
|
import { useTelegramStatus } from '../../contexts/TelegramStatusContext';
|
||||||
import { getApiPath } from '../../config/paths';
|
import { getApiPath } from '../../config/paths';
|
||||||
|
import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts';
|
||||||
|
import {
|
||||||
|
KeyboardShortcutsConfig,
|
||||||
|
ShortcutAction,
|
||||||
|
formatShortcutDisplay,
|
||||||
|
getDefaultShortcuts,
|
||||||
|
getShortcutByAction,
|
||||||
|
} from '../../utils/keyboardShortcutsService';
|
||||||
|
|
||||||
interface SidebarFooterProps {
|
interface SidebarFooterProps {
|
||||||
currentUser: { email: string };
|
currentUser: { email: string };
|
||||||
|
|
@ -31,6 +39,7 @@ interface SidebarFooterProps {
|
||||||
openNoteModal: (note: Note | null) => void;
|
openNoteModal: (note: Note | null) => void;
|
||||||
openAreaModal: (area: Area | null) => void;
|
openAreaModal: (area: Area | null) => void;
|
||||||
openTagModal: (tag: any | null) => void;
|
openTagModal: (tag: any | null) => void;
|
||||||
|
keyboardShortcuts?: KeyboardShortcutsConfig | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SidebarFooter: React.FC<SidebarFooterProps> = ({
|
const SidebarFooter: React.FC<SidebarFooterProps> = ({
|
||||||
|
|
@ -43,6 +52,7 @@ const SidebarFooter: React.FC<SidebarFooterProps> = ({
|
||||||
openNoteModal,
|
openNoteModal,
|
||||||
openAreaModal,
|
openAreaModal,
|
||||||
openTagModal,
|
openTagModal,
|
||||||
|
keyboardShortcuts,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||||
|
|
@ -51,6 +61,13 @@ const SidebarFooter: React.FC<SidebarFooterProps> = ({
|
||||||
const [version, setVersion] = useState<string>('v0.86');
|
const [version, setVersion] = useState<string>('v0.86');
|
||||||
const navigate = useNavigate();
|
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 = () => {
|
const toggleDropdown = () => {
|
||||||
setIsDropdownOpen(!isDropdownOpen);
|
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) => {
|
const handleDropdownSelect = (type: string) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'Inbox':
|
case 'Inbox':
|
||||||
|
|
@ -161,44 +135,65 @@ const SidebarFooter: React.FC<SidebarFooterProps> = ({
|
||||||
setIsDropdownOpen(false);
|
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 = [
|
const dropdownItems = [
|
||||||
{
|
{
|
||||||
label: 'Inbox',
|
label: 'Inbox',
|
||||||
translationKey: 'dropdown.inbox',
|
translationKey: 'dropdown.inbox',
|
||||||
icon: <InboxIcon className="h-5 w-5 mr-2" />,
|
icon: <InboxIcon className="h-5 w-5 mr-2" />,
|
||||||
shortcut: 'Ctrl+Shift+I',
|
action: 'inbox' as ShortcutAction,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Task',
|
label: 'Task',
|
||||||
translationKey: 'dropdown.task',
|
translationKey: 'dropdown.task',
|
||||||
icon: <CheckIcon className="h-5 w-5 mr-2" />,
|
icon: <CheckIcon className="h-5 w-5 mr-2" />,
|
||||||
shortcut: 'Ctrl+Shift+T',
|
action: 'task' as ShortcutAction,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Project',
|
label: 'Project',
|
||||||
translationKey: 'dropdown.project',
|
translationKey: 'dropdown.project',
|
||||||
icon: <FolderIcon className="h-5 w-5 mr-2" />,
|
icon: <FolderIcon className="h-5 w-5 mr-2" />,
|
||||||
shortcut: 'Ctrl+Shift+P',
|
action: 'project' as ShortcutAction,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Note',
|
label: 'Note',
|
||||||
translationKey: 'dropdown.note',
|
translationKey: 'dropdown.note',
|
||||||
icon: <BookOpenIcon className="h-5 w-5 mr-2" />,
|
icon: <BookOpenIcon className="h-5 w-5 mr-2" />,
|
||||||
shortcut: 'Ctrl+Shift+N',
|
action: 'note' as ShortcutAction,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Area',
|
label: 'Area',
|
||||||
translationKey: 'dropdown.area',
|
translationKey: 'dropdown.area',
|
||||||
icon: <Squares2X2Icon className="h-5 w-5 mr-2" />,
|
icon: <Squares2X2Icon className="h-5 w-5 mr-2" />,
|
||||||
shortcut: 'Ctrl+Shift+A',
|
action: 'area' as ShortcutAction,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Tag',
|
label: 'Tag',
|
||||||
translationKey: 'dropdown.tag',
|
translationKey: 'dropdown.tag',
|
||||||
icon: <TagIcon className="h-5 w-5 mr-2" />,
|
icon: <TagIcon className="h-5 w-5 mr-2" />,
|
||||||
shortcut: 'Ctrl+Shift+G',
|
action: 'tag' as ShortcutAction,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-auto p-3">
|
<div className="mt-auto p-3">
|
||||||
{/* Version Display */}
|
{/* Version Display */}
|
||||||
|
|
@ -237,7 +232,7 @@ const SidebarFooter: React.FC<SidebarFooterProps> = ({
|
||||||
label,
|
label,
|
||||||
translationKey,
|
translationKey,
|
||||||
icon,
|
icon,
|
||||||
shortcut,
|
action,
|
||||||
}) => (
|
}) => (
|
||||||
<button
|
<button
|
||||||
key={label}
|
key={label}
|
||||||
|
|
@ -256,15 +251,12 @@ const SidebarFooter: React.FC<SidebarFooterProps> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span
|
<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={{
|
style={{
|
||||||
fontSize: '10px',
|
fontSize: '10px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
^ + Shift +{' '}
|
{getShortcutDisplay(action)}
|
||||||
{shortcut
|
|
||||||
.split('+')
|
|
||||||
.pop()}
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
75
frontend/hooks/useKeyboardShortcuts.ts
Normal file
75
frontend/hooks/useKeyboardShortcuts.ts
Normal 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;
|
||||||
184
frontend/utils/keyboardShortcutsService.ts
Normal file
184
frontend/utils/keyboardShortcutsService.ts
Normal 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()}`;
|
||||||
|
};
|
||||||
|
|
@ -310,6 +310,25 @@
|
||||||
"productivityAssistant": "Productivity Assistant",
|
"productivityAssistant": "Productivity Assistant",
|
||||||
"productivityAssistantDescription": "Show productivity insights that help identify stalled projects, vague tasks, and workflow improvements on your Today page.",
|
"productivityAssistantDescription": "Show productivity insights that help identify stalled projects, vague tasks, and workflow improvements on your Today page.",
|
||||||
"enableProductivityAssistant": "Enable Productivity Insights",
|
"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",
|
"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.",
|
"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",
|
"enableNextTaskSuggestion": "Enable Next Task Suggestions",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue