| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429 |
- import {
- search as _search,
- setSearchQuery,
- getSearchQuery,
- openSearchPanel,
- SearchQuery,
- searchPanelOpen,
- searchKeymap,
- highlightSelectionMatches,
- togglePanel,
- } from '@codemirror/search'
- import {
- Decoration,
- EditorView,
- KeyBinding,
- keymap,
- ViewPlugin,
- } from '@codemirror/view'
- import {
- Annotation,
- Compartment,
- EditorSelection,
- EditorState,
- Prec,
- SelectionRange,
- StateEffect,
- StateField,
- TransactionSpec,
- } from '@codemirror/state'
- import { sendSearchEvent } from '@/features/event-tracking/search-events'
- import { isVisual } from '@/features/source-editor/extensions/visual/visual'
- const restoreSearchQueryAnnotation = Annotation.define<boolean>()
- const selectNextMatch = (query: SearchQuery, state: EditorState) => {
- if (!query.valid) {
- return false
- }
- let cursor = query.getCursor(state.doc, state.selection.main.from)
- let result = cursor.next()
- if (result.done) {
- cursor = query.getCursor(state.doc)
- result = cursor.next()
- }
- return result.done ? null : result.value
- }
- const storedSelectionEffect = StateEffect.define<EditorSelection | null>()
- const storedSelectionState = StateField.define<EditorSelection | null>({
- create() {
- return null
- },
- update(value, tr) {
- if (value) {
- value = value.map(tr.changes)
- }
- for (const effect of tr.effects) {
- if (effect.is(storedSelectionEffect)) {
- value = effect.value
- } else if (effect.is(togglePanel) && effect.value === false) {
- value = null // clear the stored selection when closing the search panel
- }
- }
- return value
- },
- provide(f) {
- return [
- EditorView.decorations.from(f, selection => {
- if (!selection) {
- return Decoration.none
- }
- const decorations = selection.ranges
- .filter(range => !range.empty)
- .map(range =>
- Decoration.mark({
- class: 'ol-cm-stored-selection',
- }).range(range.from, range.to)
- )
- return Decoration.set(decorations)
- }),
- ]
- },
- })
- export const getStoredSelection = (state: EditorState) =>
- state.field(storedSelectionState)
- export const setStoredSelection = (selection: EditorSelection | null) => {
- return {
- effects: [
- storedSelectionEffect.of(selection),
- // TODO: only disable selection highlighting if the current selection is a search match
- highlightSelectionMatchesConf.reconfigure(
- selection ? [] : highlightSelectionMatchesExtension
- ),
- ],
- }
- }
- const highlightSelectionMatchesConf = new Compartment()
- const highlightSelectionMatchesExtension = highlightSelectionMatches({
- wholeWords: true,
- })
- // store the search query for use when switching between files
- // TODO: move this into EditorContext?
- let searchQuery: SearchQuery | null
- const scrollToMatch = (range: SelectionRange, view: EditorView) => {
- const coords = {
- from: view.coordsAtPos(range.from),
- to: view.coordsAtPos(range.to),
- }
- const scrollRect = view.scrollDOM.getBoundingClientRect()
- const strategy =
- (coords.from && coords.from.top < scrollRect.top) ||
- (coords.to && coords.to.bottom > scrollRect.bottom)
- ? 'center'
- : 'nearest'
- return EditorView.scrollIntoView(range, {
- y: strategy,
- })
- }
- const searchEventKeymap: KeyBinding[] = [
- // record an event when the search panel is opened using the keyboard shortcut
- {
- key: 'Mod-f',
- preventDefault: true,
- scope: 'editor search-panel',
- run(view) {
- if (!searchPanelOpen(view.state)) {
- sendSearchEvent('search-open', {
- searchType: 'document',
- method: 'keyboard',
- mode: isVisual(view) ? 'visual' : 'source',
- })
- }
- return false // continue with the regular search shortcut
- },
- },
- ]
- /**
- * A collection of extensions related to the search feature.
- */
- export const search = () => {
- let open = false
- return [
- // keymap for search events
- Prec.high(keymap.of(searchEventKeymap)),
- // keymap for search
- keymap.of(searchKeymap),
- // highlight text which matches the current selection
- highlightSelectionMatchesConf.of(highlightSelectionMatchesExtension),
- // a stored selection for use in "within selection" searches
- storedSelectionState,
- /**
- * The CodeMirror `search` extension, configured to create a custom panel element
- * and to scroll the search match into the centre of the viewport when needed.
- */
- _search({
- literal: true,
- // centre the search match if it was outside the visible area
- scrollToMatch,
- createPanel: () => {
- const dom = document.createElement('div')
- dom.className = 'ol-cm-search'
- return {
- dom,
- mount() {
- open = true
- // focus the search input when the panel is already open
- const searchInput =
- dom.querySelector<HTMLInputElement>('[main-field]')
- if (searchInput) {
- searchInput.focus()
- searchInput.select()
- }
- },
- destroy() {
- window.setTimeout(() => {
- open = false // in a timeout, so the view plugin below can run its destroy method first
- }, 0)
- },
- }
- },
- }),
- // restore a stored search and re-open the search panel
- ViewPlugin.define(view => {
- if (searchQuery) {
- const _searchQuery = searchQuery
- window.setTimeout(() => {
- openSearchPanel(view)
- view.dispatch({
- effects: setSearchQuery.of(_searchQuery),
- annotations: restoreSearchQueryAnnotation.of(true),
- })
- }, 0)
- }
- return {
- destroy() {
- // persist the current search query if the panel is open
- searchQuery = open ? getSearchQuery(view.state) : null
- },
- }
- }),
- // select a match while searching
- EditorView.updateListener.of(update => {
- // if the search panel wasn't open, don't select a match
- if (!searchPanelOpen(update.startState)) {
- return
- }
- for (const tr of update.transactions) {
- // avoid changing the selection and viewport when switching between files
- if (tr.annotation(restoreSearchQueryAnnotation)) {
- continue
- }
- for (const effect of tr.effects) {
- if (effect.is(setSearchQuery)) {
- const query = effect.value
- if (!query) return
- // The rest of this messes up searching in Vim, which is handled by
- // the Vim extension, so bail out here in Vim mode. Happily, the
- // Vim extension sticks an extra property on the query value that
- // can be checked
- if ('forVim' in query) return
- const next = selectNextMatch(query, tr.state)
- if (next) {
- // select a match if possible
- const spec: TransactionSpec = {
- selection: { anchor: next.from, head: next.to },
- userEvent: 'select.search',
- }
- // scroll into view if not opening the panel
- if (searchPanelOpen(tr.startState)) {
- spec.effects = scrollToMatch(
- EditorSelection.range(next.from, next.to),
- update.view
- )
- }
- update.view.dispatch(spec)
- } else {
- // clear the selection if the query became invalid
- const prevQuery = getSearchQuery(tr.startState)
- if (prevQuery.valid) {
- const { from } = tr.startState.selection.main
- update.view.dispatch({
- selection: { anchor: from },
- })
- }
- }
- }
- }
- }
- }),
- searchFormTheme,
- ]
- }
- const searchFormTheme = EditorView.theme({
- '.ol-cm-search-form': {
- '--ol-cm-search-form-gap': '10px',
- '--ol-cm-search-form-button-margin': '3px',
- padding: 'var(--ol-cm-search-form-gap)',
- display: 'flex',
- gap: 'var(--ol-cm-search-form-gap)',
- background: 'var(--neutral-20)',
- '--ol-cm-search-form-focus-shadow':
- 'inset 0 1px 1px rgb(0 0 0 / 8%), 0 0 8px rgb(102 175 233 / 60%)',
- '--ol-cm-search-form-error-shadow':
- 'inset 0 1px 1px rgb(0 0 0 / 8%), 0 0 8px var(--red-50)',
- containerType: 'inline-size',
- '& .form-control-sm, & .btn-sm': {
- padding: 'var(--spacing-03) var(--spacing-05)',
- },
- },
- '&.ol-cm-search-form': {
- '--ol-cm-search-form-gap': 'var(--spacing-05)',
- '--ol-cm-search-form-button-margin': 'var(--spacing-02)',
- '--input-border': 'var(--border-primary)',
- '--input-border-focus': 'var(--border-active)',
- },
- '.ol-cm-search-controls': {
- display: 'grid',
- gridTemplateColumns: 'auto auto',
- gridTemplateRows: 'auto auto',
- gap: 'var(--ol-cm-search-form-gap)',
- flex: 1,
- },
- '@container (max-width: 450px)': {
- '.ol-cm-search-controls': {
- gridTemplateColumns: 'auto',
- },
- },
- '.ol-cm-search-form-row': {
- display: 'flex',
- gap: 'var(--ol-cm-search-form-gap)',
- justifyContent: 'space-between',
- },
- '.ol-cm-search-form-group': {
- display: 'flex',
- gap: 'var(--ol-cm-search-form-gap)',
- alignItems: 'center',
- },
- '.ol-cm-search-input-group': {
- border: '1px solid var(--input-border)',
- borderRadius: '20px',
- background: 'white',
- width: '100%',
- maxWidth: '50em',
- display: 'inline-flex',
- alignItems: 'center',
- '& input[type="text"]': {
- background: 'none',
- boxShadow: 'none',
- },
- '& input[type="text"]:focus': {
- outline: 'none',
- boxShadow: 'none',
- },
- '& .btn.btn': {
- background: 'var(--neutral-10)',
- color: 'var(--neutral-60)',
- borderRadius: '50%',
- height: '2em',
- display: 'inline-flex',
- alignItems: 'center',
- justifyContent: 'center',
- width: '2em',
- marginRight: 'var(--ol-cm-search-form-button-margin)',
- '&.checked': {
- color: 'var(--white)',
- backgroundColor: 'var(--blue-50)',
- },
- '&:active': {
- boxShadow: 'none',
- },
- },
- '&:focus-within': {
- borderColor: 'var(--input-border-focus)',
- boxShadow: 'var(--ol-cm-search-form-focus-shadow)',
- },
- },
- '.ol-cm-search-input-group.ol-cm-search-input-error': {
- '&:focus-within': {
- borderColor: 'var(--input-border-danger)',
- boxShadow: 'var(--ol-cm-search-form-error-shadow)',
- },
- },
- '.ol-cm-search-form-input': {
- border: 'none',
- },
- '.ol-cm-search-input-button': {
- background: '#fff',
- color: 'inherit',
- border: 'none',
- },
- '.ol-cm-search-input-button.focused': {
- borderColor: 'var(--input-border-focus)',
- boxShadow: 'var(--ol-cm-search-form-focus-shadow)',
- },
- '.ol-cm-search-form-button-group': {
- flexShrink: 0,
- },
- '.ol-cm-search-form-position': {
- flexShrink: 0,
- color: 'var(--content-secondary)',
- },
- '.ol-cm-search-hidden-inputs': {
- position: 'absolute',
- left: '-10000px',
- },
- '.ol-cm-search-form-close': {
- marginLeft: 'auto',
- display: 'flex',
- alignItems: 'start',
- },
- '.ol-cm-search-replace-input': {
- order: 3,
- },
- '.ol-cm-search-replace-buttons': {
- order: 4,
- },
- '.ol-cm-stored-selection': {
- background: 'rgba(125, 125, 125, 0.1)',
- paddingTop: 'var(--half-leading)',
- paddingBottom: 'var(--half-leading)',
- },
- // set the default "match" style
- '.cm-selectionMatch, .cm-searchMatch': {
- backgroundColor: 'transparent',
- outlineOffset: '-1px',
- paddingTop: 'var(--half-leading)',
- paddingBottom: 'var(--half-leading)',
- },
- // make sure selectionMatch inside searchMatch doesn't have a background colour
- '.cm-searchMatch .cm-selectionMatch': {
- backgroundColor: 'transparent !important',
- },
- })
|