Bläddra i källkod

new UI, styling

Andrew Schlaepfer 6 år sedan
förälder
incheckning
4f746d0520

+ 3 - 1
packages/documentsearch-extension/package.json

@@ -37,7 +37,9 @@
     "@phosphor/messaging": "^1.2.2",
     "@phosphor/signaling": "^1.2.2",
     "@phosphor/widgets": "^1.6.0",
-    "codemirror": "~5.42.0"
+    "codemirror": "~5.42.0",
+    "react": "~16.4.2",
+    "react-dom": "~16.4.2"
   },
   "devDependencies": {
     "rimraf": "~2.6.2",

+ 26 - 9
packages/documentsearch-extension/src/executor.ts

@@ -1,15 +1,13 @@
 import { SearchProviderRegistry } from './searchproviderregistry';
 import { ISearchMatch, ISearchProvider } from './index';
 
-import { ApplicationShell } from '@jupyterlab/application';
-
-import { ISignal } from '@phosphor/signaling';
+import { ISignal, Signal } from '@phosphor/signaling';
 import { Widget } from '@phosphor/widgets';
 
 export class Executor {
-  constructor(registry: SearchProviderRegistry, shell: ApplicationShell) {
+  constructor(registry: SearchProviderRegistry, activeWidget: Widget) {
     this._registry = registry;
-    this._shell = shell;
+    this._currentWidget = activeWidget;
   }
   startSearch(options: RegExp): Promise<ISearchMatch[]> {
     // TODO: figure out where to check if the options have changed
@@ -18,7 +16,6 @@ export class Executor {
     if (this._activeProvider) {
       cleanupPromise = this._activeProvider.endSearch();
     }
-    this._currentWidget = this._shell.currentWidget;
 
     const provider = this._registry.getProviderForWidget(this._currentWidget);
     if (!provider) {
@@ -28,6 +25,10 @@ export class Executor {
       return;
     }
     this._activeProvider = provider;
+    this._activeProvider.changed.connect(
+      this._onChanged,
+      this
+    );
     return cleanupPromise.then(() =>
       provider.startSearch(options, this._currentWidget)
     );
@@ -38,33 +39,49 @@ export class Executor {
       return Promise.resolve();
     }
     return this._activeProvider.endSearch().then(() => {
+      this._activeProvider.changed.disconnect(this._onChanged);
       this._activeProvider = undefined;
       this._currentWidget = undefined;
     });
   }
 
   highlightNext(): Promise<ISearchMatch> {
+    if (!this._activeProvider) {
+      return Promise.resolve(null);
+    }
     return this._activeProvider.highlightNext();
   }
 
   highlightPrevious(): Promise<ISearchMatch> {
+    if (!this._activeProvider) {
+      return Promise.resolve(null);
+    }
     return this._activeProvider.highlightPrevious();
   }
 
   get currentMatchIndex(): number {
+    if (!this._activeProvider) {
+      return 0;
+    }
     return this._activeProvider.currentMatchIndex;
   }
 
   get matches(): ISearchMatch[] {
+    if (!this._activeProvider) {
+      return [];
+    }
     return this._activeProvider.matches;
   }
 
-  get changed(): ISignal<ISearchProvider, void> {
-    return this._activeProvider.changed;
+  get changed(): ISignal<this, void> {
+    return this._changed;
   }
 
+  private _onChanged(): void {
+    this._changed.emit(null);
+  }
   private _registry: SearchProviderRegistry;
   private _activeProvider: ISearchProvider;
   private _currentWidget: Widget;
-  private _shell: ApplicationShell;
+  private _changed: Signal<this, void> = new Signal<this, void>(this);
 }

+ 185 - 51
packages/documentsearch-extension/src/index.ts

@@ -4,14 +4,19 @@
 |----------------------------------------------------------------------------*/
 import '../style/index.css';
 
-import { SearchBox } from './searchbox';
 import { Executor } from './executor';
 import { SearchProviderRegistry } from './searchproviderregistry';
 
-import { JupyterLab, JupyterLabPlugin } from '@jupyterlab/application';
+import {
+  JupyterLab,
+  JupyterLabPlugin,
+  ApplicationShell
+} from '@jupyterlab/application';
 import { ICommandPalette } from '@jupyterlab/apputils';
 
-import { ISignal } from '@phosphor/signaling';
+import { ISignal, Signal } from '@phosphor/signaling';
+import { createSearchOverlay } from './searchoverlay';
+import { Widget } from '@phosphor/widgets';
 
 export interface ISearchMatch {
   /**
@@ -93,6 +98,16 @@ export interface ISearchProvider {
   readonly currentMatchIndex: number;
 }
 
+export interface IDisplayUpdate {
+  currentIndex: number;
+  totalMatches: number;
+  caseSensitive: boolean;
+  useRegex: boolean;
+  inputText: string;
+  query: RegExp;
+  lastQuery: RegExp;
+}
+
 /**
  * Initialization data for the document-search extension.
  */
@@ -103,68 +118,41 @@ const extension: JupyterLabPlugin<void> = {
   activate: (app: JupyterLab, palette: ICommandPalette) => {
     // Create registry, retrieve all default providers
     const registry: SearchProviderRegistry = new SearchProviderRegistry();
-    const executor: Executor = new Executor(registry, app.shell);
-
-    // Create widget, attach to signals
-    const widget: SearchBox = new SearchBox();
-
-    const updateWidget = () => {
-      widget.totalMatches = executor.matches.length;
-      widget.currentIndex = executor.currentMatchIndex;
-    };
-
-    const startSearchFn = (_: any, searchOptions: any) => {
-      executor.startSearch(searchOptions).then(() => {
-        updateWidget();
-        executor.changed.connect(updateWidget);
-      });
-    };
-
-    const endSearchFn = () => {
-      executor.endSearch().then(() => {
-        widget.totalMatches = 0;
-        widget.currentIndex = 0;
-        executor.changed.disconnect(updateWidget);
-      });
-    };
-
-    const highlightNextFn = () => {
-      executor.highlightNext().then(updateWidget);
-    };
-
-    const highlightPreviousFn = () => {
-      executor.highlightPrevious().then(updateWidget);
-    };
-
-    // Default to just searching on the current widget, could eventually
-    // read a flag provided by the search box widget if we want to search something else
-    widget.startSearch.connect(startSearchFn);
-    widget.endSearch.connect(endSearchFn);
-    widget.highlightNext.connect(highlightNextFn);
-    widget.highlightPrevious.connect(highlightPreviousFn);
+    const activeSearches: Private.ActiveSearchMap = {};
 
     const startCommand: string = 'documentsearch:start';
     const nextCommand: string = 'documentsearch:highlightNext';
     const prevCommand: string = 'documentsearch:highlightPrevious';
     app.commands.addCommand(startCommand, {
       label: 'Search the open document',
-      execute: () => {
-        if (!widget.isAttached) {
-          // Attach the widget to the main work area if it's not there
-          app.shell.addToLeftArea(widget, { rank: 400 });
-        }
-        app.shell.activateById(widget.id);
-      }
+      execute: Private.onStartCommand.bind(
+        null,
+        app.shell,
+        registry,
+        activeSearches
+      )
     });
 
     app.commands.addCommand(nextCommand, {
       label: 'Search the open document',
-      execute: highlightNextFn
+      execute: Private.openBoxOrExecute.bind(
+        null,
+        app.shell,
+        registry,
+        activeSearches,
+        Private.onNextCommand
+      )
     });
 
     app.commands.addCommand(prevCommand, {
       label: 'Search the open document',
-      execute: highlightPreviousFn
+      execute: Private.openBoxOrExecute.bind(
+        null,
+        app.shell,
+        registry,
+        activeSearches,
+        Private.onPrevCommand
+      )
     });
 
     // Add the command to the palette.
@@ -172,4 +160,150 @@ const extension: JupyterLabPlugin<void> = {
   }
 };
 
+class SearchInstance {
+  constructor(currentWidget: Widget, registry: SearchProviderRegistry) {
+    this._widget = currentWidget;
+    this.initializeSearchAssets(registry);
+  }
+
+  get searchWidget() {
+    return this._searchWidget;
+  }
+
+  get executor() {
+    return this._executor;
+  }
+
+  updateIndices(): void {
+    this._displayState.totalMatches = this._executor.matches.length;
+    this._displayState.currentIndex = this._executor.currentMatchIndex;
+    this.updateDisplay();
+  }
+
+  private _widget: Widget;
+  private _displayState: IDisplayUpdate;
+  private _displayUpdateSignal: Signal<Executor, IDisplayUpdate>;
+  private _executor: Executor;
+  private _searchWidget: Widget;
+  private updateDisplay() {
+    this._displayUpdateSignal.emit(this._displayState);
+  }
+  private startSearch(query: RegExp) {
+    // save the last query (or set it to the current query if this is the first)
+    this._displayState.lastQuery = this._displayState.query || query;
+    this._displayState.query = query;
+    this._executor.startSearch(query).then(() => {
+      this.updateIndices();
+      // this signal should get injected when the widget is
+      // created and hooked up to react!
+      this._executor.changed.connect(
+        this.updateIndices,
+        this
+      );
+    });
+  }
+  private endSearch() {
+    this._executor.endSearch().then(() => {
+      // more cleanup probably
+      Signal.disconnectAll(this);
+      this._searchWidget.dispose();
+      this._executor.changed.disconnect(this.updateIndices, this);
+    });
+  }
+  private highlightNext() {
+    this._executor.highlightNext().then(this.updateIndices.bind(this));
+  }
+  private highlightPrevious() {
+    this._executor.highlightPrevious().then(this.updateIndices.bind(this));
+  }
+  private initializeSearchAssets(registry: SearchProviderRegistry) {
+    this._executor = new Executor(registry, this._widget);
+    this._displayUpdateSignal = new Signal<Executor, IDisplayUpdate>(
+      this._executor
+    );
+
+    this._displayState = {
+      currentIndex: 0,
+      totalMatches: 0,
+      caseSensitive: false,
+      useRegex: false,
+      inputText: '',
+      query: null,
+      lastQuery: null
+    };
+
+    const onCaseSensitiveToggled = () => {
+      this._displayState.caseSensitive = !this._displayState.caseSensitive;
+      this.updateDisplay();
+    };
+
+    const onRegexToggled = () => {
+      this._displayState.useRegex = !this._displayState.useRegex;
+      this.updateDisplay();
+    };
+
+    this._searchWidget = createSearchOverlay(
+      this._displayUpdateSignal,
+      this._displayState,
+      onCaseSensitiveToggled,
+      onRegexToggled,
+      this.highlightNext.bind(this),
+      this.highlightPrevious.bind(this),
+      this.startSearch.bind(this),
+      this.endSearch.bind(this)
+    );
+  }
+}
+
+namespace Private {
+  export type ActiveSearchMap = {
+    [key: string]: SearchInstance;
+  };
+
+  export function openBoxOrExecute(
+    shell: ApplicationShell,
+    registry: SearchProviderRegistry,
+    activeSearches: ActiveSearchMap,
+    command: Function
+  ): void {
+    const currentWidget = shell.currentWidget;
+    const instance = activeSearches[currentWidget.id];
+    if (instance) {
+      command(instance);
+    } else {
+      onStartCommand(shell, registry, activeSearches);
+    }
+  }
+
+  export function onStartCommand(
+    shell: ApplicationShell,
+    registry: SearchProviderRegistry,
+    activeSearches: ActiveSearchMap
+  ): void {
+    const currentWidget = shell.currentWidget;
+    const widgetId = currentWidget.id;
+    if (activeSearches[widgetId]) {
+      const searchWidget = activeSearches[widgetId].searchWidget;
+      // searchWidget.focusInput(); // focus WAS TODO
+      searchWidget;
+      return;
+    }
+    const searchInstance = new SearchInstance(currentWidget, registry);
+    activeSearches[widgetId] = searchInstance;
+
+    searchInstance.searchWidget.disposed.connect(() => {
+      activeSearches[widgetId] = undefined;
+    });
+    Widget.attach(searchInstance.searchWidget, currentWidget.node);
+  }
+
+  export function onNextCommand(instance: SearchInstance) {
+    instance.executor.highlightNext().then(() => instance.updateIndices());
+  }
+
+  export function onPrevCommand(instance: SearchInstance) {
+    instance.executor.highlightPrevious().then(() => instance.updateIndices());
+  }
+}
+
 export default extension;

+ 0 - 10
packages/documentsearch-extension/src/providers/codemirrorsearchprovider.ts

@@ -177,17 +177,7 @@ namespace Private {
         }
       };
 
-      // const localCursor = cm.cursorCoords(true, 'local');
-      // const pageCursor = cm.cursorCoords(true, 'page');
-      // const windowCursor = cm.cursorCoords(true, 'window');
-      // console.log('localCursor: ', localCursor);
-      // console.log('pageCursor: ', pageCursor);
-      // console.log('windowCursor: ', windowCursor);
-      // console.log('scroller element: ', cm.getScrollerElement());
       cm.setSelection(selRange);
-      // const scrollY = reverse ? pageCursor.top : pageCursor.bottom;
-      // console.log('------- scrolling to x, y: ', pageCursor.left, ', ', scrollY);
-      // cm.scrollTo(pageCursor.left, scrollY);
       cm.scrollIntoView(
         {
           from: fromPos,

+ 0 - 1
packages/documentsearch-extension/src/providers/notebooksearchprovider.ts

@@ -156,7 +156,6 @@ export class NotebookSearchProvider implements ISearchProvider {
   }
 
   private _restartSearch(query: RegExp, searchTarget: NotebookPanel) {
-    console.log('restarting search!');
     this.endSearch();
     this.startSearch(query, searchTarget).then(() =>
       this._changed.emit(undefined)

+ 0 - 242
packages/documentsearch-extension/src/searchbox.ts

@@ -1,242 +0,0 @@
-import { Widget } from '@phosphor/widgets';
-
-import { Message } from '@phosphor/messaging';
-
-import { ISignal, Signal } from '@phosphor/signaling';
-
-const DOCUMENT_SEARCH_CLASS = 'jp-DocumentSearch';
-const SEARCHBOX_CLASS = 'jp-DocumentSearch-searchbox';
-const WRAPPER_CLASS = 'jp-DocumentSearch-wrapper';
-const INPUT_CLASS = 'jp-DocumentSearch-input';
-const globalBtns: any = {};
-export class SearchBox extends Widget {
-  constructor() {
-    super({ node: Private.createNode(globalBtns) });
-    this._nextBtn = globalBtns.next;
-    this._prevBtn = globalBtns.prev;
-    this._trackerLabel = globalBtns.trackerLabel;
-    this._caseSensitive = globalBtns.caseSensitive;
-    this._regex = globalBtns.regex;
-
-    this.id = 'search-box';
-    this.title.iconClass = 'jp-ExtensionIcon jp-SideBar-tabIcon';
-    this.title.caption = 'Search document';
-    this.addClass(DOCUMENT_SEARCH_CLASS);
-  }
-
-  get inputNode(): HTMLInputElement {
-    return this.node.getElementsByClassName(INPUT_CLASS)[0] as HTMLInputElement;
-  }
-
-  get startSearch(): ISignal<this, RegExp> {
-    return this._startSearch;
-  }
-
-  get endSearch(): ISignal<this, void> {
-    return this._endSearch;
-  }
-
-  get highlightNext(): ISignal<this, void> {
-    return this._highlightNext;
-  }
-
-  get highlightPrevious(): ISignal<this, void> {
-    return this._highlightPrevious;
-  }
-
-  set totalMatches(num: number) {
-    this._totalMatches = num;
-    this.updateTracker();
-  }
-
-  set currentIndex(num: number) {
-    this._currentIndex = num;
-    this.updateTracker();
-  }
-
-  handleEvent(event: Event): void {
-    if (event.type === 'keydown') {
-      this._handleKeyDown(event as KeyboardEvent);
-    }
-    if (event.type === 'click') {
-      this._handleClick(event as MouseEvent);
-    }
-  }
-
-  protected onBeforeAttach(msg: Message): void {
-    // Add event listeners
-    this.node.addEventListener('keydown', this.handleEvent.bind(this));
-    this._nextBtn.addEventListener('click', this.handleEvent.bind(this));
-    this._prevBtn.addEventListener('click', this.handleEvent.bind(this));
-  }
-
-  protected onAfterAttach(msg: Message): void {
-    // Remove event listeners
-    this.node.removeEventListener('keydown', this);
-  }
-
-  private updateTracker(): void {
-    if (this._currentIndex === 0 && this._totalMatches === 0) {
-      this._trackerLabel.innerHTML = 'No results';
-      return;
-    }
-    this._trackerLabel.innerHTML = `${this._currentIndex + 1}/${
-      this._totalMatches
-    }`;
-  }
-
-  private _handleKeyDown(event: KeyboardEvent): void {
-    if (event.keyCode === 13) {
-      // execute search!
-      const query: RegExp = this.getCurrentQuery();
-      if (query.source.length === 0) {
-        return;
-      }
-
-      if (this.regexEqual(this._lastQuery, query)) {
-        this._highlightNext.emit(undefined);
-        return;
-      }
-
-      this._lastQuery = query;
-      this._startSearch.emit(query);
-    }
-  }
-
-  private _handleClick(event: MouseEvent) {
-    const query = this.getCurrentQuery();
-    if (!this.regexEqual(this._lastQuery, query)) {
-      if (query.source.length === 0) {
-        return;
-      }
-      this._lastQuery = query;
-      this._startSearch.emit(query);
-      return;
-    }
-    if (event.target === this._nextBtn) {
-      this._highlightNext.emit(undefined);
-    } else if (event.target === this._prevBtn) {
-      this._highlightPrevious.emit(undefined);
-    }
-  }
-
-  private getCurrentQuery(): RegExp {
-    return Private.parseQuery(
-      this.inputNode.value,
-      this._caseSensitive.checked,
-      this._regex.checked
-    );
-  }
-
-  private regexEqual(a: RegExp, b: RegExp) {
-    if (!a || !b) {
-      return false;
-    }
-    return (
-      a.source === b.source &&
-      a.global === b.global &&
-      a.ignoreCase === b.ignoreCase &&
-      a.multiline === b.multiline
-    );
-  }
-
-  private _startSearch = new Signal<this, RegExp>(this);
-  private _endSearch = new Signal<this, void>(this);
-  private _highlightNext = new Signal<this, void>(this);
-  private _highlightPrevious = new Signal<this, void>(this);
-  private _lastQuery: RegExp;
-  private _totalMatches: number = 0;
-  private _currentIndex: number = 0;
-
-  private _nextBtn: Element;
-  private _prevBtn: Element;
-  private _trackerLabel: Element;
-  private _caseSensitive: any;
-  private _regex: any;
-}
-
-namespace Private {
-  export function createNode(context: any) {
-    const node = document.createElement('div');
-    const search = document.createElement('div');
-    const wrapper = document.createElement('div');
-    const input = document.createElement('input');
-    const next = document.createElement('button');
-    const prev = document.createElement('button');
-    const caseSensitive = document.createElement('input');
-    const caseLabel = document.createElement('label');
-    const regex = document.createElement('input');
-    const regexLabel = document.createElement('label');
-    const trackerLabel = document.createElement('p');
-    const results = document.createElement('div');
-    const dummyText = document.createElement('p');
-
-    input.placeholder = 'SEARCH';
-    dummyText.innerHTML = 'Dummy result';
-    next.textContent = '>';
-    prev.textContent = '<';
-
-    caseSensitive.setAttribute('type', 'checkbox');
-    regex.setAttribute('type', 'checkbox');
-    caseLabel.innerHTML = 'case sensitive';
-    regexLabel.innerHTML = 'regex';
-
-    context.next = next;
-    context.prev = prev;
-    context.trackerLabel = trackerLabel;
-    context.caseSensitive = caseSensitive;
-    context.regex = regex;
-
-    search.className = SEARCHBOX_CLASS;
-    wrapper.className = WRAPPER_CLASS;
-    input.className = INPUT_CLASS;
-
-    search.appendChild(wrapper);
-    wrapper.appendChild(input);
-    wrapper.appendChild(prev);
-    wrapper.appendChild(next);
-    wrapper.appendChild(caseSensitive);
-    wrapper.appendChild(caseLabel);
-    wrapper.appendChild(regex);
-    wrapper.appendChild(regexLabel);
-    wrapper.appendChild(trackerLabel);
-    results.appendChild(dummyText);
-    node.appendChild(search);
-    node.appendChild(results);
-
-    return node;
-  }
-
-  export function parseString(str: string) {
-    return str.replace(/\\(.)/g, (_, ch) => {
-      if (ch === 'n') {
-        return '\n';
-      }
-      if (ch === 'r') {
-        return '\r';
-      }
-      return ch;
-    });
-  }
-
-  export function parseQuery(
-    queryString: string,
-    caseSensitive: boolean,
-    regex: boolean
-  ) {
-    const flag = caseSensitive ? 'g' : 'gi';
-    const queryText = regex
-      ? queryString
-      : queryString.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&');
-    let ret;
-    try {
-      ret = new RegExp(queryText, flag);
-    } catch (e) {
-      console.error('invalid regex: ', e);
-    }
-    if (ret.test('')) {
-      ret = /x^/;
-    }
-    return ret;
-  }
-}

+ 323 - 0
packages/documentsearch-extension/src/searchoverlay.tsx

@@ -0,0 +1,323 @@
+import * as React from 'react';
+
+import '../style/index.css';
+import { ReactWidget, UseSignal } from '@jupyterlab/apputils';
+import { Signal } from '@phosphor/signaling';
+import { Widget } from '@phosphor/widgets';
+import { Executor } from './executor';
+import { IDisplayUpdate } from '.';
+
+const OVERLAY_CLASS = 'jp-DocumentSearch-overlay';
+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-class';
+const CLOSE_BUTTON_CLASS = 'jp-DocumentSearch-close-button';
+
+interface ISearchEntryProps {
+  onCaseSensitiveToggled: Function;
+  onRegexToggled: Function;
+  onKeydown: Function;
+  onChange: Function;
+  caseSensitive: boolean;
+  useRegex: boolean;
+  inputText: string;
+}
+
+class SearchEntry extends React.Component<ISearchEntryProps> {
+  constructor(props: ISearchEntryProps) {
+    super(props);
+  }
+
+  componentDidMount() {
+    this.focusInput();
+  }
+
+  focusInput() {
+    (this.refs.searchInputNode as HTMLInputElement).focus();
+  }
+
+  render() {
+    const caseButtonToggleClass = this.props.caseSensitive
+      ? INPUT_BUTTON_CLASS_ON
+      : INPUT_BUTTON_CLASS_OFF;
+    const regexButtonToggleClass = this.props.useRegex
+      ? INPUT_BUTTON_CLASS_ON
+      : INPUT_BUTTON_CLASS_OFF;
+
+    return (
+      <div className={INPUT_WRAPPER_CLASS}>
+        <input
+          placeholder={this.props.inputText ? null : 'SEARCH'}
+          className={INPUT_CLASS}
+          value={this.props.inputText}
+          onChange={e => this.props.onChange(e)}
+          onKeyDown={e => this.props.onKeydown(e)}
+          ref="searchInputNode"
+        />
+        <button
+          className={`${caseButtonToggleClass}`}
+          onClick={() => this.props.onCaseSensitiveToggled()}
+        >
+          A<sup>a</sup>
+        </button>
+        <button
+          className={`${regexButtonToggleClass}`}
+          onClick={() => this.props.onRegexToggled()}
+        >
+          .*
+        </button>
+      </div>
+    );
+  }
+}
+
+interface IUpDownProps {
+  onHighlightPrevious: Function;
+  onHightlightNext: Function;
+}
+
+function UpDownButtons(props: IUpDownProps) {
+  return (
+    <div className={UP_DOWN_BUTTON_WRAPPER_CLASS}>
+      <button
+        className={UP_DOWN_BUTTON_CLASS}
+        onClick={() => props.onHighlightPrevious()}
+      >
+        ^
+      </button>
+      <button
+        className={UP_DOWN_BUTTON_CLASS}
+        onClick={() => props.onHightlightNext()}
+      >
+        v
+      </button>
+    </div>
+  );
+}
+
+interface ISearchIndexProps {
+  currentIndex: number;
+  totalMatches: number;
+}
+
+function SearchIndices(props: ISearchIndexProps) {
+  return (
+    <>
+      <label className={INDEX_COUNTER_CLASS}>
+        {props.totalMatches === 0
+          ? '-/-'
+          : `${props.currentIndex + 1}/${props.totalMatches}`}
+      </label>
+    </>
+  );
+}
+
+interface ISearchOverlayProps {
+  overlayState: IDisplayUpdate;
+  onCaseSensitiveToggled: Function;
+  onRegexToggled: Function;
+  onHightlightNext: Function;
+  onHighlightPrevious: Function;
+  onStartSearch: Function;
+  onEndSearch: Function;
+}
+
+class SearchOverlay extends React.Component<
+  ISearchOverlayProps,
+  IDisplayUpdate
+> {
+  constructor(props: ISearchOverlayProps) {
+    super(props);
+    this.state = props.overlayState;
+  }
+
+  private onChange(event: React.ChangeEvent) {
+    this.setState({ inputText: (event.target as HTMLInputElement).value });
+  }
+
+  private onKeydown(event: KeyboardEvent) {
+    if (event.keyCode !== 13) {
+      return;
+    }
+    // execute search!
+    const query: RegExp = Private.parseQuery(
+      this.state.inputText,
+      this.props.overlayState.caseSensitive,
+      this.props.overlayState.useRegex
+    );
+    if (!query) {
+      // display error! WAS TODO
+    }
+    if (query.source.length === 0) {
+      return;
+    }
+
+    if (Private.regexEqual(this.props.overlayState.lastQuery, query)) {
+      if (event.shiftKey) {
+        this.props.onHighlightPrevious();
+      } else {
+        this.props.onHightlightNext();
+      }
+      return;
+    }
+
+    this.props.onStartSearch(query);
+  }
+
+  private onClose() {
+    // clean up and close widget
+    this.props.onEndSearch();
+  }
+
+  render() {
+    return (
+      <div>
+        <SearchEntry
+          useRegex={this.props.overlayState.useRegex}
+          caseSensitive={this.props.overlayState.caseSensitive}
+          onCaseSensitiveToggled={() => this.props.onCaseSensitiveToggled()}
+          onRegexToggled={() => this.props.onRegexToggled()}
+          onKeydown={(e: KeyboardEvent) => this.onKeydown(e)}
+          onChange={(e: React.ChangeEvent) => this.onChange(e)}
+          inputText={this.state.inputText}
+        />
+        <SearchIndices
+          currentIndex={this.props.overlayState.currentIndex}
+          totalMatches={this.props.overlayState.totalMatches}
+        />
+        <UpDownButtons
+          onHighlightPrevious={() => this.props.onHighlightPrevious()}
+          onHightlightNext={() => this.props.onHightlightNext()}
+        />
+        <div className={CLOSE_BUTTON_CLASS} onClick={() => this.onClose()} />
+      </div>
+    );
+  }
+}
+
+export function createSearchOverlay(
+  wigdetChanged: Signal<Executor, IDisplayUpdate>,
+  overlayState: IDisplayUpdate,
+  onCaseSensitiveToggled: Function,
+  onRegexToggled: Function,
+  onHightlightNext: Function,
+  onHighlightPrevious: Function,
+  onStartSearch: Function,
+  onEndSearch: Function
+): Widget {
+  // const onKeydown = (event: KeyboardEvent) => {
+  //   if (event.keyCode === 13) {
+  //     // execute search!
+  //     const query: RegExp = Private.parseQuery(
+  //       overlayState.inputText,
+  //       overlayState.caseSensitive,
+  //       overlayState.useRegex
+  //     );
+  //     if (!query) {
+  //       // display error!
+  //     }
+  //     if (query.source.length === 0) {
+  //       return;
+  //     }
+
+  //     if (Private.regexEqual(overlayState.lastQuery, query)) {
+  //       if (event.shiftKey) {
+  //         onHighlightPrevious();
+  //       } else {
+  //         onHightlightNext();
+  //       }
+  //       return;
+  //     }
+
+  //     onStartSearch(query);
+  //   }
+  // };
+  // const onChange = (event: React.ChangeEvent) => {
+  //   overlayState.inputText = (event.target as HTMLInputElement).value;
+  //   wigdetChanged.emit(overlayState);
+  // };
+  // const onClose = () => {
+  //   // clean up and close widget
+  //   onEndSearch();
+  // };
+  const widget = ReactWidget.create(
+    <UseSignal signal={wigdetChanged} initialArgs={overlayState}>
+      {(sender, args) => {
+        return (
+          <SearchOverlay
+            onCaseSensitiveToggled={onCaseSensitiveToggled}
+            onRegexToggled={onRegexToggled}
+            onHightlightNext={onHightlightNext}
+            onHighlightPrevious={onHighlightPrevious}
+            onStartSearch={onStartSearch}
+            onEndSearch={onEndSearch}
+            overlayState={args}
+          />
+        );
+      }}
+    </UseSignal>
+  );
+  widget.addClass(OVERLAY_CLASS);
+  return widget;
+}
+/*
+<div>
+          <SearchEntry
+            useRegex={args.useRegex}
+            caseSensitive={args.caseSensitive}
+            onCaseSensitiveToggled={onCaseSensitiveToggled}
+            onRegexToggled={onRegexToggled}
+            onKeydown={onKeydown}
+            onChange={onChange}
+            inputText={args.inputText}
+          />
+          <SearchIndices
+            currentIndex={args.currentIndex}
+            totalMatches={args.totalMatches}
+          />
+          <UpDownButtons
+            onHighlightPrevious={onHighlightPrevious}
+            onHightlightNext={onHightlightNext}
+          />
+          <div className={CLOSE_BUTTON_CLASS} onClick={onClose} />
+        </div>
+*/
+namespace Private {
+  export function parseQuery(
+    queryString: string,
+    caseSensitive: boolean,
+    regex: boolean
+  ) {
+    const flag = caseSensitive ? 'g' : 'gi';
+    const queryText = regex
+      ? queryString
+      : queryString.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&');
+    let ret;
+    try {
+      ret = new RegExp(queryText, flag);
+    } catch (e) {
+      console.error('invalid regex: ', e);
+      return null;
+    }
+    if (ret.test('')) {
+      ret = /x^/;
+    }
+    return ret;
+  }
+
+  export function regexEqual(a: RegExp, b: RegExp) {
+    if (!a || !b) {
+      return false;
+    }
+    return (
+      a.source === b.source &&
+      a.global === b.global &&
+      a.ignoreCase === b.ignoreCase &&
+      a.multiline === b.multiline
+    );
+  }
+}

+ 18 - 11
packages/documentsearch-extension/src/searchproviderregistry.ts

@@ -7,19 +7,23 @@ import {
 const DEFAULT_NOTEBOOK_SEARCH_PROVIDER = 'jl-defaultNotebookSearchProvider';
 const DEFAULT_CODEMIRROR_SEARCH_PROVIDER = 'jl-defaultCodeMirrorSearchProvider';
 
+interface ISearchProviderConstructor {
+  new (): ISearchProvider;
+}
+
 export class SearchProviderRegistry {
   constructor() {
     this.registerDefaultProviders(
       DEFAULT_NOTEBOOK_SEARCH_PROVIDER,
-      new NotebookSearchProvider()
+      NotebookSearchProvider
     );
     this.registerDefaultProviders(
       DEFAULT_CODEMIRROR_SEARCH_PROVIDER,
-      new CodeMirrorSearchProvider()
+      CodeMirrorSearchProvider
     );
   }
 
-  registerProvider(key: string, provider: ISearchProvider): void {
+  registerProvider(key: string, provider: ISearchProviderConstructor): void {
     this._customProviders[key] = provider;
   }
 
@@ -40,7 +44,7 @@ export class SearchProviderRegistry {
 
   private registerDefaultProviders(
     key: string,
-    provider: ISearchProvider
+    provider: ISearchProviderConstructor
   ): void {
     this._defaultProviders[key] = provider;
   }
@@ -49,14 +53,17 @@ export class SearchProviderRegistry {
     providerMap: Private.ProviderMap,
     widget: any
   ): ISearchProvider {
-    const compatibleProvders = Object.keys(providerMap)
+    let providerInstance;
+    Object.keys(providerMap)
       .map(k => providerMap[k])
-      .filter((p: ISearchProvider) => p.canSearchOn(widget));
+      .forEach((providerConstructor: ISearchProviderConstructor) => {
+        const testInstance = new providerConstructor();
+        if (testInstance.canSearchOn(widget)) {
+          providerInstance = testInstance;
+        }
+      });
 
-    if (compatibleProvders.length !== 0) {
-      return compatibleProvders[0];
-    }
-    return null;
+    return providerInstance;
   }
 
   private _defaultProviders: Private.ProviderMap = {};
@@ -64,5 +71,5 @@ export class SearchProviderRegistry {
 }
 
 namespace Private {
-  export type ProviderMap = { [key: string]: ISearchProvider };
+  export type ProviderMap = { [key: string]: ISearchProviderConstructor };
 }

+ 80 - 19
packages/documentsearch-extension/style/index.css

@@ -1,27 +1,88 @@
-.jp-DocumentSearch {
-  display: flex;
-  flex-direction: column;
-  min-width: var(--jp-sidebar-min-width);
-  color: var(--jp-ui-font-color1);
-  background: var(--jp-layout-color1);
-  /* This is needed so that all font sizing of children done in ems is
-   * relative to this base size */
-  font-size: var(--jp-ui-font-size1);
+.jp-DocumentSearch-input {
+  border: none;
+  outline: none;
+  font-size: 16px;
 }
 
-.jp-DocumentSearch-searchbox {
-  padding: 8px;
+.jp-DocumentSearch-overlay {
+  position: absolute;
+  background-color: var(--jp-layout-color2);
+  border: var(--jp-border-width) solid var(--jp-border-color0);
+  border-radius: var(--jp-border-radius);
+  top: 0;
+  right: 0;
+  z-index: 5;
+  min-width: 340px;
+  padding: 3px;
 }
 
-.jp-DocumentSearch-wrapper {
-  padding: 4px 6px;
-  background: white;
-  border: 1px solid #e0e0e0;
+.jp-DocumentSearch-overlay * {
+  color: var(--jp-ui-font-color0);
 }
 
-.jp-DocumentSearch-input {
-  width: 100%;
+.jp-DocumentSearch-input-wrapper {
+  border: var(--jp-border-width) solid var(--jp-border-color0);
+  border-radius: var(--jp-border-radius);
+  display: inline-block;
+  background-color: var(--jp-layout-color0);
+}
+
+.jp-DocumentSearch-input-wrapper * {
+  background-color: var(--jp-layout-color0);
+}
+
+.jp-DocumentSearch-input-wrapper button {
+  outline: 0;
+  width: 25px;
+  height: 20px;
+}
+
+.jp-DocumentSearch-input-button:before {
+  display: block;
+  padding-top: 100%;
+}
+
+.jp-DocumentSearch-input-button-off {
   border: none;
-  outline: none;
-  font-size: 16px;
+}
+
+.jp-DocumentSearch-input-button-off:hover {
+  border: 1px solid var(--jp-inverse-layout-color3);
+}
+
+.jp-DocumentSearch-input-button-on {
+  border: 1px solid var(--jp-inverse-layout-color0);
+}
+
+.jp-DocumentSearch-index-counter {
+  padding-left: 5px;
+  user-select: none;
+}
+
+.jp-DocumentSearch-up-down-wrapper {
+  display: inline-block;
+}
+
+.jp-DocumentSearch-up-down-button-class {
+  outline: 0;
+  border: none;
+  width: 25px;
+  height: 20px;
+  background: none;
+}
+
+.jp-DocumentSearch-close-button {
+  display: inline-block;
+  margin-left: 4px;
+  background-size: 16px;
+  height: 16px;
+  width: 16px;
+  background-image: var(--jp-icon-close);
+  background-position: center;
+  background-repeat: no-repeat;
+}
+
+.jp-DocumentSearch-close-button:hover {
+  background-size: 16px;
+  background-image: var(--jp-icon-close-circle);
 }