search.ts 12 KB

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