overleaf/services/web/frontend/js/features/source-editor/extensions/search.ts
Tim Down 1b2385dfce Merge pull request #21259 from overleaf/td-bs5-editor-search
[BS5] Migrate Editor search panel

GitOrigin-RevId: 37605845d4efc27d6c0c3a11de12387e8e3262f4
2024-10-24 08:05:58 +00:00

394 lines
11 KiB
TypeScript

import {
search as _search,
setSearchQuery,
getSearchQuery,
openSearchPanel,
SearchQuery,
searchPanelOpen,
searchKeymap,
highlightSelectionMatches,
togglePanel,
} from '@codemirror/search'
import { Decoration, EditorView, keymap, ViewPlugin } from '@codemirror/view'
import {
Annotation,
Compartment,
EditorSelection,
EditorState,
SelectionRange,
StateEffect,
StateField,
TransactionSpec,
} from '@codemirror/state'
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,
})
}
/**
* A collection of extensions related to the search feature.
*/
export const search = () => {
let open = false
return [
// 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',
},
'&.bootstrap-5 .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)',
},
'@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: '25em',
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(--ol-blue-gray-4)',
},
'.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',
},
})