// Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. import { ReactWidget, UseSignal } from '@jupyterlab/apputils'; import { caretDownEmptyThinIcon, caretUpEmptyThinIcon, caseSensitiveIcon, classes, closeIcon, ellipsesIcon, regexIcon } from '@jupyterlab/ui-components'; import { Debouncer } from '@lumino/polling'; import { Signal } from '@lumino/signaling'; import { Widget } from '@lumino/widgets'; import * as React from 'react'; import { IDisplayState } from './interfaces'; import { SearchInstance } from './searchinstance'; const OVERLAY_CLASS = 'jp-DocumentSearch-overlay'; const OVERLAY_ROW_CLASS = 'jp-DocumentSearch-overlay-row'; const INPUT_CLASS = 'jp-DocumentSearch-input'; const INPUT_WRAPPER_CLASS = 'jp-DocumentSearch-input-wrapper'; const INPUT_BUTTON_CLASS_OFF = 'jp-DocumentSearch-input-button-off'; const INPUT_BUTTON_CLASS_ON = 'jp-DocumentSearch-input-button-on'; const INDEX_COUNTER_CLASS = 'jp-DocumentSearch-index-counter'; const UP_DOWN_BUTTON_WRAPPER_CLASS = 'jp-DocumentSearch-up-down-wrapper'; const UP_DOWN_BUTTON_CLASS = 'jp-DocumentSearch-up-down-button'; const ELLIPSES_BUTTON_CLASS = 'jp-DocumentSearch-ellipses-button'; const ELLIPSES_BUTTON_ENABLED_CLASS = 'jp-DocumentSearch-ellipses-button-enabled'; const REGEX_ERROR_CLASS = 'jp-DocumentSearch-regex-error'; const SEARCH_OPTIONS_CLASS = 'jp-DocumentSearch-search-options'; const SEARCH_OPTIONS_DISABLED_CLASS = 'jp-DocumentSearch-search-options-disabled'; const REPLACE_ENTRY_CLASS = 'jp-DocumentSearch-replace-entry'; const REPLACE_BUTTON_CLASS = 'jp-DocumentSearch-replace-button'; const REPLACE_BUTTON_WRAPPER_CLASS = 'jp-DocumentSearch-replace-button-wrapper'; const REPLACE_WRAPPER_CLASS = 'jp-DocumentSearch-replace-wrapper-class'; const REPLACE_TOGGLE_COLLAPSED = 'jp-DocumentSearch-replace-toggle-collapsed'; const REPLACE_TOGGLE_EXPANDED = 'jp-DocumentSearch-replace-toggle-expanded'; const FOCUSED_INPUT = 'jp-DocumentSearch-focused-input'; const TOGGLE_WRAPPER = 'jp-DocumentSearch-toggle-wrapper'; const TOGGLE_PLACEHOLDER = 'jp-DocumentSearch-toggle-placeholder'; const BUTTON_CONTENT_CLASS = 'jp-DocumentSearch-button-content'; const BUTTON_WRAPPER_CLASS = 'jp-DocumentSearch-button-wrapper'; const SPACER_CLASS = 'jp-DocumentSearch-spacer'; interface ISearchEntryProps { onCaseSensitiveToggled: Function; onRegexToggled: Function; onKeydown: Function; onChange: Function; onInputFocus: Function; onInputBlur: Function; inputFocused: boolean; caseSensitive: boolean; useRegex: boolean; searchText: string; forceFocus: boolean; } interface IReplaceEntryProps { onReplaceCurrent: Function; onReplaceAll: Function; onReplaceKeydown: Function; onChange: Function; replaceText: string; } class SearchEntry extends React.Component { constructor(props: ISearchEntryProps) { super(props); } /** * Focus the input. */ focusInput() { (this.refs.searchInputNode as HTMLInputElement).focus(); } componentDidUpdate() { if (this.props.forceFocus) { this.focusInput(); } } render() { const caseButtonToggleClass = classes( this.props.caseSensitive ? INPUT_BUTTON_CLASS_ON : INPUT_BUTTON_CLASS_OFF, BUTTON_CONTENT_CLASS ); const regexButtonToggleClass = classes( this.props.useRegex ? INPUT_BUTTON_CLASS_ON : INPUT_BUTTON_CLASS_OFF, BUTTON_CONTENT_CLASS ); const wrapperClass = `${INPUT_WRAPPER_CLASS} ${ this.props.inputFocused ? FOCUSED_INPUT : '' }`; return (
this.props.onChange(e)} onKeyDown={e => this.props.onKeydown(e)} tabIndex={2} onFocus={e => this.props.onInputFocus()} onBlur={e => this.props.onInputBlur()} ref="searchInputNode" />
); } } class ReplaceEntry extends React.Component { constructor(props: any) { super(props); } render() { return (
this.props.onReplaceKeydown(e)} onChange={e => this.props.onChange(e)} tabIndex={3} ref="replaceInputNode" />
); } } interface IUpDownProps { onHighlightPrevious: Function; onHightlightNext: Function; } function UpDownButtons(props: IUpDownProps) { return (
); } interface ISearchIndexProps { currentIndex: number | null; totalMatches: number; } function SearchIndices(props: ISearchIndexProps) { return (
{props.totalMatches === 0 ? '-/-' : `${props.currentIndex === null ? '-' : props.currentIndex + 1}/${ props.totalMatches }`}
); } interface IFilterToggleProps { enabled: boolean; toggleEnabled: () => void; } interface IFilterToggleState {} class FilterToggle extends React.Component< IFilterToggleProps, IFilterToggleState > { render() { let className = `${ELLIPSES_BUTTON_CLASS} ${BUTTON_CONTENT_CLASS}`; if (this.props.enabled) { className = `${className} ${ELLIPSES_BUTTON_ENABLED_CLASS}`; } return ( ); } } interface IFilterSelectionProps { searchOutput: boolean; canToggleOutput: boolean; toggleOutput: () => void; } interface IFilterSelectionState {} class FilterSelection extends React.Component< IFilterSelectionProps, IFilterSelectionState > { render() { return ( ); } } interface ISearchOverlayProps { overlayState: IDisplayState; onCaseSensitiveToggled: Function; onRegexToggled: Function; onHightlightNext: Function; onHighlightPrevious: Function; onStartQuery: Function; onEndSearch: Function; onReplaceCurrent: Function; onReplaceAll: Function; isReadOnly: boolean; hasOutputs: boolean; } class SearchOverlay extends React.Component< ISearchOverlayProps, IDisplayState > { constructor(props: ISearchOverlayProps) { super(props); this.state = props.overlayState; this._toggleSearchOutput = this._toggleSearchOutput.bind(this); } componentDidMount() { if (this.state.searchText) { this._executeSearch(true, this.state.searchText); } } private _onSearchChange(event: React.ChangeEvent) { const searchText = (event.target as HTMLInputElement).value; this.setState({ searchText: searchText }); void this._debouncedStartSearch.invoke(); } private _onReplaceChange(event: React.ChangeEvent) { this.setState({ replaceText: (event.target as HTMLInputElement).value }); } private _onSearchKeydown(event: KeyboardEvent) { if (event.keyCode === 13) { event.preventDefault(); event.stopPropagation(); this._executeSearch(!event.shiftKey); } else if (event.keyCode === 27) { event.preventDefault(); event.stopPropagation(); this._onClose(); } } private _onReplaceKeydown(event: KeyboardEvent) { if (event.keyCode === 13) { event.preventDefault(); event.stopPropagation(); this.props.onReplaceCurrent(this.state.replaceText); } } private _executeSearch( goForward: boolean, searchText?: string, filterChanged = false ) { // execute search! let query; const input = searchText ? searchText : this.state.searchText; try { query = Private.parseQuery( input, this.props.overlayState.caseSensitive, this.props.overlayState.useRegex ); this.setState({ errorMessage: '' }); } catch (e) { this.setState({ errorMessage: e.message }); return; } if ( Private.regexEqual(this.props.overlayState.query, query) && !filterChanged ) { if (goForward) { this.props.onHightlightNext(); } else { this.props.onHighlightPrevious(); } return; } this.props.onStartQuery(query, this.state.filters); } private _onClose() { // Clean up and close widget. this.props.onEndSearch(); this._debouncedStartSearch.dispose(); } private _onReplaceToggled() { this.setState({ replaceEntryShown: !this.state.replaceEntryShown }); } private _onSearchInputFocus() { if (!this.state.searchInputFocused) { this.setState({ searchInputFocused: true }); } } private _onSearchInputBlur() { if (this.state.searchInputFocused) { this.setState({ searchInputFocused: false }); } } private _toggleSearchOutput() { this.setState( prevState => ({ ...prevState, filters: { ...prevState.filters, output: !prevState.filters.output } }), () => this._executeSearch(true, undefined, true) ); } private _toggleFiltersOpen() { this.setState(prevState => ({ filtersOpen: !prevState.filtersOpen })); } render() { const showReplace = !this.props.isReadOnly && this.state.replaceEntryShown; const showFilter = this.props.hasOutputs; const filterToggle = showFilter ? ( this._toggleFiltersOpen()} /> ) : null; const filter = showFilter ? ( ) : null; return [
{this.props.isReadOnly ? (
) : ( )} { this.props.onCaseSensitiveToggled(); this._executeSearch(true); }} onRegexToggled={() => { this.props.onRegexToggled(); this._executeSearch(true); }} onKeydown={(e: KeyboardEvent) => this._onSearchKeydown(e)} onChange={(e: React.ChangeEvent) => this._onSearchChange(e)} onInputFocus={this._onSearchInputFocus.bind(this)} onInputBlur={this._onSearchInputBlur.bind(this)} inputFocused={this.state.searchInputFocused} searchText={this.state.searchText} forceFocus={this.props.overlayState.forceFocus} /> this._executeSearch(false)} onHightlightNext={() => this._executeSearch(true)} /> {showReplace ? null : filterToggle}
,
{showReplace ? ( <> this._onReplaceKeydown(e)} onChange={(e: React.ChangeEvent) => this._onReplaceChange(e)} onReplaceCurrent={() => this.props.onReplaceCurrent(this.state.replaceText) } onReplaceAll={() => this.props.onReplaceAll(this.state.replaceText) } replaceText={this.state.replaceText} ref="replaceEntry" />
{filterToggle} ) : null}
, this.state.filtersOpen ? filter : null, ]; } private _debouncedStartSearch = new Debouncer(() => { this._executeSearch(true, this.state.searchText); }, 500); } export function createSearchOverlay( options: createSearchOverlay.IOptions ): Widget { const { widgetChanged, overlayState, onCaseSensitiveToggled, onRegexToggled, onHightlightNext, onHighlightPrevious, onStartQuery, onReplaceCurrent, onReplaceAll, onEndSearch, isReadOnly, hasOutputs } = options; const widget = ReactWidget.create( {(_, args) => { return ( ); }} ); widget.addClass(OVERLAY_CLASS); return widget; } namespace createSearchOverlay { export interface IOptions { widgetChanged: Signal; overlayState: IDisplayState; onCaseSensitiveToggled: Function; onRegexToggled: Function; onHightlightNext: Function; onHighlightPrevious: Function; onStartQuery: Function; onEndSearch: Function; onReplaceCurrent: Function; onReplaceAll: Function; isReadOnly: boolean; hasOutputs: boolean; } } namespace Private { export function parseQuery( queryString: string, caseSensitive: boolean, regex: boolean ) { const flag = caseSensitive ? 'g' : 'gi'; // escape regex characters in query if its a string search const queryText = regex ? queryString : queryString.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&'); let ret; ret = new RegExp(queryText, flag); if (ret.test('')) { ret = /x^/; } return ret; } export function regexEqual(a: RegExp | null, b: RegExp | null) { if (!a || !b) { return false; } return ( a.source === b.source && a.global === b.global && a.ignoreCase === b.ignoreCase && a.multiline === b.multiline ); } }