Răsfoiți Sursa

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

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

GitOrigin-RevId: 8fe844389de70a919ba836d03f0390f585532bb1
Mathias Jakobsen 1 an în urmă
părinte
comite
e0f6ee8b20

+ 130 - 3
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<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
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> = 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
       })