commands.ts 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  1. import { EditorSelection, EditorState, SelectionRange } from '@codemirror/state'
  2. import { Command, EditorView } from '@codemirror/view'
  3. import {
  4. closeSearchPanel,
  5. openSearchPanel,
  6. searchPanelOpen,
  7. } from '@codemirror/search'
  8. import { sendMB } from '@/infrastructure/event-tracking'
  9. import { toggleRanges, wrapRanges } from '../../commands/ranges'
  10. import {
  11. ancestorListType,
  12. toggleListForRanges,
  13. unwrapBulletList,
  14. unwrapDescriptionList,
  15. unwrapNumberedList,
  16. wrapInBulletList,
  17. wrapInDescriptionList,
  18. wrapInNumberedList,
  19. } from './lists'
  20. import { snippet } from '@codemirror/autocomplete'
  21. import { snippets } from './snippets'
  22. import { minimumListDepthForSelection } from '../../utils/tree-operations/ancestors'
  23. import { isVisual } from '../visual/visual'
  24. import { sendSearchEvent } from '@/features/event-tracking/search-events'
  25. export const toggleBold = toggleRanges('\\textbf')
  26. export const toggleItalic = toggleRanges('\\textit')
  27. // TODO: apply as a snippet?
  28. // TODO: read URL from clipboard?
  29. export const wrapInHref = wrapRanges('\\href{}{', '}', false, (range, view) =>
  30. isVisual(view) ? range : EditorSelection.cursor(range.from - 2)
  31. )
  32. export const toggleBulletList = toggleListForRanges('itemize')
  33. export const toggleNumberedList = toggleListForRanges('enumerate')
  34. export const wrapInInlineMath = wrapRanges('\\(', '\\)')
  35. export const wrapInDisplayMath = wrapRanges('\n\\[', '\\]\n')
  36. export const ensureEmptyLine = (
  37. state: EditorState,
  38. range: SelectionRange,
  39. direction: 'above' | 'below' = 'below'
  40. ) => {
  41. let pos = range.anchor
  42. let suffix = ''
  43. let prefix = ''
  44. const line = state.doc.lineAt(pos)
  45. if (line.text.trim().length) {
  46. if (direction === 'below') {
  47. pos = Math.min(line.to + 1, state.doc.length)
  48. } else {
  49. pos = Math.max(line.from - 1, 0)
  50. }
  51. const neighbouringLine = state.doc.lineAt(pos)
  52. if (neighbouringLine.length && direction === 'below') {
  53. suffix = '\n'
  54. } else if (neighbouringLine.length && direction === 'above') {
  55. prefix = '\n'
  56. }
  57. }
  58. return { pos, suffix, prefix }
  59. }
  60. export const insertFigure: Command = view => {
  61. const { state, dispatch } = view
  62. const { pos, suffix } = ensureEmptyLine(state, state.selection.main)
  63. const template = `\n${snippets.figure}\n${suffix}`
  64. snippet(template)({ state, dispatch }, { label: 'Figure' }, pos, pos)
  65. return true
  66. }
  67. export const insertTable = (view: EditorView, sizeX: number, sizeY: number) => {
  68. const { state, dispatch } = view
  69. const visual = isVisual(view)
  70. const placeholder = visual ? '' : '#{}'
  71. const placeholderAtStart = visual ? '#{}' : ''
  72. const { pos, suffix } = ensureEmptyLine(state, state.selection.main)
  73. const template = `${placeholderAtStart}\n\\begin{table}
  74. \t\\centering
  75. \t\\begin{tabular}{${'c'.repeat(sizeX)}}
  76. ${(
  77. '\t\t' +
  78. `${placeholder} & ${placeholder}`.repeat(sizeX - 1) +
  79. '\\\\\n'
  80. ).repeat(sizeY)}\t\\end{tabular}
  81. \t\\caption{Caption}
  82. \t\\label{tab:placeholder}
  83. \\end{table}${suffix}`
  84. snippet(template)({ state, dispatch }, { label: 'Table' }, pos, pos)
  85. return true
  86. }
  87. export const insertCite: Command = view => {
  88. const { state, dispatch } = view
  89. const pos = state.selection.main.anchor
  90. const template = snippets.cite
  91. snippet(template)({ state, dispatch }, { label: 'Cite' }, pos, pos)
  92. return true
  93. }
  94. export const insertRef: Command = view => {
  95. const { state, dispatch } = view
  96. const pos = state.selection.main.anchor
  97. const template = snippets.ref
  98. snippet(template)({ state, dispatch }, { label: 'Ref' }, pos, pos)
  99. return true
  100. }
  101. export const indentDecrease: Command = view => {
  102. if (minimumListDepthForSelection(view.state) < 2) {
  103. return false
  104. }
  105. switch (ancestorListType(view.state)) {
  106. case 'itemize':
  107. return unwrapBulletList(view)
  108. case 'enumerate':
  109. return unwrapNumberedList(view)
  110. case 'description':
  111. return unwrapDescriptionList(view)
  112. default:
  113. return false
  114. }
  115. }
  116. export const cursorIsAtStartOfListItem = (state: EditorState) => {
  117. return state.selection.ranges.every(range => {
  118. const line = state.doc.lineAt(range.from)
  119. const prefix = state.sliceDoc(line.from, range.from)
  120. return /\\item\s*$/.test(prefix)
  121. })
  122. }
  123. export const indentIncrease: Command = view => {
  124. if (minimumListDepthForSelection(view.state) < 1) {
  125. return false
  126. }
  127. switch (ancestorListType(view.state)) {
  128. case 'itemize':
  129. return wrapInBulletList(view)
  130. case 'enumerate':
  131. return wrapInNumberedList(view)
  132. case 'description':
  133. return wrapInDescriptionList(view)
  134. default:
  135. return false
  136. }
  137. }
  138. export const toggleSearch: Command = view => {
  139. if (searchPanelOpen(view.state)) {
  140. closeSearchPanel(view)
  141. } else {
  142. sendSearchEvent('search-open', {
  143. searchType: 'document',
  144. method: 'button',
  145. location: 'toolbar',
  146. mode: isVisual(view) ? 'visual' : 'source',
  147. })
  148. openSearchPanel(view)
  149. }
  150. return true
  151. }
  152. export const addComment = (location: string) => {
  153. sendMB('add-comment', {
  154. location,
  155. })
  156. window.dispatchEvent(new Event('add-new-review-comment'))
  157. }
  158. export const deleteSelection: Command = view => {
  159. if (view.state.selection.ranges.every(range => range.empty)) return false
  160. const transaction = view.state.changeByRange(range => {
  161. if (range.empty) {
  162. return { changes: [], range }
  163. }
  164. return {
  165. changes: { from: range.from, to: range.to, insert: '' },
  166. range: EditorSelection.cursor(range.from),
  167. }
  168. })
  169. view.dispatch(transaction)
  170. return true
  171. }