From 3c3050447bd4ab7452145ba67a23e88670a0bd50 Mon Sep 17 00:00:00 2001 From: MusikAnimal Date: Wed, 14 Aug 2024 15:56:24 -0400 Subject: [PATCH] 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 --- extension.json | 1 + i18n/en.json | 1 + i18n/qqq.json | 1 + resources/codemirror.less | 8 ++ resources/codemirror.search.js | 129 ++++++++++++++++++++++++--- tests/jest/codemirror.search.test.js | 30 +++++++ 6 files changed, 160 insertions(+), 10 deletions(-) create mode 100644 tests/jest/codemirror.search.test.js diff --git a/extension.json b/extension.json index be209a27..0c29ba4d 100644 --- a/extension.json +++ b/extension.json @@ -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", diff --git a/i18n/en.json b/i18n/en.json index 66265635..636b9f15 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -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", diff --git a/i18n/qqq.json b/i18n/qqq.json index 4c06fd4c..55e13106 100644 --- a/i18n/qqq.json +++ b/i18n/qqq.json @@ -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.", diff --git a/resources/codemirror.less b/resources/codemirror.less index ec19d5dc..c8ea3da5 100644 --- a/resources/codemirror.less +++ b/resources/codemirror.less @@ -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 ); diff --git a/resources/codemirror.search.js b/resources/codemirror.search.js index 5f90c1b3..67aaedcb 100644 --- a/resources/codemirror.search.js +++ b/resources/codemirror.search.js @@ -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 ) : + ''; } /** diff --git a/tests/jest/codemirror.search.test.js b/tests/jest/codemirror.search.test.js new file mode 100644 index 00000000..6e547321 --- /dev/null +++ b/tests/jest/codemirror.search.test.js @@ -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 ); + } ); +} );