command-registry-context.tsx 4.5 KB

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