/* eslint-disable es-x/no-string-prototype-replaceall */
const {
EditorView,
SearchQuery,
closeSearchPanel,
findNext,
findPrevious,
getSearchQuery,
keymap,
openSearchPanel,
replaceAll,
replaceNext,
runScopeHandlers,
search,
searchPanelOpen,
selectMatches,
selectNextOccurrence,
selectSelectionMatches,
setSearchQuery
} = require( 'ext.CodeMirror.v6.lib' );
const CodeMirrorPanel = require( './codemirror.panel.js' );
/**
* Custom search panel for CodeMirror using CSS-only Codex components.
*
* @extends CodeMirrorPanel
*/
class CodeMirrorSearch extends CodeMirrorPanel {
/**
* Instantiate a new CodeMirror search panel.
*
* @param {CodeMirrorKeymap} cmKeymap Reference to the keymap instance.
*/
constructor( cmKeymap ) {
super();
/**
* @type {CodeMirrorKeymap}
*/
this.keymap = cmKeymap;
/**
* @type {SearchQuery}
*/
this.searchQuery = {
search: ''
};
/**
* @type {HTMLInputElement}
*/
this.searchInput = undefined;
/**
* @type {HTMLDivElement}
*/
this.searchInputWrapper = undefined;
/**
* @type {HTMLInputElement}
*/
this.replaceInput = undefined;
/**
* @type {HTMLButtonElement}
*/
this.matchCaseButton = undefined;
/**
* @type {HTMLButtonElement}
*/
this.regexpButton = undefined;
/**
* @type {HTMLButtonElement}
*/
this.wholeWordButton = undefined;
/**
* @type {HTMLButtonElement}
*/
this.nextButton = undefined;
/**
* @type {HTMLButtonElement}
*/
this.prevButton = undefined;
/**
* @type {HTMLButtonElement}
*/
this.allButton = undefined;
/**
* @type {HTMLButtonElement}
*/
this.replaceButton = undefined;
/**
* @type {HTMLButtonElement}
*/
this.replaceAllButton = undefined;
/**
* @type {HTMLButtonElement}
*/
this.doneButton = undefined;
/**
* @type {HTMLSpanElement}
*/
this.findResultsText = undefined;
/**
* Contains only keybindings bound to the search panel.
*
* @type {Object<CodeMirrorKeyBinding>}
*/
this.searchPanelKeymap = {
matchCase: {
key: 'Alt-c',
mac: 'Ctrl-Alt-c',
run: () => this.matchCaseButton.click(),
scope: 'search-panel',
preventDefault: true
},
regexp: {
key: 'Alt-/',
mac: 'Ctrl-Alt-/',
run: () => this.regexpButton.click(),
scope: 'search-panel',
preventDefault: true
},
wholeWord: {
key: 'Alt-w',
mac: 'Ctrl-Alt-w',
run: () => this.wholeWordButton.click(),
scope: 'search-panel',
preventDefault: true
},
replaceFocus: {
key: 'Ctrl-h',
mac: 'Cmd-Alt-f',
run: () => {
if ( document.activeElement === this.replaceInput ) {
return false;
}
this.replaceInput.focus();
this.replaceInput.select();
return true;
},
scope: 'search-panel'
}
};
}
/**
* @inheritDoc
*/
get extension() {
return [
search( {
createPanel: ( view ) => {
/**
* Fired when the CodeMirror search panel is opened or closed.
*
* @event CodeMirror~ext.CodeMirror.search
* @internal
*/
mw.hook( 'ext.CodeMirror.search' ).fire();
this.view = view;
return this.panel;
},
scrollToMatch: ( range, view ) => {
// If within the viewport, scroll to as little as possible, otherwise center it.
const scrollRect = view.scrollDOM.getBoundingClientRect();
const startCoords = view.coordsAtPos( range.from );
const endCoords = view.coordsAtPos( range.to );
const isInViewport = startCoords && startCoords.top >= scrollRect.top &&
endCoords && endCoords.bottom <= scrollRect.bottom;
return EditorView.scrollIntoView( range, { y: isInViewport ? 'nearest' : 'center' } );
}
} ),
keymap.of( [
{
key: 'Mod-f',
run: this.openPanel.bind( this ),
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: this.closePanel.bind( this ),
scope: 'editor search-panel'
},
{
key: 'Mod-Shift-l',
run: selectSelectionMatches
},
{
key: 'Mod-d',
run: selectNextOccurrence,
preventDefault: true
},
this.searchPanelKeymap.matchCase,
this.searchPanelKeymap.regexp,
this.searchPanelKeymap.wholeWord,
this.searchPanelKeymap.replaceFocus
] )
];
}
/**
* @inheritDoc
*/
get panel() {
const container = document.createElement( 'div' );
container.className = 'cm-mw-panel cm-mw-panel--search-panel';
container.addEventListener( 'keydown', this.onKeydown.bind( this ) );
const firstRow = document.createElement( 'div' );
firstRow.className = 'cm-mw-panel--row';
container.appendChild( firstRow );
// Search input.
const selection = this.view.state.selection.main;
const searchValue = !selection.empty && selection.to <= selection.from + 100 ?
this.view.state.sliceDoc( selection.from, selection.to ) :
this.searchQuery.search;
const [ searchInputWrapper, searchInput ] = this.getTextInput(
'search',
searchValue || this.searchQuery.search,
'codemirror-find'
);
this.searchInput = searchInput;
this.searchInput.setAttribute( 'main-field', 'true' );
this.searchInputWrapper = searchInputWrapper;
this.findResultsText = document.createElement( 'span' );
this.findResultsText.className = 'cm-mw-find-results';
this.searchInputWrapper.appendChild( this.findResultsText );
firstRow.appendChild( this.searchInputWrapper );
this.appendPrevAndNextButtons( firstRow );
// "All" button.
this.allButton = this.getButton( 'codemirror-all' );
this.allButton.title = mw.msg( 'codemirror-all-tooltip' );
this.allButton.addEventListener( 'click', ( e ) => {
e.preventDefault();
selectMatches( this.view );
} );
firstRow.appendChild( this.allButton );
this.appendSearchOptions( firstRow );
this.appendSecondRow( container );
return {
dom: container,
top: true,
mount: () => {
this.searchInput.focus();
this.searchInput.select();
}
};
}
/**
* Open the search panel.
*
* @param {EditorView} view
* @return {boolean}
* @internal
*/
openPanel( view ) {
// If the search panel is already open and focused, pass the event to the browser.
if ( searchPanelOpen( view.state ) && document.activeElement === this.searchInput ) {
return false;
}
openSearchPanel( view );
this.commit();
return true;
}
/**
* Close the search panel.
*
* @param {EditorView} view
* @return {boolean}
* @internal
*/
closePanel( view ) {
closeSearchPanel( view );
mw.hook( 'ext.CodeMirror.search' ).fire();
return true;
}
/**
* @param {HTMLDivElement} firstRow
* @private
*/
appendPrevAndNextButtons( firstRow ) {
const buttonGroup = document.createElement( 'div' );
buttonGroup.className = 'cdx-button-group';
// "Previous" button.
this.prevButton = this.getButton( 'codemirror-previous', { icon: 'previous', iconOnly: true } );
this.prevButton.title = this.keymap.getTitleWithShortcut(
this.keymap.keymapHelpRegistry.search.findPrev,
this.prevButton.title
);
buttonGroup.appendChild( this.prevButton );
this.prevButton.addEventListener( 'click', ( e ) => {
e.preventDefault();
this.findPrevious();
} );
// "Next" button.
this.nextButton = this.getButton( 'codemirror-next', { icon: 'next', iconOnly: true } );
this.nextButton.title = this.keymap.getTitleWithShortcut(
this.keymap.keymapHelpRegistry.search.findNext,
this.nextButton.title
);
buttonGroup.appendChild( this.nextButton );
this.nextButton.addEventListener( 'click', ( e ) => {
e.preventDefault();
this.findNext();
} );
firstRow.appendChild( buttonGroup );
}
/**
* @param {HTMLDivElement} firstRow
* @private
*/
appendSearchOptions( firstRow ) {
const buttonGroup = document.createElement( 'div' );
buttonGroup.className = 'cdx-toggle-button-group';
// "Match case" ToggleButton.
this.matchCaseButton = this.getToggleButton(
'case',
'codemirror-match-case',
'match-case',
this.searchQuery.caseSensitive,
this.searchPanelKeymap.matchCase
);
buttonGroup.appendChild( this.matchCaseButton );
// "Regexp" ToggleButton.
this.regexpButton = this.getToggleButton(
'regexp',
'codemirror-regexp',
'regexp',
this.searchQuery.regexp,
this.searchPanelKeymap.regexp
);
buttonGroup.appendChild( this.regexpButton );
// "Whole word" ToggleButton.
this.wholeWordButton = this.getToggleButton(
'word',
'codemirror-by-word',
'quotes',
this.searchQuery.wholeWord,
this.searchPanelKeymap.wholeWord
);
buttonGroup.appendChild( this.wholeWordButton );
firstRow.appendChild( buttonGroup );
}
/**
* @param {HTMLDivElement} container
* @private
*/
appendSecondRow( container ) {
const shouldBeDisabled = this.view.state.readOnly;
const row = document.createElement( 'div' );
row.className = 'cm-mw-panel--row';
container.appendChild( row );
// Replace input.
const [ replaceInputWrapper, replaceInput ] = this.getTextInput(
'replace',
this.searchQuery.replace || '',
'codemirror-replace-placeholder'
);
this.replaceInput = replaceInput;
this.replaceInput.disabled = shouldBeDisabled;
row.appendChild( replaceInputWrapper );
// "Replace" button.
this.replaceButton = this.getButton( 'codemirror-replace' );
this.replaceButton.disabled = shouldBeDisabled;
row.appendChild( this.replaceButton );
this.replaceButton.addEventListener( 'click', ( e ) => {
e.preventDefault();
replaceNext( this.view );
this.updateNumMatchesText();
} );
// "Replace all" button.
this.replaceAllButton = this.getButton( 'codemirror-replace-all' );
this.replaceAllButton.disabled = shouldBeDisabled;
row.appendChild( this.replaceAllButton );
this.replaceAllButton.addEventListener( 'click', ( e ) => {
e.preventDefault();
replaceAll( this.view );
this.updateNumMatchesText();
} );
// "Done" button.
this.doneButton = this.getButton( 'codemirror-done' );
row.appendChild( this.doneButton );
this.doneButton.addEventListener( 'click', ( e ) => {
e.preventDefault();
this.closePanel( this.view );
this.view.focus();
} );
}
/**
* Respond to keydown events.
*
* @param {KeyboardEvent} event
* @private
*/
onKeydown( event ) {
if ( runScopeHandlers( this.view, event, 'search-panel' ) ) {
event.preventDefault();
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 ? 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();
}
}
}
}
/**
* Create a new {@link SearchQuery} and dispatch it to the {@link EditorView}.
*
* @private
*/
commit() {
const query = new SearchQuery( {
search: this.searchInput.value,
caseSensitive: this.matchCaseButton.dataset.checked === 'true',
regexp: this.regexpButton.dataset.checked === 'true',
wholeWord: this.wholeWordButton.dataset.checked === 'true',
replace: this.replaceInput.value,
// Makes i.e. "\n" match the literal string "\n" instead of a newline.
literal: true
} );
if ( !query.eq( getSearchQuery( this.view.state ) ) || !query.eq( this.searchQuery ) ) {
this.searchQuery = query;
this.view.dispatch( {
effects: setSearchQuery.of( query )
} );
}
this.updateNumMatchesText( query );
}
/**
* Find the previous match.
*
* @return {boolean} Whether a match was found.
* @private
*/
findPrevious() {
const ret = findPrevious( this.view );
this.updateNumMatchesText();
return ret;
}
/**
* Find the next match.
*
* @return {boolean} Whether a match was found.
* @private
*/
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]
* @private
*/
updateNumMatchesText( query ) {
if ( !!this.searchQuery.search && this.searchQuery.regexp && !this.searchQuery.valid ) {
this.searchInputWrapper.classList.add( 'cdx-text-input--status-error' );
this.findResultsText.textContent = mw.msg( 'codemirror-regexp-invalid' );
return;
}
const cursor = query ?
query.getCursor( this.view.state ) :
getSearchQuery( this.view.state ).getCursor( this.view.state );
// Clear error state
this.searchInputWrapper.classList.remove( 'cdx-text-input--status-error' );
// Remove messaging if there's no search query.
if ( !this.searchQuery.search ) {
this.findResultsText.textContent = '';
return;
}
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 ) :
'';
}
/**
* @inheritDoc
*/
getButton( label, icon = null, iconOnly = false ) {
const button = super.getButton( label, icon, iconOnly );
// The following CSS classes may be used here:
// * cm-mw-panel--search__all
// * cm-mw-panel--search__replace
// * cm-mw-panel--search__replace-all
// * cm-mw-panel--search__done
button.classList.add( `cm-mw-panel--search__${ label.replace( 'codemirror-', '' ) }` );
return button;
}
/**
* @inheritDoc
*/
getTextInput( name, value = '', placeholder = '' ) {
const [ container, input ] = super.getTextInput( name, value, placeholder );
input.autocomplete = 'off';
input.addEventListener( 'change', this.commit.bind( this ) );
input.addEventListener( 'keyup', this.commit.bind( this ) );
return [ container, input ];
}
/**
* @inheritDoc
*/
getToggleButton( name, label, icon, checked = false, shortcut = null ) {
const button = super.getToggleButton( name, label, icon, checked );
if ( shortcut ) {
button.title = this.keymap.getTitleWithShortcut( shortcut, button.title );
}
button.addEventListener( 'click', this.commit.bind( this ) );
return button;
}
}
module.exports = CodeMirrorSearch;