search.ts 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. import {
  2. searchKeymap,
  3. search as searchExtension,
  4. setSearchQuery,
  5. getSearchQuery,
  6. openSearchPanel,
  7. SearchQuery,
  8. searchPanelOpen,
  9. } from '@codemirror/search'
  10. import { EditorView, keymap, ViewPlugin } from '@codemirror/view'
  11. import { Annotation, EditorState, TransactionSpec } from '@codemirror/state'
  12. import { highlightSelectionMatches } from './highlight-selection-matches'
  13. const restoreSearchQueryAnnotation = Annotation.define<boolean>()
  14. const ignoredSearchKeybindings = new Set([
  15. // This keybinding causes issues with entering @ on certain keyboard layouts
  16. // https://github.com/overleaf/internal/issues/12119
  17. 'Alt-g',
  18. ])
  19. const selectNextMatch = (query: SearchQuery, state: EditorState) => {
  20. if (!query.valid) {
  21. return false
  22. }
  23. let cursor = query.getCursor(state.doc, state.selection.main.from)
  24. let result = cursor.next()
  25. if (result.done) {
  26. cursor = query.getCursor(state.doc)
  27. result = cursor.next()
  28. }
  29. return result.done ? null : result.value
  30. }
  31. let searchQuery: SearchQuery | null
  32. export const search = () => {
  33. let open = false
  34. return [
  35. // keymap for search
  36. keymap.of(
  37. searchKeymap.filter(
  38. item => !item.key || !ignoredSearchKeybindings.has(item.key)
  39. )
  40. ),
  41. // highlight text which matches the current selection
  42. highlightSelectionMatches(),
  43. // a wrapper round `search`, which creates a custom panel element and passes it to React by dispatching an event
  44. searchExtension({
  45. literal: true,
  46. createPanel: () => {
  47. const dom = document.createElement('div')
  48. dom.className = 'ol-cm-search'
  49. return {
  50. dom,
  51. mount() {
  52. open = true
  53. // focus the search input when the panel is already open
  54. const searchInput =
  55. dom.querySelector<HTMLInputElement>('[main-field]')
  56. if (searchInput) {
  57. searchInput.focus()
  58. searchInput.select()
  59. }
  60. },
  61. destroy() {
  62. window.setTimeout(() => {
  63. open = false // in a timeout, so the view plugin below can run its destroy method first
  64. }, 0)
  65. },
  66. }
  67. },
  68. }),
  69. // restore a stored search and re-open the search panel
  70. ViewPlugin.define(view => {
  71. if (searchQuery) {
  72. const _searchQuery = searchQuery
  73. window.setTimeout(() => {
  74. openSearchPanel(view)
  75. view.dispatch({
  76. effects: setSearchQuery.of(_searchQuery),
  77. annotations: restoreSearchQueryAnnotation.of(true),
  78. })
  79. }, 0)
  80. }
  81. return {
  82. destroy() {
  83. // persist the current search query if the panel is open
  84. searchQuery = open ? getSearchQuery(view.state) : null
  85. },
  86. }
  87. }),
  88. // select a match while searching
  89. EditorView.updateListener.of(update => {
  90. for (const tr of update.transactions) {
  91. // avoid changing the selection and viewport when switching between files
  92. if (tr.annotation(restoreSearchQueryAnnotation)) {
  93. continue
  94. }
  95. for (const effect of tr.effects) {
  96. if (effect.is(setSearchQuery)) {
  97. const query = effect.value
  98. if (!query) return
  99. // The rest of this messes up searching in Vim, which is handled by
  100. // the Vim extension, so bail out here in Vim mode. Happily, the
  101. // Vim extension sticks an extra property on the query value that
  102. // can be checked
  103. if ('forVim' in query) return
  104. const next = selectNextMatch(query, tr.state)
  105. if (next) {
  106. // select a match if possible
  107. const spec: TransactionSpec = {
  108. selection: { anchor: next.from, head: next.to },
  109. userEvent: 'select.search',
  110. }
  111. // scroll into view if not opening the panel
  112. if (searchPanelOpen(tr.startState)) {
  113. spec.effects = EditorView.scrollIntoView(next.from, {
  114. y: 'center',
  115. })
  116. }
  117. update.view.dispatch(spec)
  118. } else {
  119. // clear the selection if the query became invalid
  120. const prevQuery = getSearchQuery(tr.startState)
  121. if (prevQuery.valid) {
  122. const { from } = tr.startState.selection.main
  123. update.view.dispatch({
  124. selection: { anchor: from },
  125. })
  126. }
  127. }
  128. }
  129. }
  130. }
  131. }),
  132. // search form theme
  133. EditorView.theme({
  134. '.ol-cm-search-form': {
  135. padding: '10px',
  136. display: 'flex',
  137. gap: '10px',
  138. background: 'var(--ol-blue-gray-1)',
  139. '--ol-cm-search-form-focus-shadow':
  140. 'inset 0 1px 1px rgb(0 0 0 / 8%), 0 0 8px rgb(102 175 233 / 60%)',
  141. },
  142. '.ol-cm-search-controls': {
  143. display: 'grid',
  144. gridTemplateColumns: 'auto auto',
  145. gridTemplateRows: 'auto auto',
  146. gap: '10px',
  147. },
  148. '.ol-cm-search-form-row': {
  149. display: 'flex',
  150. gap: '10px',
  151. justifyContent: 'space-between',
  152. },
  153. '.ol-cm-search-form-group': {
  154. display: 'flex',
  155. gap: '10px',
  156. alignItems: 'center',
  157. },
  158. '.ol-cm-search-input-group': {
  159. border: '1px solid var(--input-border)',
  160. borderRadius: '20px',
  161. background: 'white',
  162. width: '100%',
  163. maxWidth: '25em',
  164. '& input[type="text"]': {
  165. background: 'none',
  166. boxShadow: 'none',
  167. },
  168. '& input[type="text"]:focus': {
  169. outline: 'none',
  170. boxShadow: 'none',
  171. },
  172. '& .btn.btn': {
  173. background: 'var(--ol-blue-gray-0)',
  174. color: 'var(--ol-blue-gray-3)',
  175. borderRadius: '50%',
  176. height: '2em',
  177. display: 'inline-flex',
  178. alignItems: 'center',
  179. justifyContent: 'center',
  180. width: '2em',
  181. marginRight: '3px',
  182. '&.checked': {
  183. color: '#fff',
  184. backgroundColor: 'var(--ol-blue)',
  185. },
  186. '&:active': {
  187. boxShadow: 'none',
  188. },
  189. },
  190. '&:focus-within': {
  191. borderColor: 'var(--input-border-focus)',
  192. boxShadow: 'var(--ol-cm-search-form-focus-shadow)',
  193. },
  194. },
  195. '.input-group .ol-cm-search-form-input': {
  196. border: 'none',
  197. },
  198. '.ol-cm-search-input-button': {
  199. background: '#fff',
  200. color: 'inherit',
  201. border: 'none',
  202. },
  203. '.ol-cm-search-input-button.focused': {
  204. borderColor: 'var(--input-border-focus)',
  205. boxShadow: 'var(--ol-cm-search-form-focus-shadow)',
  206. },
  207. '.ol-cm-search-form-button-group': {
  208. flexShrink: 0,
  209. },
  210. '.ol-cm-search-form-position': {
  211. flexShrink: 0,
  212. color: 'var(--ol-blue-gray-4)',
  213. },
  214. '.ol-cm-search-hidden-inputs': {
  215. position: 'absolute',
  216. left: '-10000px',
  217. },
  218. '.ol-cm-search-form-close': {
  219. flex: 1,
  220. },
  221. '.ol-cm-search-replace-input': {
  222. order: 3,
  223. },
  224. '.ol-cm-search-replace-buttons': {
  225. order: 4,
  226. },
  227. }),
  228. ]
  229. }