keybindings.ts 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  1. import { openSearchPanel } from '@codemirror/search'
  2. import {
  3. Compartment,
  4. EditorSelection,
  5. Prec,
  6. TransactionSpec,
  7. } from '@codemirror/state'
  8. import type { EmacsHandler } from '@replit/codemirror-emacs'
  9. import type { CodeMirror, Vim } from '@replit/codemirror-vim'
  10. import { foldCode, toggleFold, unfoldCode } from '@codemirror/language'
  11. import { EditorView } from '@codemirror/view'
  12. import {
  13. cursorToBeginningOfVisualLine,
  14. cursorToEndOfVisualLine,
  15. selectRestOfVisualLine,
  16. selectToBeginningOfVisualLine,
  17. selectToEndOfVisualLine,
  18. } from './visual-line-selection'
  19. const hasNonEmptySelection = (cm: CodeMirror): boolean => {
  20. const selections = cm.getSelections()
  21. return selections.some(selection => selection.length)
  22. }
  23. type VimCodeMirrorCommands = typeof CodeMirror.commands & {
  24. save: (cm: CodeMirror) => void
  25. }
  26. let customisedVim = false
  27. const customiseVimOnce = (_Vim: typeof Vim, _CodeMirror: typeof CodeMirror) => {
  28. if (customisedVim) {
  29. return
  30. }
  31. // Allow copy via Ctrl-C in insert mode
  32. _Vim.unmap('<C-c>', 'insert')
  33. _Vim.defineAction(
  34. 'insertModeCtrlC',
  35. (cm: CodeMirror, actionArgs: object, state: any) => {
  36. if (hasNonEmptySelection(cm)) {
  37. navigator.clipboard.writeText(cm.getSelection())
  38. cm.setSelection(cm.getCursor(), cm.getCursor())
  39. } else {
  40. _Vim.exitInsertMode(cm)
  41. }
  42. }
  43. )
  44. // Overwrite the moveByCharacters command with a decoration-aware version
  45. _Vim.defineMotion(
  46. 'moveByCharacters',
  47. function (
  48. cm: CodeMirror,
  49. head: { line: number; ch: number },
  50. motionArgs: Record<string, unknown>
  51. ) {
  52. const { cm6: view } = cm
  53. const repeat = Math.min(Number(motionArgs.repeat), view.state.doc.length)
  54. const forward = Boolean(motionArgs.forward)
  55. // head.line is 0-indexed
  56. const startLine = view.state.doc.line(head.line + 1)
  57. let cursor = EditorSelection.cursor(startLine.from + head.ch)
  58. for (let i = 0; i < repeat; ++i) {
  59. cursor = view.moveByChar(cursor, forward)
  60. }
  61. const finishLine = view.state.doc.lineAt(cursor.head)
  62. return new _CodeMirror.Pos(
  63. finishLine.number - 1,
  64. cursor.head - finishLine.from
  65. )
  66. }
  67. )
  68. _Vim.mapCommand('<C-c>', 'action', 'insertModeCtrlC', undefined, {
  69. context: 'insert',
  70. })
  71. // Code folding commands
  72. _Vim.defineAction('toggleFold', function (cm: CodeMirror) {
  73. toggleFold(cm.cm6)
  74. })
  75. _Vim.mapCommand('za', 'action', 'toggleFold')
  76. _Vim.defineAction('foldCode', function (cm: CodeMirror) {
  77. foldCode(cm.cm6)
  78. })
  79. _Vim.mapCommand('zc', 'action', 'foldCode')
  80. _Vim.defineAction('unfoldCode', function (cm: CodeMirror) {
  81. unfoldCode(cm.cm6)
  82. })
  83. // disable tab and shift-tab keys in command (normal) and visual modes
  84. // using "undefined" params because mapCommand signature is:
  85. // mapCommand(keys, type, name, args, extra)
  86. _Vim.mapCommand('<Tab>', undefined, undefined, undefined, {
  87. context: 'normal',
  88. })
  89. _Vim.mapCommand('<Tab>', undefined, undefined, undefined, {
  90. context: 'visual',
  91. })
  92. _Vim.mapCommand('<S-Tab>', undefined, undefined, undefined, {
  93. context: 'normal',
  94. })
  95. _Vim.mapCommand('<S-Tab>', undefined, undefined, undefined, {
  96. context: 'visual',
  97. })
  98. // Make the Vim 'write' command start a compile
  99. ;(_CodeMirror.commands as VimCodeMirrorCommands).save = () => {
  100. window.dispatchEvent(new Event('pdf:recompile'))
  101. }
  102. customisedVim = true
  103. }
  104. // Used to ensure that only one listener is active
  105. let emacsSearchCloseListener: (() => void) | undefined
  106. let customisedEmacs = false
  107. const customiseEmacsOnce = (_EmacsHandler: typeof EmacsHandler) => {
  108. if (customisedEmacs) {
  109. return
  110. }
  111. customisedEmacs = true
  112. const jumpToLastMark = (handler: EmacsHandler) => {
  113. const mark = handler.popEmacsMark()
  114. if (!mark || !mark.length) {
  115. return
  116. }
  117. let selection = null
  118. if (mark.length >= 2) {
  119. selection = EditorSelection.range(mark[0], mark[1])
  120. } else {
  121. selection = EditorSelection.cursor(mark[0])
  122. }
  123. handler.view.dispatch({ selection, scrollIntoView: true })
  124. }
  125. _EmacsHandler.addCommands({
  126. openSearch(handler: EmacsHandler) {
  127. const mark = handler.view.state.selection.main
  128. handler.pushEmacsMark([mark.anchor, mark.head])
  129. openSearchPanel(handler.view)
  130. if (emacsSearchCloseListener) {
  131. document.removeEventListener(
  132. 'cm:emacs-close-search-panel',
  133. emacsSearchCloseListener
  134. )
  135. }
  136. emacsSearchCloseListener = () => {
  137. jumpToLastMark(handler)
  138. }
  139. document.addEventListener(
  140. 'cm:emacs-close-search-panel',
  141. emacsSearchCloseListener
  142. )
  143. },
  144. save() {
  145. window.dispatchEvent(new Event('pdf:recompile'))
  146. },
  147. })
  148. _EmacsHandler.bindKey('C-s', 'openSearch')
  149. _EmacsHandler.bindKey('C-r', 'openSearch')
  150. _EmacsHandler.bindKey('C-x C-s', 'save')
  151. _EmacsHandler.bindKey('C-a', {
  152. command: 'goOrSelect',
  153. args: [cursorToBeginningOfVisualLine, selectToBeginningOfVisualLine],
  154. })
  155. _EmacsHandler.bindKey('C-e', {
  156. command: 'goOrSelect',
  157. args: [cursorToEndOfVisualLine, selectToEndOfVisualLine],
  158. })
  159. _EmacsHandler.bindKey('C-k', {
  160. command: 'killLine',
  161. args: selectRestOfVisualLine,
  162. })
  163. }
  164. const options = [
  165. {
  166. name: 'default',
  167. load: async () => {
  168. // TODO: load default keybindings?
  169. return []
  170. },
  171. },
  172. {
  173. name: 'vim',
  174. load: () =>
  175. import(
  176. /* webpackChunkName: "codemirror-vim" */ '@replit/codemirror-vim'
  177. ).then(m => {
  178. customiseVimOnce(m.Vim, m.CodeMirror)
  179. return m.vim()
  180. }),
  181. },
  182. {
  183. name: 'emacs',
  184. load: () =>
  185. import(
  186. /* webpackChunkName: "codemirror-emacs" */ '@replit/codemirror-emacs'
  187. ).then(m => {
  188. customiseEmacsOnce(m.EmacsHandler)
  189. return [
  190. m.emacs(),
  191. EditorView.domEventHandlers({
  192. keydown(event) {
  193. if (event.ctrlKey && event.key === 's') {
  194. event.stopPropagation()
  195. }
  196. },
  197. }),
  198. ]
  199. }),
  200. },
  201. ]
  202. const keybindingsConf = new Compartment()
  203. /**
  204. * Third-party extensions providing Emacs and Vim keybindings,
  205. * implemented as wrappers around the CodeMirror 5 interface,
  206. * with some customisation (particularly related to search).
  207. */
  208. export const keybindings = () => {
  209. return keybindingsConf.of(Prec.highest([]))
  210. }
  211. export const setKeybindings = async (
  212. selectedKeybindings = 'default'
  213. ): Promise<TransactionSpec> => {
  214. if (selectedKeybindings === 'none') {
  215. selectedKeybindings = 'default'
  216. }
  217. const selectedOption = options.find(
  218. option => option.name === selectedKeybindings
  219. )
  220. if (!selectedOption) {
  221. throw new Error(`No key bindings found with name ${selectedKeybindings}`)
  222. }
  223. const support = await selectedOption.load()
  224. return {
  225. // NOTE: use Prec.highest as this keybinding must be above the default keymap(s)
  226. effects: keybindingsConf.reconfigure(Prec.highest(support)),
  227. }
  228. }