commands.ts 4.9 KB

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