CodeMirrorSearch: add num results and current selection; improve tabbing

Just like the 2017 editor, we show the number of results and which one
is currently highlighted.

This patch also brings the Tab behaviour closer to the 2017 editor.
Hitting Tab from the search input focuses the replace input, followed by
the replacement buttons, then the find buttons, then the content
editable. Shift+Tab largely does the reverse, except Shift+Tab from the
editor doesn't bring you to the search panel. Doing this would require a
lot of work for minor benefit, as we'd need to determine which panel to
focus to.

Add basic unit test

Bug: T371436
Change-Id: I968f91320ecb6ab9e9da0994052d33c76f85974b
This commit is contained in:
MusikAnimal 2024-08-14 15:56:24 -04:00
parent 87daa30df0
commit 3c3050447b
6 changed files with 160 additions and 10 deletions

View file

@ -221,6 +221,7 @@
"codemirror-replace",
"codemirror-replace-all",
"codemirror-replace-placeholder",
"codemirror-find-results",
"codemirror-special-char-backspace",
"codemirror-special-char-bell",
"codemirror-special-char-carriage-return",

View file

@ -25,6 +25,7 @@
"codemirror-replace-placeholder": "Replace",
"codemirror-replace-all": "Replace all",
"codemirror-done": "Done",
"codemirror-find-results": "$1 of $2",
"codemirror-goto-line": "Go to line",
"codemirror-goto-line-go": "Go",
"codemirror-control-character": "Control character $1",

View file

@ -30,6 +30,7 @@
"codemirror-replace-placeholder": "Placeholder text for the 'Replace' input in the CodeMirror search panel.",
"codemirror-replace-all": "Label for the 'Replace all' button in the CodeMirror search panel.",
"codemirror-done": "Label for the 'Done' button in CodeMirror panels.\n{{Identical|Done}}",
"codemirror-find-results": "Label for find results showing how many results were found ($2), and which one is currently highlighted ($1).\n{{Identical|Of}}",
"codemirror-goto-line": "Label for the 'Go to line' input field.",
"codemirror-goto-line-go": "Label for the 'Go to line' submit button.\n{{Identical|Go}}",
"codemirror-control-character": "Tooltip text shown when hovering over special characters. $1 is the Unicode value of the special character.",

View file

