[cm6] Add "within selection" option to the search form (#12798)

* Use forked @codemirror/search
* Use getPanel to get the search panel
* Remove layer-based highlightSelectionMatches
* Add "within selection" option to the search form
* Add test for "replace all within selection"
* Fix tests

GitOrigin-RevId: 95ce76fd017f96278b04c16a1fd34f785f7504a3
This commit is contained in:
Alf Eaton 2023-04-28 10:37:36 +01:00 committed by Copybot
parent 7f7443356c
commit e0d9069131
10 changed files with 178 additions and 169 deletions

15
package-lock.json generated
View file

@ -3249,8 +3249,9 @@
},
"node_modules/@codemirror/search": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.4.0.tgz",
"integrity": "sha512-zMDgaBXah+nMLK2dHz9GdCnGbQu+oaGRXS1qviqNZkvOCv/whp5XZFyoikLp/23PM9RBcbuKUUISUmQHM1eRHw==",
"resolved": "git+ssh://git@github.com/overleaf/codemirror-search.git#ea83364b22ad66455fc94babea7d576fa9f76a93",
"integrity": "sha512-02UOFSNY7/FamUaRPNPwcjq58V2nsRbtXRIT85/AKgfEWv6tVHj5slobCeaRKCXb6hPSOEDMztR5ShmbuxLfEw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
@ -35172,7 +35173,7 @@
"@codemirror/lang-markdown": "^6.1.1",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.2.1",
"@codemirror/search": "^6.4.0",
"@codemirror/search": "github:overleaf/codemirror-search#ea83364b22ad66455fc94babea7d576fa9f76a93",
"@codemirror/state": "^6.2.0",
"@codemirror/view": "^6.9.6",
"@contentful/rich-text-html-renderer": "^16.0.2",
@ -40025,9 +40026,9 @@
}
},
"@codemirror/search": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.4.0.tgz",
"integrity": "sha512-zMDgaBXah+nMLK2dHz9GdCnGbQu+oaGRXS1qviqNZkvOCv/whp5XZFyoikLp/23PM9RBcbuKUUISUmQHM1eRHw==",
"version": "git+ssh://git@github.com/overleaf/codemirror-search.git#ea83364b22ad66455fc94babea7d576fa9f76a93",
"integrity": "sha512-02UOFSNY7/FamUaRPNPwcjq58V2nsRbtXRIT85/AKgfEWv6tVHj5slobCeaRKCXb6hPSOEDMztR5ShmbuxLfEw==",
"from": "@codemirror/search@github:overleaf/codemirror-search#ea83364b22ad66455fc94babea7d576fa9f76a93",
"requires": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
@ -44852,7 +44853,7 @@
"@codemirror/lang-markdown": "^6.1.1",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.2.1",
"@codemirror/search": "^6.4.0",
"@codemirror/search": "github:overleaf/codemirror-search#ea83364b22ad66455fc94babea7d576fa9f76a93",
"@codemirror/state": "^6.2.0",
"@codemirror/view": "^6.9.6",
"@contentful/rich-text-html-renderer": "^16.0.2",

View file

@ -748,6 +748,7 @@
"search_replace_with": "",
"search_search_for": "",
"search_whole_word": "",
"search_within_selection": "",
"select_a_file": "",
"select_a_payment_method": "",
"select_a_project": "",

View file

