search.ts 12 KB

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