@ -138,6 +138,14 @@
}
}
.cm-mw-find-results {
color: @color-placeholder;
position: absolute;
right: 9px;
top: 50%;
transform: translateY( -50% );
}
.cm-mw-icon--match-case {
background-color: @color-base;
.cdx-mixin-css-icon( @cdx-icon-search-case-sensitive, @color-base, @size-icon-medium, true );

View file

@ -4,13 +4,16 @@ const {
closeSearchPanel,
findNext,
findPrevious,
getSearchQuery,
keymap,
openSearchPanel,
replaceAll,
replaceNext,
runScopeHandlers,
search,
searchKeymap,
selectMatches,
selectNextOccurrence,
selectSelectionMatches,
setSearchQuery
} = require( 'ext.CodeMirror.v6.lib' );
const CodeMirrorPanel = require( './codemirror.panel.js' );
@ -70,6 +73,14 @@ class CodeMirrorSearch extends CodeMirrorPanel {
* @type {HTMLButtonElement}
*/
this.replaceAllButton = undefined;
/**
* @type {HTMLButtonElement}
*/
this.doneButton = undefined;
/**
* @type {HTMLSpanElement}
*/
this.findResultsText = undefined;
}
/**
@ -83,7 +94,14 @@ class CodeMirrorSearch extends CodeMirrorPanel {
return this.panel;
}
} ),
keymap.of( searchKeymap )
keymap.of( [
{ key: 'Mod-f', run: openSearchPanel, scope: 'editor search-panel' },
{ key: 'F3', run: this.findNext.bind( this ), shift: this.findPrevious.bind( this ), scope: 'editor search-panel', preventDefault: true },
{ key: 'Mod-g', run: this.findNext.bind( this ), shift: this.findPrevious.bind( this ), scope: 'editor search-panel', preventDefault: true },
{ key: 'Escape', run: closeSearchPanel, scope: 'editor search-panel' },
{ key: 'Mod-Shift-l', run: selectSelectionMatches },
{ key: 'Mod-d', run: selectNextOccurrence, preventDefault: true }
] )
];
}
@ -102,11 +120,17 @@ class CodeMirrorSearch extends CodeMirrorPanel {
// Search input.
const [ searchInputWrapper, searchInput ] = this.getTextInput(
'search',
this.searchQuery.search || '',
this.searchQuery.search || this.view.state.sliceDoc(
this.view.state.selection.main.from,
this.view.state.selection.main.to
),
'codemirror-find'
);
this.searchInput = searchInput;
this.searchInput.setAttribute( 'main-field', 'true' );
this.findResultsText = document.createElement( 'span' );
this.findResultsText.className = 'cm-mw-find-results';
searchInputWrapper.appendChild( this.findResultsText );
firstRow.appendChild( searchInputWrapper );
this.appendPrevAndNextButtons( firstRow );
@ -146,7 +170,7 @@ class CodeMirrorSearch extends CodeMirrorPanel {
buttonGroup.appendChild( this.prevButton );
this.prevButton.addEventListener( 'click', ( e ) => {
e.preventDefault();
findPrevious( this.view );
this.findPrevious();
} );
// "Next" button.
@ -154,7 +178,7 @@ class CodeMirrorSearch extends CodeMirrorPanel {
buttonGroup.appendChild( this.nextButton );
this.nextButton.addEventListener( 'click', ( e ) => {
e.preventDefault();
findNext( this.view );
this.findNext();
} );
firstRow.appendChild( buttonGroup );
@ -225,6 +249,7 @@ class CodeMirrorSearch extends CodeMirrorPanel {
this.replaceButton.addEventListener( 'click', ( e ) => {
e.preventDefault();
replaceNext( this.view );
this.updateNumMatchesText();
} );
// "Replace all" button.
@ -234,12 +259,13 @@ class CodeMirrorSearch extends CodeMirrorPanel {
this.replaceAllButton.addEventListener( 'click', ( e ) => {
e.preventDefault();
replaceAll( this.view );
this.updateNumMatchesText();
} );
// "Done" button.
const doneButton = this.getButton( 'codemirror-done' );
row.appendChild( doneButton );
doneButton.addEventListener( 'click', ( e ) => {
this.doneButton = this.getButton( 'codemirror-done' );
row.appendChild( this.doneButton );
this.doneButton.addEventListener( 'click', ( e ) => {
e.preventDefault();
closeSearchPanel( this.view );
this.view.focus();
@ -254,12 +280,46 @@ class CodeMirrorSearch extends CodeMirrorPanel {
onKeydown( event ) {
if ( runScopeHandlers( this.view, event, 'search-panel' ) ) {
event.preventDefault();
} else if ( event.key === 'Enter' && event.target === this.searchInput ) {
return;
}
if ( this.view.state.readOnly ) {
// Use normal tab behaviour if the editor is read-only.
return;
}
if ( event.key === 'Enter' && event.target === this.searchInput ) {
event.preventDefault();
( event.shiftKey ? findPrevious : findNext )( this.view );
( event.shiftKey ? this.findPrevious : this.findNext ).call( this );
} else if ( event.key === 'Enter' && event.target === this.replaceInput ) {
event.preventDefault();
replaceNext( this.view );
this.updateNumMatchesText();
} else if ( event.key === 'Tab' ) {
if ( !event.shiftKey && event.target === this.searchInput ) {
// Tabbing from the search input should focus the replaceInput.
event.preventDefault();
this.replaceInput.focus();
} else if ( event.shiftKey && event.target === this.replaceInput ) {
// Shift+Tabbing from the replaceInput should focus the searchInput.
event.preventDefault();
this.searchInput.focus();
} else if ( !event.shiftKey && event.target === this.doneButton ) {
// Tabbing from the "Done" button should focus the prevButton.
event.preventDefault();
this.prevButton.focus();
} else if ( !event.shiftKey && event.target === this.wholeWordButton ) {
// Tabbing from the "Whole word" button should focus the editor,
// or the next focusable panel if there is one.
event.preventDefault();
const el = this.view.dom.querySelector( '.cm-mw-panel--search-panel' );
if ( el && el.nextElementSibling && el.nextElementSibling.classList.contains( 'cm-panel' ) ) {
const input = el.nextElementSibling.querySelector( 'input' );
( input || el.nextElementSibling ).focus();
} else {
this.view.focus();
}
}
}
}
@ -282,6 +342,55 @@ class CodeMirrorSearch extends CodeMirrorPanel {
effects: setSearchQuery.of( query )
} );
}
this.updateNumMatchesText( query );
}
/**
* Find the previous match.
*
* @return {boolean} Whether a match was found.
*/
findPrevious() {
const ret = findPrevious( this.view );
this.updateNumMatchesText();
return ret;
}
/**
* Find the next match.
*
* @return {boolean} Whether a match was found.
*/
findNext() {
const ret = findNext( this.view );
this.updateNumMatchesText();
return ret;
}
/**
* Show the number of matches for the given {@link SearchQuery}
* and the index of the current match in the find input.
*
* @param {SearchQuery} [query]
*/
updateNumMatchesText( query ) {
const cursor = query ?
query.getCursor( this.view.state ) :
getSearchQuery( this.view.state ).getCursor( this.view.state );
let count = 0,
current = 1;
const { from, to } = this.view.state.selection.main;
let item = cursor.next();
while ( !item.done ) {
if ( item.value.from === from && item.value.to === to ) {
current = count + 1;
}
item = cursor.next();
count++;
}
this.findResultsText.textContent = count ?
mw.msg( 'codemirror-find-results', current, count ) :
'';
}
/**

View file

@ -0,0 +1,30 @@
/* eslint-disable-next-line n/no-missing-require */
const { EditorView, EditorState } = require( 'ext.CodeMirror.v6.lib' );
const CodeMirrorSearch = require( '../../resources/codemirror.search.js' );
describe( 'CodeMirrorSearch', () => {
it( 'should provide an Extension getter and a Panel getter', () => {
const cmSearch = new CodeMirrorSearch();
cmSearch.view = new EditorView();
expect( cmSearch.extension ).toBeInstanceOf( Array );
expect( cmSearch.extension[ 0 ][ 0 ].constructor.name ).toStrictEqual( 'FacetProvider' );
expect( cmSearch.panel ).toHaveProperty( 'dom' );
} );
it( 'should disable replacement fields if the textarea is read-only', () => {
const cmSearch = new CodeMirrorSearch();
cmSearch.view = new EditorView();
// eslint-disable-next-line no-unused-expressions
cmSearch.panel;
cmSearch.view = new EditorView( {
state: EditorState.create( {
doc: '',
extensions: [ EditorState.readOnly.of( true ) ]
} )
} );
// eslint-disable-next-line no-unused-expressions
cmSearch.panel;
expect( cmSearch.replaceInput.disabled ).toBe( true );
} );
} );