From e0f6ee8b206eab4f30a15aa57cf1ef4aa3746391 Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Mon, 9 Jun 2025 10:16:41 +0100 Subject: [PATCH] Merge pull request #26133 from overleaf/mj-ide-keyboard-shortcuts [web] Editor redesign: Add keyboard shortcuts to menu bar GitOrigin-RevId: 8fe844389de70a919ba836d03f0390f585532bb1 --- .../context/command-registry-context.tsx | 133 +++++++++++++++++- .../components/toolbar/command-dropdown.tsx | 35 +++-- 2 files changed, 156 insertions(+), 12 deletions(-) diff --git a/services/web/frontend/js/features/ide-react/context/command-registry-context.tsx b/services/web/frontend/js/features/ide-react/context/command-registry-context.tsx index e8bec19b8b..ff54c21f2a 100644 --- a/services/web/frontend/js/features/ide-react/context/command-registry-context.tsx +++ b/services/web/frontend/js/features/ide-react/context/command-registry-context.tsx @@ -1,4 +1,11 @@ -import { createContext, useCallback, useContext, useState } from 'react' +import { isMac } from '@/shared/utils/os' +import { + createContext, + useCallback, + useContext, + useMemo, + useState, +} from 'react' type CommandInvocationContext = { location?: string @@ -10,17 +17,21 @@ export type Command = { handler?: (context: CommandInvocationContext) => void href?: string disabled?: boolean - // TODO: Keybinding? } const CommandRegistryContext = createContext( undefined ) +export type Shortcut = { key: string } + +export type Shortcuts = Record + type CommandRegistry = { registry: Map register: (...elements: Command[]) => void unregister: (...id: string[]) => void + shortcuts: Shortcuts } export const CommandRegistryProvider: React.FC = ({ @@ -43,8 +54,35 @@ export const CommandRegistryProvider: React.FC = ({ ) }, []) + // NOTE: This is where we'd add functionality for customising shortcuts. + const shortcuts: Record = useMemo( + () => ({ + undo: [ + { + key: 'Mod-z', + }, + ], + redo: [ + { + key: 'Mod-y', + }, + { + key: 'Mod-Shift-Z', + }, + ], + find: [{ key: 'Mod-f' }], + 'select-all': [{ key: 'Mod-a' }], + 'insert-comment': [{ key: 'Mod-Shift-C' }], + 'format-bold': [{ key: 'Mod-b' }], + 'format-italics': [{ key: 'Mod-i' }], + }), + [] + ) + return ( - + {children} ) @@ -59,3 +97,92 @@ export const useCommandRegistry = (): CommandRegistry => { } return context } + +function parseShortcut(shortcut: Shortcut) { + // Based on KeyBinding type of CodeMirror 6 + let alt = false + let ctrl = false + let shift = false + let meta = false + + let character = null + // isMac ? shortcut.mac : shortcut.key etc. + const shortcutString = shortcut.key ?? '' + const keys = shortcutString.split(/-(?!$)/) ?? [] + + for (let i = 0; i < keys.length; i++) { + const isLast = i === keys.length - 1 + const key = keys[i] + if (!key) { + throw new Error('Empty key in shortcut: ' + shortcutString) + } + if (key === 'Alt' || (!isLast && key === 'a')) { + alt = true + } else if ( + key === 'Ctrl' || + key === 'Control' || + (!isLast && key === 'c') + ) { + ctrl = true + } else if (key === 'Shift' || (!isLast && key === 's')) { + shift = true + } else if (key === 'Meta' || key === 'Cmd' || (!isLast && key === 'm')) { + meta = true + } else if (key === 'Mod') { + if (isMac) { + meta = true + } else { + ctrl = true + } + } else { + if (key === 'Space') { + character = ' ' + } + if (!isLast) { + throw new Error( + 'Character key must be last in shortcut: ' + shortcutString + ) + } + if (key.length !== 1) { + throw new Error(`Invalid key '${key}' in shortcut: ${shortcutString}`) + } + if (character) { + throw new Error('Multiple characters in shortcut: ' + shortcutString) + } + character = key + } + } + if (!character) { + throw new Error('No character in shortcut: ' + shortcutString) + } + + return { + alt, + ctrl, + shift, + meta, + character, + } +} + +export const formatShortcut = (shortcut: Shortcut): string => { + const { alt, ctrl, shift, meta, character } = parseShortcut(shortcut) + + if (isMac) { + return [ + ctrl ? '⌃' : '', + alt ? '⌥' : '', + shift ? '⇧' : '', + meta ? '⌘' : '', + character.toUpperCase(), + ].join('') + } + + return [ + ctrl ? 'Ctrl' : '', + shift ? 'Shift' : '', + meta ? 'Meta' : '', + alt ? 'Alt' : '', + character.toUpperCase(), + ].join(' ') +} diff --git a/services/web/frontend/js/features/ide-redesign/components/toolbar/command-dropdown.tsx b/services/web/frontend/js/features/ide-redesign/components/toolbar/command-dropdown.tsx index e08cf8873a..2dc696cdbf 100644 --- a/services/web/frontend/js/features/ide-redesign/components/toolbar/command-dropdown.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/toolbar/command-dropdown.tsx @@ -1,5 +1,7 @@ import { Command, + formatShortcut, + Shortcuts, useCommandRegistry, } from '@/features/ide-react/context/command-registry-context' import { @@ -14,7 +16,10 @@ import { MenuBarOption } from '@/shared/components/menu-bar/menu-bar-option' import { Fragment, useCallback, useMemo } from 'react' type CommandId = string -type TaggedCommand = Command & { type: 'command' } +type TaggedCommand = Command & { + type: 'command' + shortcuts?: Shortcuts[CommandId] +} type Entry = T | GroupStructure type GroupStructure = { id: string @@ -37,13 +42,13 @@ const CommandDropdown = ({ title: string id: string }) => { - const { registry } = useCommandRegistry() + const { registry, shortcuts } = useCommandRegistry() const populatedSections = useMemo( () => menu - .map(section => populateSectionOrGroup(section, registry)) + .map(section => populateSectionOrGroup(section, registry, shortcuts)) .filter(x => x.children.length > 0), - [menu, registry] + [menu, registry, shortcuts] ) if (populatedSections.length === 0) { @@ -76,8 +81,8 @@ export const CommandSection = ({ }: { section: MenuSectionStructure }) => { - const { registry } = useCommandRegistry() - const section = populateSectionOrGroup(sectionStructure, registry) + const { registry, shortcuts } = useCommandRegistry() + const section = populateSectionOrGroup(sectionStructure, registry, shortcuts) if (section.children.length === 0) { return null } @@ -108,6 +113,9 @@ const CommandDropdownChild = ({ item }: { item: Entry }) => { onClick={onClickHandler} href={item.href} disabled={item.disabled} + trailingIcon={ + item.shortcuts && {formatShortcut(item.shortcuts[0])} + } /> ) } else { @@ -127,7 +135,8 @@ function populateSectionOrGroup< T extends { children: Array> }, >( section: T, - registry: Map + registry: Map, + shortcuts: Shortcuts ): Omit & { children: Array> } { @@ -137,7 +146,11 @@ function populateSectionOrGroup< children: children .map(child => { if (typeof child !== 'string') { - const populatedChild = populateSectionOrGroup(child, registry) + const populatedChild = populateSectionOrGroup( + child, + registry, + shortcuts + ) if (populatedChild.children.length === 0) { // Skip empty groups return undefined @@ -146,7 +159,11 @@ function populateSectionOrGroup< } const command = registry.get(child) if (command) { - return { ...command, type: 'command' as const } + return { + ...command, + shortcuts: shortcuts[command.id], + type: 'command' as const, + } } return undefined })