mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 21:23:45 +01:00
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:
committed by
Copybot
parent
edc7634007
commit
e0f6ee8b20
@@ -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(' ')
|
||||
}
|
||||
|
||||
+26
-9
@@ -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
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user