UNPKG

@ryusei/code

Version:

<div align="center"> <a href="https://code.ryuseijs.com"> <img alt="RyuseiCode" src="https://code.ryuseijs.com/images/svg/logo.svg" width="70"> </a>

636 lines (531 loc) 15.7 kB
import { Elements, Range, SearchOptions } from '@ryusei/code'; import { Component } from '../../classes/Component/Component'; import { MAX_RANGES } from '../../components/Range/Range'; import { CLASS_ACTIVE } from '../../constants/classes'; import { EVENT_CHANGE, EVENT_CHANGED, EVENT_KEYMAP, EVENT_READONLY, EVENT_SYNCED } from '../../constants/events'; import { Editor } from '../../core/Editor/Editor'; import { assign, attr, clamp, compare, create, div, includes, isUndefined, prevent, text, throttle, toggleClass, } from '../../utils'; import { Throttle } from '../../utils/function/throttle/throttle'; import { Toolbar } from '../Toolbar/Toolbar'; import { REPLACE_BUTTONS, SEARCH_BUTTONS } from './buttons'; import { CLASS_MATCHES_COUNT, CLASS_REPLACE, CLASS_REPLACE_CONTROLS, CLASS_SEARCH, CLASS_SEARCH_CONTROLS, } from './classes'; import { ACTIVE_MARKER_ID, JUMP_DELAY_AFTER_REPLACE, MARKER_ID, SEARCH_THROTTLE_DURATION, TOOLBAR_ID, } from './constants'; import { DEFAULT_OPTIONS } from './defaults'; import { I18N } from './i18n'; import { ICONS } from './icons'; import { KEYMAP } from './keymap'; /** * The class for searching texts in the code. * * @since 0.1.0 */ export class Search extends Component { /** * Holds the Toolbar component. */ private Toolbar: Toolbar; /** * Holds the wrapper element. */ private wrapper: HTMLDivElement; /** * Holds the element that wraps elements of the search interface. */ private searchBar: HTMLDivElement; /** * Holds the element that wraps elements of the replace interface. */ private replaceBar: HTMLDivElement; /** * Holds the element that displays matches count. */ private counter: HTMLSpanElement; /** * Stores button elements. */ private buttons: Record<string, HTMLButtonElement>; /** * The throttled search function. */ private throttledSearch: Throttle<( search: string, index?: number ) => void>; /** * Holds matched ranges. */ private ranges: Range[] = []; /** * The current range index. */ private index = -1; /** * Indicates whether to ignore cases or not. */ private matchCase: boolean; /** * Whether to search texts by the regular expression or not. */ private regexp: boolean; /** * Whether to search texts by a whole word or not. */ private wholeWord: boolean; /** * Holds search options. */ private opts: SearchOptions; /** * Keeps the ID of the timer for the delay until jumping to the next match. */ private jumpTimerAfterReplace: ReturnType<typeof setTimeout>; /** * Holds the search input element. */ searchField: HTMLInputElement; /** * Holds the replace input element. */ replaceField: HTMLInputElement; /** * The Search constructor. * * @param Editor - An Editor instance. */ constructor( Editor: Editor ) { super( Editor ); this.addIcons( ICONS ); this.addI18n( I18N ); this.addKeyBindings( KEYMAP ); } /** * Initializes the component. * * @param elements - A collection of essential elements. */ mount( elements: Elements ): void { if ( ! ( this.Toolbar = this.require( 'Toolbar' ) ) ) { return; } super.mount( elements ); this.opts = this.getOptions( 'search', DEFAULT_OPTIONS ); this.throttledSearch = throttle( this.search.bind( this ), SEARCH_THROTTLE_DURATION ); this.create(); this.Toolbar.register( TOOLBAR_ID, this.wrapper, this.i18n.searchToolbar ); this.listen(); } /** * Creates elements for the search interface. */ private create(): void { const { Toolbar } = this; const wrapper = div(); const searchBar = div( CLASS_SEARCH, wrapper ); const replaceBar = div( CLASS_REPLACE, wrapper ); this.searchField = Toolbar.createField( { id: 'search', tabindex: 1 }, searchBar ); this.replaceField = Toolbar.createField( { id: 'replace', tabindex: 1 }, replaceBar ); const searchControls = div( CLASS_SEARCH_CONTROLS, searchBar ); const replaceControls = div( CLASS_REPLACE_CONTROLS, replaceBar ); const searchButtons = SEARCH_BUTTONS.filter( settings => ! includes( this.opts.hideButtons, settings.id ) ); const replaceButtons = REPLACE_BUTTONS.filter( settings => ! includes( this.opts.hideButtons, settings.id ) ); this.buttons = assign( Toolbar.createButtons<Search>( searchButtons, searchControls, this ), Toolbar.createButtons<Search>( replaceButtons, replaceControls, this ) ); if ( ! this.opts.hideMatchCount ) { this.counter = create( 'span', CLASS_MATCHES_COUNT, searchControls ); } this.wrapper = wrapper; this.searchBar = searchBar; this.replaceBar = replaceBar; } /** * Listens to some events. */ private listen(): void { const { searchField } = this; this.on( `${ EVENT_KEYMAP }:search`, ( e, ke ) => { this.show( ! this.options.keymap.replace ); prevent( ke ); } ); this.on( `${ EVENT_KEYMAP }:replace`, ( e, ke ) => { this.show( true ); prevent( ke ); } ); this.bind( searchField, 'input', this.onInput, this ); this.bind( searchField, 'keydown', this.onSearchFieldKeydown, this ); this.bind( this.replaceField, 'keydown', this.onReplaceFieldKeydown, this ); this.on( 'toolbar:opened', ( e, toolbar, id ) => { if ( id !== TOOLBAR_ID ) { this.clear(); } } ); this.on( 'toolbar:closed', this.clear, this ); this.on( [ EVENT_CHANGED, EVENT_SYNCED ], () => { const { value } = searchField; if ( this.isActive() && value ) { this.throttledSearch( value, this.index ); } } ); this.on( EVENT_READONLY, ( e, readOnly ) => { if ( this.isActive() ) { this.toggleReplace( ! readOnly ); } } ); } /** * Called when any key is pressed on the search field. * * @param e - A KeyboardEvent object. */ private onSearchFieldKeydown( e: KeyboardEvent ): void { if ( e.key === 'Enter' ) { this.next(); prevent( e ); return; } this.onKeydown( e ); } /** * Called when any key is pressed on the replace field. * * @param e - A KeyboardEvent object. */ private onReplaceFieldKeydown( e: KeyboardEvent ): void { if ( e.key === 'Enter' ) { this.replace(); prevent( e ); return; } this.onKeydown( e ); } /** * Called when any key is pressed on both the search and input fields. * * @param e - A KeyboardEvent object. */ private onKeydown( e: KeyboardEvent ): void { const key = e.key.toUpperCase(); const { Keymap } = this; const matches = Keymap.matches.bind( Keymap, e ); const next = matches( 'searchNext' ); const prev = matches( 'searchPrev' ); if ( next || prev ) { this[ prev ? 'prev' : 'next' ](); prevent( e ); } else if ( matches( 'search' ) ) { this.show( false ); prevent( e ); } else if ( matches( 'replace' ) ) { this.show( true ); prevent( e ); } else if ( e.ctrlKey ) { if ( key !== 'A' && key !== 'X' && key === 'C' ) { prevent( e ); } } else if ( e.altKey ) { prevent( e ); } } /** * Called when the field receives input. */ private onInput(): void { const { value } = this.searchField; if ( value ) { this.throttledSearch( value ); } else { this.clear(); this.toggleDisabled(); } } /** * Searches the provided string with current settings. * * @param search - Optional. A string to search. * @param index - Optional. An index to activate. * * @return An array with tuples that contains `[ index, length ]`. */ private search( search: string = this.searchField.value, index?: number ): void { const { Range } = this; let source: string | RegExp; try { source = this.regexp && search ? new RegExp( search ) : search; } catch ( e ) { return; } const ranges = this.Code.search( source, ! this.matchCase, this.wholeWord, MAX_RANGES ); this.clear(); Range.register( MARKER_ID, ranges ); this.ranges = ranges; if ( isUndefined( index ) || index < 0 ) { this.index = -1; this.next(); } else { this.index = clamp( index, 0, ranges.length - 1 ); this.activate( this.index ); } this.updateMatchesCount(); this.toggleDisabled(); } /** * Search again without changing the current index. * * @param index - Optional. An index to activate. */ private rematch( index?: number ): void { this.search( undefined, index ); } /** * Updates matches counter. */ private updateMatchesCount(): void { if ( this.counter ) { const { length } = this.ranges; let string: string; if ( ! length ) { string = this.i18n.noResults; } else if ( length > MAX_RANGES ) { string = `${ MAX_RANGES }+`; } else { string = `${ this.index + 1 }/${ length }`; } text( this.counter, string ); } } /** * Toggles `disabled` property of some buttons. */ private toggleDisabled(): void { [ 'prevMatch', 'nextMatch', 'replace', 'replaceAll' ].forEach( name => { const button = this.buttons[ name ]; if ( button ) { button.disabled = ! this.ranges.length; } } ); } /** * Jumps to the start position of the range specified by the index. * * @param index - An index of the range to jump to. */ private jump( index: number ): void { const range = this.ranges[ index ]; if ( range ) { this.View.jump( range.start[ 0 ], true ); } } /** * Highlights the prev or next matched text and jumps there. * * @param prev - Whether to highlight the previous or next match. */ private move( prev: boolean ): void { const { length } = this.ranges; let index = this.index + ( prev ? -1 : 1 ); if ( index >= length ) { index = 0; } else if ( index < 0 ) { index = length - 1; } this.activate( index ); this.jump( index ); this.index = index; this.updateMatchesCount(); } /** * Toggles the active class and the `aria-checked` attribute. * * @param button - A target button element. * @param checked - Determines whether to check or uncheck them. */ private toggleChecked( button: HTMLButtonElement, checked: boolean ): void { toggleClass( button, CLASS_ACTIVE, checked ); attr( button, { 'aria-checked': checked } ); } /** * Toggles the replace UI. * * @param show - Determines whether to show the replace UI or not. */ private toggleReplace( show: boolean ): void { toggleClass( this.replaceBar, CLASS_ACTIVE, show && ! this.Editor.readOnly && ! this.opts.hideReplace ); } /** * Checks if the search toolbar is active or not. * * @return `true` if the search toolbar is active, or otherwise `false`. */ private isActive(): boolean { return this.Toolbar.isActive( TOOLBAR_ID ); } /** * Toggles the "Match Case" mode. * * @param activate - Optional. Whether to activate the "Match Case" mode or not. */ toggleMatchCase( activate = ! this.matchCase ): void { this.toggleChecked( this.buttons.matchCase, ( this.matchCase = activate ) ); this.search(); } /** * Toggles the "RegExp" mode. * * @param activate - Optional. Whether to activate the "RegExp" mode or not. */ toggleRegExp( activate = ! this.regexp ): void { this.toggleChecked( this.buttons.regexp, ( this.regexp = activate ) ); this.search(); } /** * Toggles the "Match Whole Word" mode. * * @param wholeWord - Optional. Whether to activate the "Match Whole Word" mode or not. */ toggleWholeWord( wholeWord = ! this.wholeWord ): void { this.toggleChecked( this.buttons.wholeWord, ( this.wholeWord = wholeWord ) ); this.search(); } /** * Highlights the matched text at the index. * * @param index - An index of the range to highlight. */ activate( index: number ): void { const activeRange = this.ranges[ index ]; if ( activeRange ) { const { Range } = this; Range.clear( ACTIVE_MARKER_ID ); Range.register( ACTIVE_MARKER_ID, [ activeRange ] ); } } /** * Highlights the next matched text and jumps there. */ next(): void { this.move( false ); } /** * Highlights the previous matched text and jumps there. */ prev(): void { this.move( true ); } /** * Replaces the search result with the provided replacement string. * If the length of ranges does not change after replacing, * that means the replacement includes the original word itself. * * @param replacement - Optional. A replacement string. * @param index - Optional. An index to replace. */ replace( replacement = this.replaceField.value, index = this.index ): void { const { ranges } = this; const activeRange = ranges[ index ]; if ( activeRange ) { const { Selection } = this; const { start, end } = activeRange; const nextRange = ranges[ index + 1 ]; Selection.update( start, start, true ); this.emit( EVENT_CHANGE, 'replace' ); this.jump( index ); this.Code.replaceRange( start, end, replacement ); this.Sync.sync( start[ 0 ], end[ 0 ] ); this.emit( EVENT_CHANGED, 'replace' ); this.rematch( index ); if ( nextRange ) { this.index = this.toIndex( nextRange ); this.activate( this.index ); } this.jumpTimerAfterReplace = setTimeout( () => { this.jump( this.index ); }, JUMP_DELAY_AFTER_REPLACE ); } } /** * Converts the provided range to the range index. * * @param range - A range to convert into a range index. * * @return A range index if available, or otherwise `-1`. */ private toIndex( range: Range ): number { const { ranges } = this; for ( let i = 0; i < ranges.length; i++ ) { if ( ! compare( ranges[ i ].start, range.start ) && ! compare( ranges[ i ].end, range.end ) ) { return i; } } return -1; } /** * Replaces all matched strings with the replacement. * * @param replacement - Optional. A replacement string. */ replaceAll( replacement = this.replaceField.value ): void { const { ranges } = this; if ( ranges.length ) { this.emit( EVENT_CHANGE ); ranges.forEach( range => { this.Code.replaceRange( range.start, range.end, replacement ); } ); const endRow = ranges[ ranges.length - 1 ].end[ 0 ]; this.View.jump( endRow ); this.Sync.sync( ranges[ 0 ].start[ 0 ], endRow ); this.clear(); this.emit( EVENT_CHANGED ); } } /** * Shows the toolbar. * * @param replace - Whether to display the replace interface or not. */ show( replace: boolean ): void { const { Selection, searchField } = this; this.toggleReplace( replace ); if ( ! Selection.isCollapsed() ) { if ( ! Selection.isMultiline() ) { searchField.value = Selection.toString(); } } this.Toolbar.show( TOOLBAR_ID ); this.rematch(); } /** * Clears all markers. */ clear(): void { const { Range } = this; Range.clear( MARKER_ID ); Range.clear( ACTIVE_MARKER_ID ); this.ranges = []; this.updateMatchesCount(); this.throttledSearch.cancel(); clearTimeout( this.jumpTimerAfterReplace ); } }