command-registry-context.tsx 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. import { isMac } from '@/shared/utils/os'
  2. import {
  3. createContext,
  4. useCallback,
  5. useContext,
  6. useMemo,
  7. useState,
  8. } from 'react'
  9. type CommandInvocationContext = {
  10. location?: string
  11. }
  12. export type Command = {
  13. label: string
  14. // Optional label for the command when it appears in a menu. If not provided, `label` will be used.
  15. menuLabel?: string
  16. id: string
  17. handler?: (context: CommandInvocationContext) => void
  18. href?: string
  19. disabled?: boolean
  20. }
  21. const CommandRegistryContext = createContext<CommandRegistry | undefined>(
  22. undefined
  23. )
  24. export type Shortcut = { key: string }
  25. export type Shortcuts = Record<string, Shortcut[]>
  26. type CommandRegistry = {
  27. registry: Map<string, Command>
  28. register: (...elements: Command[]) => void
  29. unregister: (...id: string[]) => void
  30. shortcuts: Shortcuts
  31. }
  32. export const CommandRegistryProvider: React.FC<React.PropsWithChildren> = ({
  33. children,
  34. }) => {
  35. const [registry, setRegistry] = useState(new Map<string, Command>())
  36. const register = useCallback((...elements: Command[]) => {
  37. setRegistry(
  38. registry =>
  39. new Map([
  40. ...registry,
  41. ...elements.map(element => [element.id, element] as const),
  42. ])
  43. )
  44. }, [])
  45. const unregister = useCallback((...ids: string[]) => {
  46. setRegistry(
  47. registry => new Map([...registry].filter(([key]) => !ids.includes(key)))
  48. )
  49. }, [])
  50. // NOTE: This is where we'd add functionality for customising shortcuts.
  51. const shortcuts: Record<string, Shortcut[]> = useMemo(
  52. () => ({
  53. cut: [{ key: 'Mod-x' }],
  54. copy: [{ key: 'Mod-c' }],
  55. paste: [{ key: 'Mod-v' }],
  56. 'paste-special': [{ key: 'Mod-Shift-V' }],
  57. 'toggle-track-changes': [{ key: 'Mod-Shift-A' }],
  58. undo: [
  59. {
  60. key: 'Mod-z',
  61. },
  62. ],
  63. redo: [
  64. {
  65. key: 'Mod-y',
  66. },
  67. {
  68. key: 'Mod-Shift-Z',
  69. },
  70. ],
  71. find: [{ key: 'Mod-f' }],
  72. 'select-all': [{ key: 'Mod-a' }],
  73. 'insert-comment': [{ key: 'Mod-Shift-C' }],
  74. 'format-bold': [{ key: 'Mod-b' }],
  75. 'format-italics': [{ key: 'Mod-i' }],
  76. }),
  77. []
  78. )
  79. return (
  80. <CommandRegistryContext.Provider
  81. value={{ registry, register, unregister, shortcuts }}
  82. >
  83. {children}
  84. </CommandRegistryContext.Provider>
  85. )
  86. }
  87. export const useCommandRegistry = (): CommandRegistry => {
  88. const context = useContext(CommandRegistryContext)
  89. if (!context) {
  90. throw new Error(
  91. 'useCommandRegistry must be used within a CommandRegistryProvider'
  92. )
  93. }
  94. return context
  95. }
  96. function parseShortcut(shortcut: Shortcut) {
  97. // Based on KeyBinding type of CodeMirror 6
  98. let alt = false
  99. let ctrl = false
  100. let shift = false
  101. let meta = false
  102. let character = null
  103. // isMac ? shortcut.mac : shortcut.key etc.
  104. const shortcutString = shortcut.key ?? ''
  105. const keys = shortcutString.split(/-(?!$)/) ?? []
  106. for (let i = 0; i < keys.length; i++) {
  107. const isLast = i === keys.length - 1
  108. const key = keys[i]
  109. if (!key) {
  110. throw new Error('Empty key in shortcut: ' + shortcutString)
  111. }
  112. if (key === 'Alt' || (!isLast && key === 'a')) {
  113. alt = true
  114. } else if (
  115. key === 'Ctrl' ||
  116. key === 'Control' ||
  117. (!isLast && key === 'c')
  118. ) {
  119. ctrl = true
  120. } else if (key === 'Shift' || (!isLast && key === 's')) {
  121. shift = true
  122. } else if (key === 'Meta' || key === 'Cmd' || (!isLast && key === 'm')) {
  123. meta = true
  124. } else if (key === 'Mod') {
  125. if (isMac) {
  126. meta = true
  127. } else {
  128. ctrl = true
  129. }
  130. } else {
  131. if (key === 'Space') {
  132. character = ' '
  133. }
  134. if (!isLast) {
  135. throw new Error(
  136. 'Character key must be last in shortcut: ' + shortcutString
  137. )
  138. }
  139. if (key.length !== 1) {
  140. throw new Error(`Invalid key '${key}' in shortcut: ${shortcutString}`)
  141. }
  142. if (character) {
  143. throw new Error('Multiple characters in shortcut: ' + shortcutString)
  144. }
  145. character = key
  146. }
  147. }
  148. if (!character) {
  149. throw new Error('No character in shortcut: ' + shortcutString)
  150. }
  151. return {
  152. alt,
  153. ctrl,
  154. shift,
  155. meta,
  156. character,
  157. }
  158. }
  159. export const formatShortcut = (shortcut: Shortcut): string => {
  160. const { alt, ctrl, shift, meta, character } = parseShortcut(shortcut)
  161. if (isMac) {
  162. return [
  163. ctrl ? '⌃' : '',
  164. alt ? '⌥' : '',
  165. shift ? '⇧' : '',
  166. meta ? '⌘' : '',
  167. character.toUpperCase(),
  168. ].join('')
  169. }
  170. return [
  171. ctrl ? 'Ctrl' : '',
  172. shift ? 'Shift' : '',
  173. meta ? 'Meta' : '',
  174. alt ? 'Alt' : '',
  175. character.toUpperCase(),
  176. ].join(' ')
  177. }