@ -21,10 +21,16 @@ import Tooltip from '../../../shared/components/tooltip'
import Icon from '../../../shared/components/icon'
import classnames from 'classnames'
import useScopeValue from '../../../shared/hooks/use-scope-value'
import { getStoredSelection, setStoredSelection } from '../extensions/search'
const MAX_MATCH_COUNT = 1000
type ActiveSearchOption = 'caseSensitive' | 'regexp' | 'wholeWord' | null
type ActiveSearchOption =
| 'caseSensitive'
| 'regexp'
| 'wholeWord'
| 'withinSelection'
| null
const CodeMirrorSearchForm: FC = () => {
const view = useCodeMirrorViewContext()
@ -42,6 +48,7 @@ const CodeMirrorSearchForm: FC = () => {
const caseSensitiveId = 'caseSensitive' + idSuffix
const regexpId = 'regexp' + idSuffix
const wholeWordId = 'wholeWord' + idSuffix
const withinSelectionId = 'withinSelection' + idSuffix
const { t } = useTranslation()
@ -114,12 +121,19 @@ const CodeMirrorSearchForm: FC = () => {
regexp: data.regexp === 'on',
literal: true,
wholeWord: data.wholeWord === 'on',
scope: getStoredSelection(view.state)?.ranges,
})
view.dispatch({ effects: setSearchQuery.of(query) })
}
}, [view])
const handleWithinSelectionChange = useCallback(() => {
const storedSelection = getStoredSelection(state)
view.dispatch(setStoredSelection(storedSelection ? null : state.selection))
handleChange()
}, [handleChange, state, view])
const handleFormKeyDown = useCallback(
event => {
if (runScopeHandlers(view, event, 'search-panel')) {
@ -308,6 +322,27 @@ const CodeMirrorSearchForm: FC = () => {
</label>
</Tooltip>
</InputGroup.Button>
<InputGroup.Button>
<Tooltip
id="search-within-selection"
description={t('search_within_selection')}
>
<label
className={classnames(
'btn btn-sm btn-default ol-cm-search-input-button',
{
checked: !!query.scope,
focused: activeSearchOption === 'withinSelection',
}
)}
htmlFor={withinSelectionId}
aria-label={t('search_within_selection')}
>
<Icon type="align-left" fw />
</label>
</Tooltip>
</InputGroup.Button>
</InputGroup>
<InputGroup
@ -362,6 +397,17 @@ const CodeMirrorSearchForm: FC = () => {
onFocus={() => setActiveSearchOption('wholeWord')}
onBlur={() => setActiveSearchOption(null)}
/>
<input
id={withinSelectionId}
name="withinSelection"
type="checkbox"
checked={!!query.scope}
onChange={handleWithinSelectionChange}
onClick={focusSearchBox}
onFocus={() => setActiveSearchOption('withinSelection')}
onBlur={() => setActiveSearchOption(null)}
/>
</div>
<div className="ol-cm-search-form-group ol-cm-search-next-previous">

View file

@ -1,17 +1,19 @@
import { createPortal } from 'react-dom'
import CodeMirrorSearchForm from './codemirror-search-form'
import { useCodeMirrorViewContext } from './codemirror-editor'
import { getPanel } from '@codemirror/view'
import { createSearchPanel } from '@codemirror/search'
function CodeMirrorSearch() {
const view = useCodeMirrorViewContext()
const dom = view.dom.querySelector('.ol-cm-search')
const panel = getPanel(view, createSearchPanel)
if (!dom) {
if (!panel) {
return null
}
return createPortal(<CodeMirrorSearchForm />, dom)
return createPortal(<CodeMirrorSearchForm />, panel.dom)
}
export default CodeMirrorSearch

View file

@ -1,128 +0,0 @@
/**
* This file is adapted from CodeMirror 6, licensed under the MIT license:
* https://github.com/codemirror/search/blob/main/src/selection-match.ts
*/
import { EditorView, layer, RectangleMarker } from '@codemirror/view'
import {
CharCategory,
EditorSelection,
EditorState,
Extension,
} from '@codemirror/state'
import { SearchCursor } from '@codemirror/search'
import { rectangleMarkerForRange } from '../utils/layer'
/*
This extension highlights text that matches the selection.
It uses the `"cm-selectionMatch"` class for the highlighting.
*/
export const highlightSelectionMatches = (): Extension => [
layer({
above: false,
markers(view) {
return buildMarkers(view, view.state)
},
update(update) {
return update.docChanged || update.selectionSet || update.viewportChanged
},
class: 'ol-cm-selectionMatchesLayer',
}),
EditorView.baseTheme({
'.ol-cm-selectionMatchesLayer': {
contain: 'size style',
pointerEvents: 'none',
},
'.cm-selectionMatch': {
position: 'absolute',
},
}),
]
// Whether the characters directly outside the given positions are non-word characters
function insideWordBoundaries(
check: (char: string) => CharCategory,
state: EditorState,
from: number,
to: number
): boolean {
return (
(from === 0 ||
check(state.sliceDoc(from - 1, from)) !== CharCategory.Word) &&
(to === state.doc.length ||
check(state.sliceDoc(to, to + 1)) !== CharCategory.Word)
)
}
// Whether the characters directly at the given positions are word characters
function insideWord(
check: (char: string) => CharCategory,
state: EditorState,
from: number,
to: number
): boolean {
return (
check(state.sliceDoc(from, from + 1)) === CharCategory.Word &&
check(state.sliceDoc(to - 1, to)) === CharCategory.Word
)
}
const buildMarkers = (
view: EditorView,
state: EditorState
): RectangleMarker[] => {
const sel = state.selection
if (sel.ranges.length > 1) {
return []
}
const range = sel.main
if (range.empty) {
return []
}
const len = range.to - range.from
if (len < 3 || len > 200) {
return []
}
const query = state.sliceDoc(range.from, range.to) // TODO: allow and include leading/trailing space?
if (query === '') {
return []
}
const check = state.charCategorizer(range.head)
if (
!(
insideWordBoundaries(check, state, range.from, range.to) &&
insideWord(check, state, range.from, range.to)
)
) {
return []
}
const markers: RectangleMarker[] = []
for (const part of view.visibleRanges) {
const cursor = new SearchCursor(state.doc, query, part.from, part.to)
while (!cursor.next().done) {
const { from, to } = cursor.value
if (!check || insideWordBoundaries(check, state, from, to)) {
markers.push(
...rectangleMarkerForRange(
view,
'cm-selectionMatch',
EditorSelection.range(from, to)
)
)
if (markers.length > 100) {
return []
}
}
}
}
return markers
}

View file

@ -1,29 +1,28 @@
import {
searchKeymap,
search as searchExtension,
setSearchQuery,
getSearchQuery,
openSearchPanel,
SearchQuery,
searchPanelOpen,
searchKeymap,
highlightSelectionMatches,
togglePanel,
} from '@codemirror/search'
import { EditorView, keymap, ViewPlugin } from '@codemirror/view'
import { Decoration, EditorView, keymap, ViewPlugin } from '@codemirror/view'
import {
Annotation,
Compartment,
EditorSelection,
EditorState,
SelectionRange,
StateEffect,
StateField,
TransactionSpec,
} from '@codemirror/state'
import { highlightSelectionMatches } from './highlight-selection-matches'
const restoreSearchQueryAnnotation = Annotation.define<boolean>()
const ignoredSearchKeybindings = new Set([
// This keybinding causes issues with entering @ on certain keyboard layouts
// https://github.com/overleaf/internal/issues/12119
'Alt-g',
])
const selectNextMatch = (query: SearchQuery, state: EditorState) => {
if (!query.valid) {
return false
@ -41,6 +40,69 @@ const selectNextMatch = (query: SearchQuery, state: EditorState) => {
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
export const search = () => {
@ -48,14 +110,13 @@ export const search = () => {
return [
// keymap for search
keymap.of(
searchKeymap.filter(
item => !item.key || !ignoredSearchKeybindings.has(item.key)
)
),
keymap.of(searchKeymap),
// highlight text which matches the current selection
highlightSelectionMatches(),
highlightSelectionMatchesConf.of(highlightSelectionMatchesExtension),
// a stored selection for use in "within selection" searches
storedSelectionState,
// a wrapper round `search`, which creates a custom panel element and passes it to React by dispatching an event
searchExtension({
@ -285,6 +346,22 @@ export const search = () => {
'.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',
},
}),
]
}

View file

@ -195,20 +195,6 @@ const staticTheme = EditorView.theme({
marginLeft: '-1px', // half the border width
borderLeftColor: 'inherit',
},
// set the default "selection match" style
'.cm-selectionMatch, .cm-searchMatch': {
backgroundColor: 'transparent',
outlineOffset: '-1px',
},
// make sure selectionMatch inside searchMatch doesn't have a background colour
'.cm-searchMatch .cm-selectionMatch': {
backgroundColor: 'transparent !important',
},
// Match the height of search matches to selection matches
'.cm-searchMatch': {
paddingTop: 'var(--half-leading)',
paddingBottom: 'var(--half-leading)',
},
// remove border from hover tooltips (e.g. cursor highlights)
'.cm-tooltip-hover': {
border: 'none',

View file

@ -1298,6 +1298,7 @@
"search_replace_with": "Replace with",
"search_search_for": "Search for",
"search_whole_word": "Whole word",
"search_within_selection": "Within selection",
"secondary_email_password_reset": "That email is registered as a secondary email. Please enter the primary email for your account.",
"security": "Security",
"see_changes_in_your_documents_live": "See changes in your documents, live",

View file

@ -76,7 +76,7 @@
"@codemirror/lang-markdown": "^6.1.1",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.2.1",
"@codemirror/search": "^6.4.0",
"@codemirror/search": "github:overleaf/codemirror-search#ea83364b22ad66455fc94babea7d576fa9f76a93",
"@codemirror/state": "^6.2.0",
"@codemirror/view": "^6.9.6",
"@contentful/rich-text-html-renderer": "^16.0.2",

View file

@ -441,6 +441,24 @@ describe('<CodeMirrorEditor/>', { scrollBehavior: false }, function () {
cy.get('@replace-input').clear()
cy.should('not.contain.text', 'abcde')
// replace all within selection
cy.get('@search-input').clear().type('contentLine')
cy.get('.ol-cm-search-form-position').should('have.text', '1 of 100')
cy.get('.cm-line')
.eq(27)
.should('contain.text', 'contentLine 0')
.click()
.type('{shift}{downArrow}{downArrow}{downArrow}')
cy.findByLabelText('Within selection').click()
cy.get('.ol-cm-search-form-position').should('have.text', '1 of 3')
cy.get('@replace-input').clear().type('contentedLine')
cy.findByRole('button', { name: /replace all/i }).click()
cy.get('.cm-line:contains("contentedLine")').should('have.length', 3)
cy.findByLabelText('Within selection').click()
cy.get('.ol-cm-search-form-position').should('have.text', '2 of 97')
cy.get('@search-input').clear()
cy.get('@replace-input').clear()
// close the search form, to clear the stored query
cy.findByRole('button', { name: 'Close' }).click()
})
@ -465,9 +483,11 @@ describe('<CodeMirrorEditor/>', { scrollBehavior: false }, function () {
cy.get('[type="checkbox"][name="caseSensitive"]').as('case-sensitive')
cy.get('[type="checkbox"][name="regexp"]').as('regexp')
cy.get('[type="checkbox"][name="wholeWord"]').as('whole-word')
cy.get('[type="checkbox"][name="withinSelection"]').as('within-selection')
cy.get('label').contains('Aa').as('case-sensitive-label')
cy.get('label').contains('[.*]').as('regexp-label')
cy.get('label').contains('W').as('whole-word-label')
cy.findByLabelText('Within selection').as('within-selection-label')
cy.findByRole('button', { name: 'Replace' }).as('replace')
cy.findByRole('button', { name: 'Replace All' }).as('replace-all')
cy.findByRole('button', { name: 'next' }).as('find-next')
@ -480,6 +500,7 @@ describe('<CodeMirrorEditor/>', { scrollBehavior: false }, function () {
cy.get('@case-sensitive').should('be.focused').tab()
cy.get('@regexp').should('be.focused').tab()
cy.get('@whole-word').should('be.focused').tab()
cy.get('@within-selection').should('be.focused').tab()
cy.get('@find-next').should('be.focused').tab()
cy.get('@find-previous').should('be.focused').tab()
cy.get('@replace').should('be.focused').tab()
@ -491,6 +512,7 @@ describe('<CodeMirrorEditor/>', { scrollBehavior: false }, function () {
cy.get('@replace').should('be.focused').tab({ shift: true })
cy.get('@find-previous').should('be.focused').tab({ shift: true })
cy.get('@find-next').should('be.focused').tab({ shift: true })
cy.get('@within-selection').should('be.focused').tab({ shift: true })
cy.get('@whole-word').should('be.focused').tab({ shift: true })
cy.get('@regexp').should('be.focused').tab({ shift: true })
cy.get('@case-sensitive').should('be.focused').tab({ shift: true })
@ -501,6 +523,7 @@ describe('<CodeMirrorEditor/>', { scrollBehavior: false }, function () {
'@case-sensitive-label',
'@regexp-label',
'@whole-word-label',
'@within-selection-label',
]) {
// Toggle when clicked, then focus the search input
cy.get(option).click().should('have.class', 'checked')