searchoverlay.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643
  1. // Copyright (c) Jupyter Development Team.
  2. // Distributed under the terms of the Modified BSD License.
  3. import { ReactWidget, UseSignal } from '@jupyterlab/apputils';
  4. import {
  5. caretDownEmptyThinIcon,
  6. caretUpEmptyThinIcon,
  7. caseSensitiveIcon,
  8. classes,
  9. closeIcon,
  10. ellipsesIcon,
  11. regexIcon
  12. } from '@jupyterlab/ui-components';
  13. import { Debouncer } from '@lumino/polling';
  14. import { Signal } from '@lumino/signaling';
  15. import { Widget } from '@lumino/widgets';
  16. import * as React from 'react';
  17. import { IDisplayState } from './interfaces';
  18. import { SearchInstance } from './searchinstance';
  19. const OVERLAY_CLASS = 'jp-DocumentSearch-overlay';
  20. const OVERLAY_ROW_CLASS = 'jp-DocumentSearch-overlay-row';
  21. const INPUT_CLASS = 'jp-DocumentSearch-input';
  22. const INPUT_WRAPPER_CLASS = 'jp-DocumentSearch-input-wrapper';
  23. const INPUT_BUTTON_CLASS_OFF = 'jp-DocumentSearch-input-button-off';
  24. const INPUT_BUTTON_CLASS_ON = 'jp-DocumentSearch-input-button-on';
  25. const INDEX_COUNTER_CLASS = 'jp-DocumentSearch-index-counter';
  26. const UP_DOWN_BUTTON_WRAPPER_CLASS = 'jp-DocumentSearch-up-down-wrapper';
  27. const UP_DOWN_BUTTON_CLASS = 'jp-DocumentSearch-up-down-button';
  28. const ELLIPSES_BUTTON_CLASS = 'jp-DocumentSearch-ellipses-button';
  29. const ELLIPSES_BUTTON_ENABLED_CLASS =
  30. 'jp-DocumentSearch-ellipses-button-enabled';
  31. const REGEX_ERROR_CLASS = 'jp-DocumentSearch-regex-error';
  32. const SEARCH_OPTIONS_CLASS = 'jp-DocumentSearch-search-options';
  33. const SEARCH_OPTIONS_DISABLED_CLASS =
  34. 'jp-DocumentSearch-search-options-disabled';
  35. const REPLACE_ENTRY_CLASS = 'jp-DocumentSearch-replace-entry';
  36. const REPLACE_BUTTON_CLASS = 'jp-DocumentSearch-replace-button';
  37. const REPLACE_BUTTON_WRAPPER_CLASS = 'jp-DocumentSearch-replace-button-wrapper';
  38. const REPLACE_WRAPPER_CLASS = 'jp-DocumentSearch-replace-wrapper-class';
  39. const REPLACE_TOGGLE_COLLAPSED = 'jp-DocumentSearch-replace-toggle-collapsed';
  40. const REPLACE_TOGGLE_EXPANDED = 'jp-DocumentSearch-replace-toggle-expanded';
  41. const FOCUSED_INPUT = 'jp-DocumentSearch-focused-input';
  42. const TOGGLE_WRAPPER = 'jp-DocumentSearch-toggle-wrapper';
  43. const TOGGLE_PLACEHOLDER = 'jp-DocumentSearch-toggle-placeholder';
  44. const BUTTON_CONTENT_CLASS = 'jp-DocumentSearch-button-content';
  45. const BUTTON_WRAPPER_CLASS = 'jp-DocumentSearch-button-wrapper';
  46. const SPACER_CLASS = 'jp-DocumentSearch-spacer';
  47. interface ISearchEntryProps {
  48. onCaseSensitiveToggled: Function;
  49. onRegexToggled: Function;
  50. onKeydown: Function;
  51. onChange: Function;
  52. onInputFocus: Function;
  53. onInputBlur: Function;
  54. inputFocused: boolean;
  55. caseSensitive: boolean;
  56. useRegex: boolean;
  57. searchText: string;
  58. forceFocus: boolean;
  59. }
  60. interface IReplaceEntryProps {
  61. onReplaceCurrent: Function;
  62. onReplaceAll: Function;
  63. onReplaceKeydown: Function;
  64. onChange: Function;
  65. replaceText: string;
  66. }
  67. class SearchEntry extends React.Component<ISearchEntryProps> {
  68. constructor(props: ISearchEntryProps) {
  69. super(props);
  70. }
  71. /**
  72. * Focus the input.
  73. */
  74. focusInput() {
  75. (this.refs.searchInputNode as HTMLInputElement).focus();
  76. }
  77. componentDidUpdate() {
  78. if (this.props.forceFocus) {
  79. this.focusInput();
  80. }
  81. }
  82. render() {
  83. const caseButtonToggleClass = classes(
  84. this.props.caseSensitive ? INPUT_BUTTON_CLASS_ON : INPUT_BUTTON_CLASS_OFF,
  85. BUTTON_CONTENT_CLASS
  86. );
  87. const regexButtonToggleClass = classes(
  88. this.props.useRegex ? INPUT_BUTTON_CLASS_ON : INPUT_BUTTON_CLASS_OFF,
  89. BUTTON_CONTENT_CLASS
  90. );
  91. const wrapperClass = `${INPUT_WRAPPER_CLASS} ${
  92. this.props.inputFocused ? FOCUSED_INPUT : ''
  93. }`;
  94. return (
  95. <div className={wrapperClass}>
  96. <input
  97. placeholder={this.props.searchText ? undefined : 'Find'}
  98. className={INPUT_CLASS}
  99. value={this.props.searchText}
  100. onChange={e => this.props.onChange(e)}
  101. onKeyDown={e => this.props.onKeydown(e)}
  102. tabIndex={2}
  103. onFocus={e => this.props.onInputFocus()}
  104. onBlur={e => this.props.onInputBlur()}
  105. ref="searchInputNode"
  106. />
  107. <button
  108. className={BUTTON_WRAPPER_CLASS}
  109. onClick={() => this.props.onCaseSensitiveToggled()}
  110. tabIndex={4}
  111. >
  112. <caseSensitiveIcon.react
  113. className={caseButtonToggleClass}
  114. tag="span"
  115. />
  116. </button>
  117. <button
  118. className={BUTTON_WRAPPER_CLASS}
  119. onClick={() => this.props.onRegexToggled()}
  120. tabIndex={5}
  121. >
  122. <regexIcon.react className={regexButtonToggleClass} tag="span" />
  123. </button>
  124. </div>
  125. );
  126. }
  127. }
  128. class ReplaceEntry extends React.Component<IReplaceEntryProps> {
  129. constructor(props: any) {
  130. super(props);
  131. }
  132. render() {
  133. return (
  134. <div className={REPLACE_WRAPPER_CLASS}>
  135. <input
  136. placeholder={this.props.replaceText ? undefined : 'Replace'}
  137. className={REPLACE_ENTRY_CLASS}
  138. value={this.props.replaceText}
  139. onKeyDown={e => this.props.onReplaceKeydown(e)}
  140. onChange={e => this.props.onChange(e)}
  141. tabIndex={3}
  142. ref="replaceInputNode"
  143. />
  144. <button
  145. className={REPLACE_BUTTON_WRAPPER_CLASS}
  146. onClick={() => this.props.onReplaceCurrent()}
  147. tabIndex={10}
  148. >
  149. <span
  150. className={`${REPLACE_BUTTON_CLASS} ${BUTTON_CONTENT_CLASS}`}
  151. tabIndex={-1}
  152. >
  153. Replace
  154. </span>
  155. </button>
  156. <button
  157. className={REPLACE_BUTTON_WRAPPER_CLASS}
  158. tabIndex={11}
  159. onClick={() => this.props.onReplaceAll()}
  160. >
  161. <span
  162. className={`${REPLACE_BUTTON_CLASS} ${BUTTON_CONTENT_CLASS}`}
  163. tabIndex={-1}
  164. >
  165. Replace All
  166. </span>
  167. </button>
  168. </div>
  169. );
  170. }
  171. }
  172. interface IUpDownProps {
  173. onHighlightPrevious: Function;
  174. onHightlightNext: Function;
  175. }
  176. function UpDownButtons(props: IUpDownProps) {
  177. return (
  178. <div className={UP_DOWN_BUTTON_WRAPPER_CLASS}>
  179. <button
  180. className={BUTTON_WRAPPER_CLASS}
  181. onClick={() => props.onHighlightPrevious()}
  182. tabIndex={6}
  183. >
  184. <caretUpEmptyThinIcon.react
  185. className={classes(UP_DOWN_BUTTON_CLASS, BUTTON_CONTENT_CLASS)}
  186. tag="span"
  187. />
  188. </button>
  189. <button
  190. className={BUTTON_WRAPPER_CLASS}
  191. onClick={() => props.onHightlightNext()}
  192. tabIndex={7}
  193. >
  194. <caretDownEmptyThinIcon.react
  195. className={classes(UP_DOWN_BUTTON_CLASS, BUTTON_CONTENT_CLASS)}
  196. tag="span"
  197. />
  198. </button>
  199. </div>
  200. );
  201. }
  202. interface ISearchIndexProps {
  203. currentIndex: number | null;
  204. totalMatches: number;
  205. }
  206. function SearchIndices(props: ISearchIndexProps) {
  207. return (
  208. <div className={INDEX_COUNTER_CLASS}>
  209. {props.totalMatches === 0
  210. ? '-/-'
  211. : `${props.currentIndex === null ? '-' : props.currentIndex + 1}/${
  212. props.totalMatches
  213. }`}
  214. </div>
  215. );
  216. }
  217. interface IFilterToggleProps {
  218. enabled: boolean;
  219. toggleEnabled: () => void;
  220. }
  221. interface IFilterToggleState {}
  222. class FilterToggle extends React.Component<
  223. IFilterToggleProps,
  224. IFilterToggleState
  225. > {
  226. render() {
  227. let className = `${ELLIPSES_BUTTON_CLASS} ${BUTTON_CONTENT_CLASS}`;
  228. if (this.props.enabled) {
  229. className = `${className} ${ELLIPSES_BUTTON_ENABLED_CLASS}`;
  230. }
  231. return (
  232. <button
  233. className={BUTTON_WRAPPER_CLASS}
  234. onClick={() => this.props.toggleEnabled()}
  235. tabIndex={8}
  236. >
  237. <ellipsesIcon.react
  238. className={className}
  239. tag="span"
  240. height="20px"
  241. width="20px"
  242. />
  243. </button>
  244. );
  245. }
  246. }
  247. interface IFilterSelectionProps {
  248. searchOutput: boolean;
  249. canToggleOutput: boolean;
  250. toggleOutput: () => void;
  251. }
  252. interface IFilterSelectionState {}
  253. class FilterSelection extends React.Component<
  254. IFilterSelectionProps,
  255. IFilterSelectionState
  256. > {
  257. render() {
  258. return (
  259. <label className={SEARCH_OPTIONS_CLASS}>
  260. <span
  261. className={
  262. this.props.canToggleOutput ? '' : SEARCH_OPTIONS_DISABLED_CLASS
  263. }
  264. >
  265. Search Cell Outputs
  266. </span>
  267. <input
  268. type="checkbox"
  269. disabled={!this.props.canToggleOutput}
  270. checked={this.props.searchOutput}
  271. onChange={this.props.toggleOutput}
  272. />
  273. </label>
  274. );
  275. }
  276. }
  277. interface ISearchOverlayProps {
  278. overlayState: IDisplayState;
  279. onCaseSensitiveToggled: Function;
  280. onRegexToggled: Function;
  281. onHightlightNext: Function;
  282. onHighlightPrevious: Function;
  283. onStartQuery: Function;
  284. onEndSearch: Function;
  285. onReplaceCurrent: Function;
  286. onReplaceAll: Function;
  287. isReadOnly: boolean;
  288. hasOutputs: boolean;
  289. }
  290. class SearchOverlay extends React.Component<
  291. ISearchOverlayProps,
  292. IDisplayState
  293. > {
  294. constructor(props: ISearchOverlayProps) {
  295. super(props);
  296. this.state = props.overlayState;
  297. this._toggleSearchOutput = this._toggleSearchOutput.bind(this);
  298. }
  299. componentDidMount() {
  300. if (this.state.searchText) {
  301. this._executeSearch(true, this.state.searchText);
  302. }
  303. }
  304. private _onSearchChange(event: React.ChangeEvent) {
  305. const searchText = (event.target as HTMLInputElement).value;
  306. this.setState({ searchText: searchText });
  307. void this._debouncedStartSearch.invoke();
  308. }
  309. private _onReplaceChange(event: React.ChangeEvent) {
  310. this.setState({ replaceText: (event.target as HTMLInputElement).value });
  311. }
  312. private _onSearchKeydown(event: KeyboardEvent) {
  313. if (event.keyCode === 13) {
  314. event.preventDefault();
  315. event.stopPropagation();
  316. this._executeSearch(!event.shiftKey);
  317. } else if (event.keyCode === 27) {
  318. event.preventDefault();
  319. event.stopPropagation();
  320. this._onClose();
  321. }
  322. }
  323. private _onReplaceKeydown(event: KeyboardEvent) {
  324. if (event.keyCode === 13) {
  325. event.preventDefault();
  326. event.stopPropagation();
  327. this.props.onReplaceCurrent(this.state.replaceText);
  328. }
  329. }
  330. private _executeSearch(
  331. goForward: boolean,
  332. searchText?: string,
  333. filterChanged = false
  334. ) {
  335. // execute search!
  336. let query;
  337. const input = searchText ? searchText : this.state.searchText;
  338. try {
  339. query = Private.parseQuery(
  340. input,
  341. this.props.overlayState.caseSensitive,
  342. this.props.overlayState.useRegex
  343. );
  344. this.setState({ errorMessage: '' });
  345. } catch (e) {
  346. this.setState({ errorMessage: e.message });
  347. return;
  348. }
  349. if (
  350. Private.regexEqual(this.props.overlayState.query, query) &&
  351. !filterChanged
  352. ) {
  353. if (goForward) {
  354. this.props.onHightlightNext();
  355. } else {
  356. this.props.onHighlightPrevious();
  357. }
  358. return;
  359. }
  360. this.props.onStartQuery(query, this.state.filters);
  361. }
  362. private _onClose() {
  363. // Clean up and close widget.
  364. this.props.onEndSearch();
  365. this._debouncedStartSearch.dispose();
  366. }
  367. private _onReplaceToggled() {
  368. this.setState({
  369. replaceEntryShown: !this.state.replaceEntryShown
  370. });
  371. }
  372. private _onSearchInputFocus() {
  373. if (!this.state.searchInputFocused) {
  374. this.setState({ searchInputFocused: true });
  375. }
  376. }
  377. private _onSearchInputBlur() {
  378. if (this.state.searchInputFocused) {
  379. this.setState({ searchInputFocused: false });
  380. }
  381. }
  382. private _toggleSearchOutput() {
  383. this.setState(
  384. prevState => ({
  385. ...prevState,
  386. filters: {
  387. ...prevState.filters,
  388. output: !prevState.filters.output
  389. }
  390. }),
  391. () => this._executeSearch(true, undefined, true)
  392. );
  393. }
  394. private _toggleFiltersOpen() {
  395. this.setState(prevState => ({
  396. filtersOpen: !prevState.filtersOpen
  397. }));
  398. }
  399. render() {
  400. const showReplace = !this.props.isReadOnly && this.state.replaceEntryShown;
  401. const showFilter = this.props.hasOutputs;
  402. const filterToggle = showFilter ? (
  403. <FilterToggle
  404. enabled={this.state.filtersOpen}
  405. toggleEnabled={() => this._toggleFiltersOpen()}
  406. />
  407. ) : null;
  408. const filter = showFilter ? (
  409. <FilterSelection
  410. key={'filter'}
  411. canToggleOutput={!showReplace}
  412. searchOutput={this.state.filters.output}
  413. toggleOutput={this._toggleSearchOutput}
  414. />
  415. ) : null;
  416. return [
  417. <div className={OVERLAY_ROW_CLASS} key={0}>
  418. {this.props.isReadOnly ? (
  419. <div className={TOGGLE_PLACEHOLDER} />
  420. ) : (
  421. <button
  422. className={TOGGLE_WRAPPER}
  423. onClick={() => this._onReplaceToggled()}
  424. tabIndex={1}
  425. >
  426. <span
  427. className={`${
  428. this.state.replaceEntryShown
  429. ? REPLACE_TOGGLE_EXPANDED
  430. : REPLACE_TOGGLE_COLLAPSED
  431. } ${BUTTON_CONTENT_CLASS}`}
  432. tabIndex={-1}
  433. />
  434. </button>
  435. )}
  436. <SearchEntry
  437. useRegex={this.props.overlayState.useRegex}
  438. caseSensitive={this.props.overlayState.caseSensitive}
  439. onCaseSensitiveToggled={() => {
  440. this.props.onCaseSensitiveToggled();
  441. this._executeSearch(true);
  442. }}
  443. onRegexToggled={() => {
  444. this.props.onRegexToggled();
  445. this._executeSearch(true);
  446. }}
  447. onKeydown={(e: KeyboardEvent) => this._onSearchKeydown(e)}
  448. onChange={(e: React.ChangeEvent) => this._onSearchChange(e)}
  449. onInputFocus={this._onSearchInputFocus.bind(this)}
  450. onInputBlur={this._onSearchInputBlur.bind(this)}
  451. inputFocused={this.state.searchInputFocused}
  452. searchText={this.state.searchText}
  453. forceFocus={this.props.overlayState.forceFocus}
  454. />
  455. <SearchIndices
  456. currentIndex={this.props.overlayState.currentIndex}
  457. totalMatches={this.props.overlayState.totalMatches}
  458. />
  459. <UpDownButtons
  460. onHighlightPrevious={() => this._executeSearch(false)}
  461. onHightlightNext={() => this._executeSearch(true)}
  462. />
  463. {showReplace ? null : filterToggle}
  464. <button
  465. className={BUTTON_WRAPPER_CLASS}
  466. onClick={() => this._onClose()}
  467. tabIndex={9}
  468. >
  469. <closeIcon.react
  470. className="jp-icon-hover"
  471. justify="center"
  472. height="20px"
  473. width="20px"
  474. />
  475. </button>
  476. </div>,
  477. <div className={OVERLAY_ROW_CLASS} key={1}>
  478. {showReplace ? (
  479. <>
  480. <ReplaceEntry
  481. onReplaceKeydown={(e: KeyboardEvent) => this._onReplaceKeydown(e)}
  482. onChange={(e: React.ChangeEvent) => this._onReplaceChange(e)}
  483. onReplaceCurrent={() =>
  484. this.props.onReplaceCurrent(this.state.replaceText)
  485. }
  486. onReplaceAll={() =>
  487. this.props.onReplaceAll(this.state.replaceText)
  488. }
  489. replaceText={this.state.replaceText}
  490. ref="replaceEntry"
  491. />
  492. <div className={SPACER_CLASS}></div>
  493. {filterToggle}
  494. </>
  495. ) : null}
  496. </div>,
  497. this.state.filtersOpen ? filter : null,
  498. <div
  499. className={REGEX_ERROR_CLASS}
  500. hidden={
  501. !!this.state.errorMessage && this.state.errorMessage.length === 0
  502. }
  503. key={3}
  504. >
  505. {this.state.errorMessage}
  506. </div>
  507. ];
  508. }
  509. private _debouncedStartSearch = new Debouncer(() => {
  510. this._executeSearch(true, this.state.searchText);
  511. }, 500);
  512. }
  513. export function createSearchOverlay(
  514. options: createSearchOverlay.IOptions
  515. ): Widget {
  516. const {
  517. widgetChanged,
  518. overlayState,
  519. onCaseSensitiveToggled,
  520. onRegexToggled,
  521. onHightlightNext,
  522. onHighlightPrevious,
  523. onStartQuery,
  524. onReplaceCurrent,
  525. onReplaceAll,
  526. onEndSearch,
  527. isReadOnly,
  528. hasOutputs
  529. } = options;
  530. const widget = ReactWidget.create(
  531. <UseSignal signal={widgetChanged} initialArgs={overlayState}>
  532. {(_, args) => {
  533. return (
  534. <SearchOverlay
  535. onCaseSensitiveToggled={onCaseSensitiveToggled}
  536. onRegexToggled={onRegexToggled}
  537. onHightlightNext={onHightlightNext}
  538. onHighlightPrevious={onHighlightPrevious}
  539. onStartQuery={onStartQuery}
  540. onEndSearch={onEndSearch}
  541. onReplaceCurrent={onReplaceCurrent}
  542. onReplaceAll={onReplaceAll}
  543. overlayState={args!}
  544. isReadOnly={isReadOnly}
  545. hasOutputs={hasOutputs}
  546. />
  547. );
  548. }}
  549. </UseSignal>
  550. );
  551. widget.addClass(OVERLAY_CLASS);
  552. return widget;
  553. }
  554. namespace createSearchOverlay {
  555. export interface IOptions {
  556. widgetChanged: Signal<SearchInstance, IDisplayState>;
  557. overlayState: IDisplayState;
  558. onCaseSensitiveToggled: Function;
  559. onRegexToggled: Function;
  560. onHightlightNext: Function;
  561. onHighlightPrevious: Function;
  562. onStartQuery: Function;
  563. onEndSearch: Function;
  564. onReplaceCurrent: Function;
  565. onReplaceAll: Function;
  566. isReadOnly: boolean;
  567. hasOutputs: boolean;
  568. }
  569. }
  570. namespace Private {
  571. export function parseQuery(
  572. queryString: string,
  573. caseSensitive: boolean,
  574. regex: boolean
  575. ) {
  576. const flag = caseSensitive ? 'g' : 'gi';
  577. // escape regex characters in query if its a string search
  578. const queryText = regex
  579. ? queryString
  580. : queryString.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&');
  581. let ret;
  582. ret = new RegExp(queryText, flag);
  583. if (ret.test('')) {
  584. ret = /x^/;
  585. }
  586. return ret;
  587. }
  588. export function regexEqual(a: RegExp | null, b: RegExp | null) {
  589. if (!a || !b) {
  590. return false;
  591. }
  592. return (
  593. a.source === b.source &&
  594. a.global === b.global &&
  595. a.ignoreCase === b.ignoreCase &&
  596. a.multiline === b.multiline
  597. );
  598. }
  599. }