Merge pull request #26133 from overleaf/mj-ide-keyboard-shortcuts

[web] Editor redesign: Add keyboard shortcuts to menu bar

GitOrigin-RevId: 8fe844389de70a919ba836d03f0390f585532bb1
This commit is contained in:
Mathias Jakobsen
2025-06-10 08:05:42 +00:00
committed by Copybot
parent edc7634007
commit e0f6ee8b20
2 changed files with 156 additions and 12 deletions
@@ -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<CommandRegistry | undefined>(
undefined
)
export type Shortcut = { key: string }
export type Shortcuts = Record<string, Shortcut[]>
type CommandRegistry = {
registry: Map<string, Command>
register: (...elements: Command[]) => void
unregister: (...id: string[]) => void
shortcuts: Shortcuts
}
export const CommandRegistryProvider: React.FC<React.PropsWithChildren> = ({
@@ -43,8 +54,35 @@ export const CommandRegistryProvider: React.FC<React.PropsWithChildren> = ({
)
}, [])
// NOTE: This is where we'd add functionality for customising shortcuts.
const shortcuts: Record<string, Shortcut[]> = 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 (
<CommandRegistryContext.Provider value={{ registry, register, unregister }}>
<CommandRegistryContext.Provider
value={{ registry, register, unregister, shortcuts }}
>
{children}
</CommandRegistryContext.Provider>
)
@@ -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(' ')
}
@@ -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> = T | GroupStructure<T>
type GroupStructure<T> = {
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<CommandId>
}) => {
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<TaggedCommand> }) => {
onClick={onClickHandler}
href={item.href}
disabled={item.disabled}
trailingIcon={
item.shortcuts && <span>{formatShortcut(item.shortcuts[0])}</span>
}
/>
)
} else {
@@ -127,7 +135,8 @@ function populateSectionOrGroup<
T extends { children: Array<Entry<CommandId>> },
>(
section: T,
registry: Map<string, Command>
registry: Map<string, Command>,
shortcuts: Shortcuts
): Omit<T, 'children'> & {
children: Array<Entry<TaggedCommand>>
} {
@@ -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
})