search.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  1. import {
  2. search as _search,
  3. setSearchQuery,
  4. getSearchQuery,
  5. openSearchPanel,
  6. SearchQuery,
  7. searchPanelOpen,
  8. searchKeymap,
  9. highlightSelectionMatches,
  10. togglePanel,
  11. } from '@codemirror/search'
  12. import { Decoration, EditorView, keymap, ViewPlugin } from '@codemirror/view'
  13. import {
  14. Annotation,
  15. Compartment,
  16. EditorSelection,
  17. EditorState,
  18. SelectionRange,
  19. StateEffect,
  20. StateField,
  21. TransactionSpec,
  22. } from '@codemirror/state'
  23. const restoreSearchQueryAnnotation = Annotation.define<boolean>()
  24. const selectNextMatch = (query: SearchQuery, state: EditorState) => {
  25. if (!query.valid) {
  26. return false
  27. }
  28. let cursor = query.getCursor(state.doc, state.selection.main.from)
  29. let result = cursor.next()
  30. if (result.done) {
  31. cursor = query.getCursor(state.doc)
  32. result = cursor.next()
  33. }
  34. return result.done ? null : result.value
  35. }
  36. const storedSelectionEffect = StateEffect.define<EditorSelection | null>()
  37. const storedSelectionState = StateField.define<EditorSelection | null>({
  38. create() {
  39. return null
  40. },
  41. update(value, tr) {
  42. if (value) {
  43. value = value.map(tr.changes)
  44. }
  45. for (const effect of tr.effects) {
  46. if (effect.is(storedSelectionEffect)) {
  47. value = effect.value
  48. } else if (effect.is(togglePanel) && effect.value === false) {
  49. value = null // clear the stored selection when closing the search panel
  50. }
  51. }
  52. return value
  53. },
  54. provide(f) {
  55. return [
  56. EditorView.decorations.from(f, selection => {
  57. if (!selection) {
  58. return Decoration.none
  59. }
  60. const decorations = selection.ranges
  61. .filter(range => !range.empty)
  62. .map(range =>
  63. Decoration.mark({
  64. class: 'ol-cm-stored-selection',
  65. }).range(range.from, range.to)
  66. )
  67. return Decoration.set(decorations)
  68. }),
  69. ]
  70. },
  71. })
  72. export const getStoredSelection = (state: EditorState) =>
  73. state.field(storedSelectionState)
  74. export const setStoredSelection = (selection: EditorSelection | null) => {
  75. return {
  76. effects: [
  77. storedSelectionEffect.of(selection),
  78. // TODO: only disable selection highlighting if the current selection is a search match
  79. highlightSelectionMatchesConf.reconfigure(
  80. selection ? [] : highlightSelectionMatchesExtension
  81. ),
  82. ],
  83. }
  84. }
  85. const highlightSelectionMatchesConf = new Compartment()
  86. const highlightSelectionMatchesExtension = highlightSelectionMatches({
  87. wholeWords: true,
  88. })
  89. // store the search query for use when switching between files
  90. // TODO: move this into EditorContext?
  91. let searchQuery: SearchQuery | null
  92. const scrollToMatch = (range: SelectionRange, view: EditorView) => {
  93. const coords = {
  94. from: view.coordsAtPos(range.from),
  95. to: view.coordsAtPos(range.to),
  96. }
  97. const scrollRect = view.scrollDOM.getBoundingClientRect()
  98. const strategy =
  99. (coords.from && coords.from.top < scrollRect.top) ||
  100. (coords.to && coords.to.bottom > scrollRect.bottom)
  101. ? 'center'
  102. : 'nearest'
  103. return EditorView.scrollIntoView(range, {
  104. y: strategy,
  105. })
  106. }
  107. /**
  108. * A collection of extensions related to the search feature.
  109. */
  110. export const search = () => {
  111. let open = false
  112. return [
  113. // keymap for search
  114. keymap.of(searchKeymap),
  115. // highlight text which matches the current selection
  116. highlightSelectionMatchesConf.of(highlightSelectionMatchesExtension),
  117. // a stored selection for use in "within selection" searches
  118. storedSelectionState,
  119. /**
  120. * The CodeMirror `search` extension, configured to create a custom panel element
  121. * and to scroll the search match into the centre of the viewport when needed.
  122. */
  123. _search({
  124. literal: true,
  125. // centre the search match if it was outside the visible area
  126. scrollToMatch,
  127. createPanel: () => {
  128. const dom = document.createElement('div')
  129. dom.className = 'ol-cm-search'
  130. return {
  131. dom,
  132. mount() {
  133. open = true
  134. // focus the search input when the panel is already open
  135. const searchInput =
  136. dom.querySelector<HTMLInputElement>('[main-field]')
  137. if (searchInput) {
  138. searchInput.focus()
  139. searchInput.select()
  140. }
  141. },
  142. destroy() {
  143. window.setTimeout(() => {
  144. open = false // in a timeout, so the view plugin below can run its destroy method first
  145. }, 0)
  146. },
  147. }
  148. },
  149. }),
  150. // restore a stored search and re-open the search panel
  151. ViewPlugin.define(view => {
  152. if (searchQuery) {
  153. const _searchQuery = searchQuery
  154. window.setTimeout(() => {
  155. openSearchPanel(view)
  156. view.dispatch({
  157. effects: setSearchQuery.of(_searchQuery),
  158. annotations: restoreSearchQueryAnnotation.of(true),
  159. })
  160. }, 0)
  161. }
  162. return {
  163. destroy() {
  164. // persist the current search query if the panel is open
  165. searchQuery = open ? getSearchQuery(view.state) : null
  166. },
  167. }
  168. }),
  169. // select a match while searching
  170. EditorView.updateListener.of(update => {
  171. // if the search panel wasn't open, don't select a match
  172. if (!searchPanelOpen(update.startState)) {
  173. return
  174. }
  175. for (const tr of update.transactions) {
  176. // avoid changing the selection and viewport when switching between files
  177. if (tr.annotation(restoreSearchQueryAnnotation)) {
  178. continue
  179. }
  180. for (const effect of tr.effects) {
  181. if (effect.is(setSearchQuery)) {
  182. const query = effect.value
  183. if (!query) return
  184. // The rest of this messes up searching in Vim, which is handled by
  185. // the Vim extension, so bail out here in Vim mode. Happily, the
  186. // Vim extension sticks an extra property on the query value that
  187. // can be checked
  188. if ('forVim' in query) return
  189. const next = selectNextMatch(query, tr.state)
  190. if (next) {
  191. // select a match if possible
  192. const spec: TransactionSpec = {
  193. selection: { anchor: next.from, head: next.to },
  194. userEvent: 'select.search',
  195. }
  196. // scroll into view if not opening the panel
  197. if (searchPanelOpen(tr.startState)) {
  198. spec.effects = scrollToMatch(
  199. EditorSelection.range(next.from, next.to),
  200. update.view
  201. )
  202. }
  203. update.view.dispatch(spec)
  204. } else {
  205. // clear the selection if the query became invalid
  206. const prevQuery = getSearchQuery(tr.startState)
  207. if (prevQuery.valid) {
  208. const { from } = tr.startState.selection.main
  209. update.view.dispatch({
  210. selection: { anchor: from },
  211. })
  212. }
  213. }
  214. }
  215. }
  216. }
  217. }),
  218. searchFormTheme,
  219. ]
  220. }
  221. const searchFormTheme = EditorView.theme({
  222. '.ol-cm-search-form': {
  223. padding: '10px',
  224. display: 'flex',
  225. gap: '10px',
  226. background: 'var(--ol-blue-gray-1)',
  227. '--ol-cm-search-form-focus-shadow':
  228. 'inset 0 1px 1px rgb(0 0 0 / 8%), 0 0 8px rgb(102 175 233 / 60%)',
  229. '--ol-cm-search-form-error-shadow':
  230. 'inset 0 1px 1px rgb(0 0 0 / 8%), 0 0 8px var(--input-shadow-danger-color)',
  231. },
  232. '.ol-cm-search-controls': {
  233. display: 'grid',
  234. gridTemplateColumns: 'auto auto',
  235. gridTemplateRows: 'auto auto',
  236. gap: '10px',
  237. },
  238. '.ol-cm-search-form-row': {
  239. display: 'flex',
  240. gap: '10px',
  241. justifyContent: 'space-between',
  242. },
  243. '.ol-cm-search-form-group': {
  244. display: 'flex',
  245. gap: '10px',
  246. alignItems: 'center',
  247. },
  248. '.ol-cm-search-input-group': {
  249. border: '1px solid var(--input-border)',
  250. borderRadius: '20px',
  251. background: 'white',
  252. width: '100%',
  253. maxWidth: '25em',
  254. '& input[type="text"]': {
  255. background: 'none',
  256. boxShadow: 'none',
  257. },
  258. '& input[type="text"]:focus': {
  259. outline: 'none',
  260. boxShadow: 'none',
  261. },
  262. '& .btn.btn': {
  263. background: 'var(--ol-blue-gray-0)',
  264. color: 'var(--ol-blue-gray-3)',
  265. borderRadius: '50%',
  266. height: '2em',
  267. display: 'inline-flex',
  268. alignItems: 'center',
  269. justifyContent: 'center',
  270. width: '2em',
  271. marginRight: '3px',
  272. '&.checked': {
  273. color: '#fff',
  274. backgroundColor: 'var(--ol-blue)',
  275. },
  276. '&:active': {
  277. boxShadow: 'none',
  278. },
  279. },
  280. '&:focus-within': {
  281. borderColor: 'var(--input-border-focus)',
  282. boxShadow: 'var(--ol-cm-search-form-focus-shadow)',
  283. },
  284. },
  285. '.ol-cm-search-input-group.ol-cm-search-input-error': {
  286. '&:focus-within': {
  287. borderColor: 'var(--input-border-danger)',
  288. boxShadow: 'var(--ol-cm-search-form-error-shadow)',
  289. },
  290. },
  291. '.input-group .ol-cm-search-form-input': {
  292. border: 'none',
  293. },
  294. '.ol-cm-search-input-button': {
  295. background: '#fff',
  296. color: 'inherit',
  297. border: 'none',
  298. },
  299. '.ol-cm-search-input-button.focused': {
  300. borderColor: 'var(--input-border-focus)',
  301. boxShadow: 'var(--ol-cm-search-form-focus-shadow)',
  302. },
  303. '.ol-cm-search-form-button-group': {
  304. flexShrink: 0,
  305. },
  306. '.ol-cm-search-form-position': {
  307. flexShrink: 0,
  308. color: 'var(--ol-blue-gray-4)',
  309. },
  310. '.ol-cm-search-hidden-inputs': {
  311. position: 'absolute',
  312. left: '-10000px',
  313. },
  314. '.ol-cm-search-form-close': {
  315. flex: 1,
  316. },
  317. '.ol-cm-search-replace-input': {
  318. order: 3,
  319. },
  320. '.ol-cm-search-replace-buttons': {
  321. order: 4,
  322. },
  323. '.ol-cm-stored-selection': {
  324. background: 'rgba(125, 125, 125, 0.1)',
  325. paddingTop: 'var(--half-leading)',
  326. paddingBottom: 'var(--half-leading)',
  327. },
  328. // set the default "match" style
  329. '.cm-selectionMatch, .cm-searchMatch': {
  330. backgroundColor: 'transparent',
  331. outlineOffset: '-1px',
  332. paddingTop: 'var(--half-leading)',
  333. paddingBottom: 'var(--half-leading)',
  334. },
  335. // make sure selectionMatch inside searchMatch doesn't have a background colour
  336. '.cm-searchMatch .cm-selectionMatch': {
  337. backgroundColor: 'transparent !important',
  338. },
  339. })