keybindings.ts 6.5 KB

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