command-registry-context.tsx 4.6 KB

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