command-registry-context.tsx 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  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. undo: [
  52. {
  53. key: 'Mod-z',
  54. },
  55. ],
  56. redo: [
  57. {
  58. key: 'Mod-y',
  59. },
  60. {
  61. key: 'Mod-Shift-Z',
  62. },
  63. ],
  64. find: [{ key: 'Mod-f' }],
  65. 'select-all': [{ key: 'Mod-a' }],
  66. 'insert-comment': [{ key: 'Mod-Shift-C' }],
  67. 'format-bold': [{ key: 'Mod-b' }],
  68. 'format-italics': [{ key: 'Mod-i' }],
  69. }),
  70. []
  71. )
  72. return (
  73. <CommandRegistryContext.Provider
  74. value={{ registry, register, unregister, shortcuts }}
  75. >
  76. {children}
  77. </CommandRegistryContext.Provider>
  78. )
  79. }
  80. export const useCommandRegistry = (): CommandRegistry => {
  81. const context = useContext(CommandRegistryContext)
  82. if (!context) {
  83. throw new Error(
  84. 'useCommandRegistry must be used within a CommandRegistryProvider'
  85. )
  86. }
  87. return context
  88. }
  89. function parseShortcut(shortcut: Shortcut) {
  90. // Based on KeyBinding type of CodeMirror 6
  91. let alt = false
  92. let ctrl = false
  93. let shift = false
  94. let meta = false
  95. let character = null
  96. // isMac ? shortcut.mac : shortcut.key etc.
  97. const shortcutString = shortcut.key ?? ''
  98. const keys = shortcutString.split(/-(?!$)/) ?? []
  99. for (let i = 0; i < keys.length; i++) {
  100. const isLast = i === keys.length - 1
  101. const key = keys[i]
  102. if (!key) {
  103. throw new Error('Empty key in shortcut: ' + shortcutString)
  104. }
  105. if (key === 'Alt' || (!isLast && key === 'a')) {
  106. alt = true
  107. } else if (
  108. key === 'Ctrl' ||
  109. key === 'Control' ||
  110. (!isLast && key === 'c')
  111. ) {
  112. ctrl = true
  113. } else if (key === 'Shift' || (!isLast && key === 's')) {
  114. shift = true
  115. } else if (key === 'Meta' || key === 'Cmd' || (!isLast && key === 'm')) {
  116. meta = true
  117. } else if (key === 'Mod') {
  118. if (isMac) {
  119. meta = true
  120. } else {
  121. ctrl = true
  122. }
  123. } else {
  124. if (key === 'Space') {
  125. character = ' '
  126. }
  127. if (!isLast) {
  128. throw new Error(
  129. 'Character key must be last in shortcut: ' + shortcutString
  130. )
  131. }
  132. if (key.length !== 1) {
  133. throw new Error(`Invalid key '${key}' in shortcut: ${shortcutString}`)
  134. }
  135. if (character) {
  136. throw new Error('Multiple characters in shortcut: ' + shortcutString)
  137. }
  138. character = key
  139. }
  140. }
  141. if (!character) {
  142. throw new Error('No character in shortcut: ' + shortcutString)
  143. }
  144. return {
  145. alt,
  146. ctrl,
  147. shift,
  148. meta,
  149. character,
  150. }
  151. }
  152. export const formatShortcut = (shortcut: Shortcut): string => {
  153. const { alt, ctrl, shift, meta, character } = parseShortcut(shortcut)
  154. if (isMac) {
  155. return [
  156. ctrl ? '⌃' : '',
  157. alt ? '⌥' : '',
  158. shift ? '⇧' : '',
  159. meta ? '⌘' : '',
  160. character.toUpperCase(),
  161. ].join('')
  162. }
  163. return [
  164. ctrl ? 'Ctrl' : '',
  165. shift ? 'Shift' : '',
  166. meta ? 'Meta' : '',
  167. alt ? 'Alt' : '',
  168. character.toUpperCase(),
  169. ].join(' ')
  170. }