From 703f6fe50651fd59c0d8d165ae84cccac078cad1 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 28 Dec 2025 21:58:21 +0200 Subject: [PATCH] Add custom keyboard shortcuts (#749) * Add custom keyboard shortcuts * fixup! Add custom keyboard shortcuts --- .../20260201000002-add-keyboard-shortcuts.js | 19 ++ backend/models/user.js | 7 + backend/routes/users.js | 5 + frontend/Layout.tsx | 24 ++ .../components/Profile/ProfileSettings.tsx | 23 ++ .../Profile/tabs/KeyboardShortcutsTab.tsx | 307 ++++++++++++++++++ frontend/components/Profile/types.ts | 3 + frontend/components/Sidebar.tsx | 4 + frontend/components/Sidebar/SidebarFooter.tsx | 104 +++--- frontend/hooks/useKeyboardShortcuts.ts | 75 +++++ frontend/utils/keyboardShortcutsService.ts | 184 +++++++++++ public/locales/en/translation.json | 19 ++ 12 files changed, 718 insertions(+), 56 deletions(-) create mode 100644 backend/migrations/20260201000002-add-keyboard-shortcuts.js create mode 100644 frontend/components/Profile/tabs/KeyboardShortcutsTab.tsx create mode 100644 frontend/hooks/useKeyboardShortcuts.ts create mode 100644 frontend/utils/keyboardShortcutsService.ts diff --git a/backend/migrations/20260201000002-add-keyboard-shortcuts.js b/backend/migrations/20260201000002-add-keyboard-shortcuts.js new file mode 100644 index 0000000..0a00770 --- /dev/null +++ b/backend/migrations/20260201000002-add-keyboard-shortcuts.js @@ -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'); + }, +}; diff --git a/backend/models/user.js b/backend/models/user.js index 6e52ef3..b269e6e 100644 --- a/backend/models/user.js +++ b/backend/models/user.js @@ -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, diff --git a/backend/routes/users.js b/backend/routes/users.js index 8c50509..307440d 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -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', ], }); diff --git a/frontend/Layout.tsx b/frontend/Layout.tsx index 595bac1..dacf6ca 100644 --- a/frontend/Layout.tsx +++ b/frontend/Layout.tsx @@ -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 = ({ const [selectedNote, setSelectedNote] = useState(null); const [selectedArea, setSelectedArea] = useState(null); const [selectedTag, setSelectedTag] = useState(null); + const [keyboardShortcuts, setKeyboardShortcuts] = useState(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 = ({ notes={notes} areas={areas} tags={tags} + keyboardShortcuts={keyboardShortcuts} />
= ({ notes={notes} areas={areas} tags={tags} + keyboardShortcuts={keyboardShortcuts} />
= ({ notes={notes} areas={areas} tags={tags} + keyboardShortcuts={keyboardShortcuts} />
= ({ 'telegram', 'ai', 'notifications', + 'keyboard-shortcuts', ]; return section && validTabs.includes(section) ? section : 'general'; }, [location.search]); @@ -116,6 +120,7 @@ const ProfileSettings: React.FC = ({ 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 = ({ : 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 = ({ name: t('profile.tabs.ai', 'AI Features'), icon: , }, + { + id: 'keyboard-shortcuts', + name: t('profile.tabs.keyboardShortcuts', 'Shortcuts'), + icon: , + }, ]; return ( @@ -1295,6 +1307,17 @@ const ProfileSettings: React.FC = ({ } /> + + setFormData((prev) => ({ + ...prev, + keyboard_shortcuts: config, + })) + } + /> +
+ + +
+ ) : ( +
+ {shortcut && ( + + {formatShortcutDisplay(shortcut)} + + )} + +
+ )} +
+ ); + })} +
+ + {/* Reset to Defaults */} +
+ +
+ + {/* Info Box */} +
+

+ {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.' + )} +

+
+
+ ); +}; + +export default KeyboardShortcutsTab; diff --git a/frontend/components/Profile/types.ts b/frontend/components/Profile/types.ts index 68cd267..6c0ce63 100644 --- a/frontend/components/Profile/types.ts +++ b/frontend/components/Profile/types.ts @@ -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 { diff --git a/frontend/components/Sidebar.tsx b/frontend/components/Sidebar.tsx index 6b0e048..5e236e1 100644 --- a/frontend/components/Sidebar.tsx +++ b/frontend/components/Sidebar.tsx @@ -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 = ({ @@ -45,6 +47,7 @@ const Sidebar: React.FC = ({ notes, areas, tags, + keyboardShortcuts, }) => { const navigate = useNavigate(); const location = useLocation(); @@ -149,6 +152,7 @@ const Sidebar: React.FC = ({ openNoteModal={openNoteModal} openAreaModal={openAreaModal} openTagModal={openTagModal} + keyboardShortcuts={keyboardShortcuts} /> )} diff --git a/frontend/components/Sidebar/SidebarFooter.tsx b/frontend/components/Sidebar/SidebarFooter.tsx index 0edd3df..1d2a91e 100644 --- a/frontend/components/Sidebar/SidebarFooter.tsx +++ b/frontend/components/Sidebar/SidebarFooter.tsx @@ -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 = ({ @@ -43,6 +52,7 @@ const SidebarFooter: React.FC = ({ openNoteModal, openAreaModal, openTagModal, + keyboardShortcuts, }) => { const { t } = useTranslation(); const [isDropdownOpen, setIsDropdownOpen] = useState(false); @@ -51,6 +61,13 @@ const SidebarFooter: React.FC = ({ const [version, setVersion] = useState('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 = ({ }); }, []); - // 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 = ({ 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: , - shortcut: 'Ctrl+Shift+I', + action: 'inbox' as ShortcutAction, }, { label: 'Task', translationKey: 'dropdown.task', icon: , - shortcut: 'Ctrl+Shift+T', + action: 'task' as ShortcutAction, }, { label: 'Project', translationKey: 'dropdown.project', icon: , - shortcut: 'Ctrl+Shift+P', + action: 'project' as ShortcutAction, }, { label: 'Note', translationKey: 'dropdown.note', icon: , - shortcut: 'Ctrl+Shift+N', + action: 'note' as ShortcutAction, }, { label: 'Area', translationKey: 'dropdown.area', icon: , - shortcut: 'Ctrl+Shift+A', + action: 'area' as ShortcutAction, }, { label: 'Tag', translationKey: 'dropdown.tag', icon: , - shortcut: 'Ctrl+Shift+G', + action: 'tag' as ShortcutAction, }, ]; + return (
{/* Version Display */} @@ -237,7 +232,7 @@ const SidebarFooter: React.FC = ({ label, translationKey, icon, - shortcut, + action, }) => (
- ^ + Shift +{' '} - {shortcut - .split('+') - .pop()} + {getShortcutDisplay(action)} ) diff --git a/frontend/hooks/useKeyboardShortcuts.ts b/frontend/hooks/useKeyboardShortcuts.ts new file mode 100644 index 0000000..d7009c1 --- /dev/null +++ b/frontend/hooks/useKeyboardShortcuts.ts @@ -0,0 +1,75 @@ +import { useEffect, useCallback, useMemo } from 'react'; +import { + KeyboardShortcut, + ShortcutAction, + matchesShortcut, + isInputElement, + getDefaultShortcuts, +} from '../utils/keyboardShortcutsService'; + +type ShortcutHandlers = Partial 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; diff --git a/frontend/utils/keyboardShortcutsService.ts b/frontend/utils/keyboardShortcutsService.ts new file mode 100644 index 0000000..53e51c4 --- /dev/null +++ b/frontend/utils/keyboardShortcutsService.ts @@ -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 = { + 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(); + 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()}`; +}; diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 98ce005..7631fda 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -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",