command-registry-context.tsx 4.4 KB

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