diff --git a/packages/documentsearch/package.json b/packages/documentsearch/package.json index 10e6bc7abcf9..840ca13fbd15 100644 --- a/packages/documentsearch/package.json +++ b/packages/documentsearch/package.json @@ -38,6 +38,7 @@ "@jupyterlab/codemirror": "^2.0.0-alpha.4", "@jupyterlab/fileeditor": "^2.0.0-alpha.4", "@jupyterlab/notebook": "^2.0.0-alpha.4", + "@lumino/algorithm": "^1.2.1", "@lumino/coreutils": "^1.4.0", "@lumino/disposable": "^1.3.2", "@lumino/polling": "^1.0.1", diff --git a/packages/documentsearch/src/interfaces.ts b/packages/documentsearch/src/interfaces.ts index be54e0494087..6c8714e2fc3c 100644 --- a/packages/documentsearch/src/interfaces.ts +++ b/packages/documentsearch/src/interfaces.ts @@ -4,6 +4,10 @@ import { ISignal } from '@lumino/signaling'; import { Widget } from '@lumino/widgets'; +export interface IFiltersType { + output: boolean; +} + export interface IDisplayState { /** * The index of the currently selected match @@ -64,6 +68,16 @@ export interface IDisplayState { * Whether or not the replace entry row is visible */ replaceEntryShown: boolean; + + /** + * What should we include when we search? + */ + filters: IFiltersType; + + /** + * Is the filters view open? + */ + filtersOpen: boolean; } export interface ISearchMatch { @@ -123,10 +137,15 @@ export interface ISearchProvider { * * @param query A RegExp to be use to perform the search * @param searchTarget The widget to be searched + * @param filters Filter parameters to pass to provider * * @returns A promise that resolves with a list of all matches */ - startQuery(query: RegExp, searchTarget: T): Promise; + startQuery( + query: RegExp, + searchTarget: T, + filters: IFiltersType + ): Promise; /** * Clears state of a search provider to prepare for startQuery to be called @@ -194,4 +213,10 @@ export interface ISearchProvider { * the replace option. */ readonly isReadOnly: boolean; + + /** + * Set to true if the widget under search has outputs to search. + * Defaults to false. + */ + readonly hasOutputs?: boolean; } diff --git a/packages/documentsearch/src/providers/codemirrorsearchprovider.ts b/packages/documentsearch/src/providers/codemirrorsearchprovider.ts index 8664e29fe717..7887be7a1290 100644 --- a/packages/documentsearch/src/providers/codemirrorsearchprovider.ts +++ b/packages/documentsearch/src/providers/codemirrorsearchprovider.ts @@ -69,12 +69,14 @@ export class CodeMirrorSearchProvider * * @param query A RegExp to be use to perform the search * @param searchTarget The widget to be searched + * @param [filters={}] Filter parameters to pass to provider * * @returns A promise that resolves with a list of all matches */ async startQuery( query: RegExp, - searchTarget: Widget + searchTarget: Widget, + filters = {} ): Promise { if (!CodeMirrorSearchProvider.canSearchOn(searchTarget)) { throw new Error('Cannot find Codemirror instance to search'); @@ -298,6 +300,9 @@ export class CodeMirrorSearchProvider return undefined; } + get editor(): CodeMirrorEditor { + return this._cm; + } /** * Set whether or not the CodemirrorSearchProvider will wrap to the beginning * or end of the document on invocations of highlightNext or highlightPrevious, respectively diff --git a/packages/documentsearch/src/providers/genericsearchprovider.ts b/packages/documentsearch/src/providers/genericsearchprovider.ts new file mode 100644 index 000000000000..a176c8e41a56 --- /dev/null +++ b/packages/documentsearch/src/providers/genericsearchprovider.ts @@ -0,0 +1,427 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { ISearchProvider, ISearchMatch } from '../interfaces'; + +import { ISignal, Signal } from '@lumino/signaling'; +import { Widget } from '@lumino/widgets'; + +const FOUND_CLASSES = ['cm-string', 'cm-overlay', 'cm-searching']; +const SELECTED_CLASSES = ['CodeMirror-selectedtext']; + +export class GenericSearchProvider implements ISearchProvider { + /** + * We choose opt out as most node types should be searched (e.g. script). + * Even nodes like , could have innerText we care about. + * + * Note: nodeName is capitalized, so we do the same here + */ + static UNSUPPORTED_ELEMENTS = { + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element#Document_metadata + BASE: true, + HEAD: true, + LINK: true, + META: true, + STYLE: true, + TITLE: true, + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element#Sectioning_root + BODY: true, + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element#Content_sectioning + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element#Text_content + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element#Inline_text_semantics + // Above is searched + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element#Image_and_multimedia + AREA: true, + AUDIO: true, + IMG: true, + MAP: true, + TRACK: true, + VIDEO: true, + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element#Embedded_content + APPLET: true, + EMBED: true, + IFRAME: true, + NOEMBED: true, + OBJECT: true, + PARAM: true, + PICTURE: true, + SOURCE: true, + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element#Scripting + CANVAS: true, + NOSCRIPT: true, + SCRIPT: true, + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element#Demarcating_edits + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element#Table_content + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element#Forms + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element#Interactive_elements + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element#Web_Components + // Above is searched + // Other: + SVG: true + }; + + /** + * Get an initial query value if applicable so that it can be entered + * into the search box as an initial query + * + * @returns Initial value used to populate the search box. + */ + getInitialQuery(searchTarget: Widget): any { + return ''; + } + + /** + * Initialize the search using the provided options. Should update the UI + * to highlight all matches and "select" whatever the first match should be. + * + * @param query A RegExp to be use to perform the search + * @param searchTarget The widget to be searched + * @param [filters={}] Filter parameters to pass to provider + * + * @returns A promise that resolves with a list of all matches + */ + async startQuery( + query: RegExp, + searchTarget: Widget, + filters = {} + ): Promise { + const that = this; + // No point in removing overlay in the middle of the search + await this.endQuery(false); + + this._widget = searchTarget; + this._query = query; + this._mutationObserver.disconnect(); + + const matches: IGenericSearchMatch[] = []; + const walker = document.createTreeWalker( + this._widget.node, + NodeFilter.SHOW_TEXT, + { + acceptNode: node => { + // Filter subtrees of UNSUPPORTED_ELEMENTS and nodes that + // do not contain our search text + let parentElement = node.parentElement!; + while (parentElement !== this._widget.node) { + if ( + parentElement.nodeName in + GenericSearchProvider.UNSUPPORTED_ELEMENTS + ) { + return NodeFilter.FILTER_REJECT; + } + parentElement = parentElement.parentElement!; + } + return that._query.test(node.textContent!) + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT; + } + }, + false + ); + const nodes: (Node | null)[] = []; + const originalNodes: Node[] = []; + // We MUST gather nodes first, otherwise the updates below will find each result twice + let node = walker.nextNode(); + while (node) { + nodes.push(node); + /* We store them here as we want to avoid saving a modified one + * This happens with something like this:
Hello world
and looking for o + * The o in world is found after the o in hello which means the pre could have been modified already + * While there may be a better data structure to do this for performance, this was easy to reason about. + */ + originalNodes.push(node.parentElement!.cloneNode(true)); + node = walker.nextNode(); + } + // We'll need to copy the regexp to ensure its 'g' and that we start the index count from 0 + const flags = + this._query.flags.indexOf('g') === -1 ? query.flags + 'g' : query.flags; + + nodes.forEach((node, nodeIndex) => { + const q = new RegExp(query.source, flags); + const subsections = []; + let match = q.exec(node!.textContent!); + while (match) { + subsections.push({ + start: match.index, + end: match.index + match[0].length, + text: match[0] + }); + match = q.exec(node!.textContent!); + } + const originalNode = originalNodes[nodeIndex]; + const originalLength = node!.textContent!.length; // Node length will change below + let lastNodeAdded = null; + // Go backwards as index may change if we go forwards + let newMatches = []; + for (let idx = subsections.length - 1; idx >= 0; --idx) { + const { start, end, text } = subsections[idx]; + // TODO: support tspan for svg when svg support is added + const spannedNode = document.createElement('span'); + spannedNode.classList.add(...FOUND_CLASSES); + spannedNode.innerText = text; + // Splice the text out before we add it back in with a span + node!.textContent = `${node!.textContent!.slice( + 0, + start + )}${node!.textContent!.slice(end)}`; + // Are we replacing from the start? + if (start === 0) { + node!.parentNode!.prepend(spannedNode); + // Are we replacing at the end? + } else if (end === originalLength) { + node!.parentNode!.append(spannedNode); + // Are the two results are adjacent to each other? + } else if (lastNodeAdded && end === subsections[idx + 1].start) { + node!.parentNode!.insertBefore(spannedNode, lastNodeAdded); + // Ok, we are replacing somewhere in the middle + } else { + // We know this is Text as we filtered for this in the walker above + const endText = (node as Text).splitText(start); + node!.parentNode!.insertBefore(spannedNode, endText); + } + lastNodeAdded = spannedNode; + newMatches.unshift({ + text, + fragment: '', + line: 0, + column: 0, + index: -1, // We set this later to ensure we get order correct + // GenericSearchFields + matchesIndex: -1, + indexInOriginal: idx, + spanElement: spannedNode, + originalNode + }); + } + matches.push(...newMatches); + }); + matches.forEach((match, idx) => { + // This may be changed when this is a subprovider :/ + match.index = idx; + // @ts-ignore + match.matchesIndex = idx; + }); + if (!this.isSubProvider && matches.length > 0) { + this._currentMatch = matches[0]; + } + // Watch for future changes: + this._mutationObserver.observe( + this._widget.node, + // https://developer.mozilla.org/en-US/docs/Web/API/MutationObserverInit + { + attributes: false, + characterData: true, + childList: true, + subtree: true + } + ); + + this._matches = matches; + return this._matches; + } + + refreshOverlay() { + // We don't have an overlay, we are directly changing the DOM + } + + /** + * Clears state of a search provider to prepare for startQuery to be called + * in order to start a new query or refresh an existing one. + * + * @returns A promise that resolves when the search provider is ready to + * begin a new search. + */ + async endQuery(removeOverlay = true): Promise { + this._matches.forEach(match => { + // We already took care of this parent with another match + if (match.indexInOriginal !== 0) { + return; + } + match.spanElement.parentElement!.replaceWith(match.originalNode); + }); + this._matches = []; + this._currentMatch = null; + this._mutationObserver.disconnect(); + } + + /** + * Resets UI state, removes all matches. + * + * @returns A promise that resolves when all state has been cleaned up. + */ + async endSearch(): Promise { + return this.endQuery(); + } + + /** + * Move the current match indicator to the next match. + * + * @returns A promise that resolves once the action has completed. + */ + async highlightNext(): Promise { + return this._highlightNext(false); + } + + /** + * Move the current match indicator to the previous match. + * + * @returns A promise that resolves once the action has completed. + */ + async highlightPrevious(): Promise { + return this._highlightNext(true); + } + + private _highlightNext(reverse: boolean): ISearchMatch | undefined { + if (this._matches.length === 0) { + return undefined; + } + if (!this._currentMatch) { + this._currentMatch = reverse + ? this._matches[this.matches.length - 1] + : this._matches[0]; + } else { + this._currentMatch.spanElement.classList.remove(...SELECTED_CLASSES); + + let nextIndex = reverse + ? this._currentMatch.matchesIndex - 1 + : this._currentMatch.matchesIndex + 1; + // When we are a subprovider, don't loop + if (this.isSubProvider) { + if (nextIndex < 0 || nextIndex >= this._matches.length) { + this._currentMatch = null; + return undefined; + } + } + // Cheap way to make this a circular buffer + nextIndex = (nextIndex + this._matches.length) % this._matches.length; + this._currentMatch = this._matches[nextIndex]; + } + if (this._currentMatch) { + this._currentMatch.spanElement.classList.add(...SELECTED_CLASSES); + // If not in view, scroll just enough to see it + if (!elementInViewport(this._currentMatch.spanElement)) { + this._currentMatch.spanElement.scrollIntoView(reverse); + } + this._currentMatch.spanElement.focus(); + } + return this._currentMatch; + } + + /** + * Replace the currently selected match with the provided text + * + * @returns A promise that resolves with a boolean indicating whether a replace occurred. + */ + async replaceCurrentMatch(newText: string): Promise { + return Promise.resolve(false); + } + + /** + * Replace all matches in the notebook with the provided text + * + * @returns A promise that resolves with a boolean indicating whether a replace occurred. + */ + async replaceAllMatches(newText: string): Promise { + // This is read only, but we could loosen this in theory for input boxes... + return Promise.resolve(false); + } + + /** + * Report whether or not this provider has the ability to search on the given object + */ + static canSearchOn(domain: Widget) { + return domain instanceof Widget; + } + + /** + * The same list of matches provided by the startQuery promise resolution + */ + get matches(): ISearchMatch[] { + // Ensure that no other fn can overwrite matches index property + // We shallow clone each node + return this._matches + ? this._matches.map(m => Object.assign({}, m)) + : this._matches; + } + + /** + * Signal indicating that something in the search has changed, so the UI should update + */ + get changed(): ISignal { + return this._changed; + } + + /** + * The current index of the selected match. + */ + get currentMatchIndex(): number | null { + if (!this._currentMatch) { + return null; + } + return this._currentMatch.index; + } + + get currentMatch(): ISearchMatch | null { + return this._currentMatch; + } + + /** + * Set to true if the widget under search is read-only, false + * if it is editable. Will be used to determine whether to show + * the replace option. + */ + readonly isReadOnly = true; + + clearSelection(): void { + return; + } + + /** + * Set whether or not this will wrap to the beginning + * or end of the document on invocations of highlightNext or highlightPrevious, respectively + */ + isSubProvider = false; + + private async _onWidgetChanged( + mutations: MutationRecord[], + observer: MutationObserver + ) { + // This is typically cheap, but we do not control the rate of change or size of the output + await this.startQuery(this._query, this._widget); + this._changed.emit(undefined); + } + + private _query: RegExp; + private _widget: Widget; + private _currentMatch: IGenericSearchMatch | null; + private _matches: IGenericSearchMatch[] = []; + private _mutationObserver: MutationObserver = new MutationObserver( + this._onWidgetChanged.bind(this) + ); + private _changed = new Signal(this); +} + +interface IGenericSearchMatch extends ISearchMatch { + readonly originalNode: Node; + readonly spanElement: HTMLElement; + /* + * Index among spans within the same originalElement + */ + readonly indexInOriginal: number; + /** + * Index in the matches array + */ + readonly matchesIndex: number; +} + +function elementInViewport(el: HTMLElement): boolean { + const boundingClientRect = el.getBoundingClientRect(); + return ( + boundingClientRect.top >= 0 && + boundingClientRect.bottom <= + (window.innerHeight || document.documentElement.clientHeight) && + boundingClientRect.left >= 0 && + boundingClientRect.right <= + (window.innerWidth || document.documentElement.clientWidth) + ); +} diff --git a/packages/documentsearch/src/providers/notebooksearchprovider.ts b/packages/documentsearch/src/providers/notebooksearchprovider.ts index 07308f7761b4..7a7b85ef2b34 100644 --- a/packages/documentsearch/src/providers/notebooksearchprovider.ts +++ b/packages/documentsearch/src/providers/notebooksearchprovider.ts @@ -2,11 +2,13 @@ // Distributed under the terms of the Modified BSD License. import { ISearchProvider, ISearchMatch } from '../index'; import { CodeMirrorSearchProvider } from './codemirrorsearchprovider'; +import { GenericSearchProvider } from './genericsearchprovider'; -import { NotebookPanel } from '@jupyterlab/notebook'; +import { Cell, MarkdownCell, CodeCell } from '@jupyterlab/cells'; import { CodeMirrorEditor } from '@jupyterlab/codemirror'; -import { Cell, MarkdownCell } from '@jupyterlab/cells'; +import { NotebookPanel } from '@jupyterlab/notebook'; +import { ArrayExt } from '@lumino/algorithm'; import { Signal, ISignal } from '@lumino/signaling'; import { Widget } from '@lumino/widgets'; @@ -14,7 +16,14 @@ import CodeMirror from 'codemirror'; interface ICellSearchPair { cell: Cell; - provider: CodeMirrorSearchProvider; + provider: CodeMirrorSearchProvider | GenericSearchProvider; +} + +export interface INotebookFilters { + /** + * Should cell output be searched? + */ + output: boolean; } export class NotebookSearchProvider implements ISearchProvider { @@ -39,17 +48,23 @@ export class NotebookSearchProvider implements ISearchProvider { * * @param query A RegExp to be use to perform the search * @param searchTarget The widget to be searched + * @param filters Filter parameters to pass to provider * * @returns A promise that resolves with a list of all matches */ async startQuery( query: RegExp, - searchTarget: NotebookPanel + searchTarget: NotebookPanel, + filters: INotebookFilters | undefined ): Promise { this._searchTarget = searchTarget; const cells = this._searchTarget.content.widgets; this._query = query; + this._filters = + !filters || Object.entries(filters).length === 0 + ? { output: true } + : filters; // Listen for cell model change to redo the search in case of // new/pasted/deleted cells const cellList = this._searchTarget.model!.cells; @@ -109,14 +124,36 @@ export class NotebookSearchProvider implements ISearchProvider { indexTotal += matchesFromCell.length; // search has been initialized, connect the changed signal - cmSearchProvider.changed.connect(this._onCmSearchProviderChanged, this); + cmSearchProvider.changed.connect(this._onSearchProviderChanged, this); allMatches.concat(matchesFromCell); - this._cmSearchProviders.push({ + this._searchProviders.push({ cell: cell, provider: cmSearchProvider }); + + if (cell instanceof CodeCell && this._filters.output) { + const outputProivder = new GenericSearchProvider(); + outputProivder.isSubProvider = true; + let matchesFromOutput = await outputProivder.startQuery( + query, + cell.outputArea + ); + matchesFromOutput.map(match => { + match.index = match.index + indexTotal; + }); + indexTotal += matchesFromOutput.length; + + allMatches.concat(matchesFromOutput); + + outputProivder.changed.connect(this._onSearchProviderChanged, this); + + this._searchProviders.push({ + cell: cell, + provider: outputProivder + }); + } } // show the widget again, recalculation of layout will matter again @@ -124,7 +161,7 @@ export class NotebookSearchProvider implements ISearchProvider { this._searchTarget.show(); this._currentMatch = await this._stepNext( - this._searchTarget.content.activeCell! + this._updatedCurrentProvider(false)! ); this._refreshCurrentCellEditor(); @@ -170,13 +207,14 @@ export class NotebookSearchProvider implements ISearchProvider { this._searchTarget!.hide(); const queriesEnded: Promise[] = []; - this._cmSearchProviders.forEach(({ provider }) => { + this._searchProviders.forEach(({ provider }) => { queriesEnded.push(provider.endQuery()); - provider.changed.disconnect(this._onCmSearchProviderChanged, this); + provider.changed.disconnect(this._onSearchProviderChanged, this); }); Signal.disconnectBetween(this._searchTarget!.model!.cells, this); - this._cmSearchProviders = []; + this._searchProviders = []; + this._currentProvider = null; this._unRenderedMarkdownCells.forEach((cell: MarkdownCell) => { // Guard against the case where markdown cells have been deleted if (!cell.isDisposed) { @@ -208,12 +246,13 @@ export class NotebookSearchProvider implements ISearchProvider { const index = this._searchTarget!.content.activeCellIndex; const searchEnded: Promise[] = []; - this._cmSearchProviders.forEach(({ provider }) => { + this._searchProviders.forEach(({ provider }) => { searchEnded.push(provider.endSearch()); - provider.changed.disconnect(this._onCmSearchProviderChanged, this); + provider.changed.disconnect(this._onSearchProviderChanged, this); }); - this._cmSearchProviders = []; + this._searchProviders = []; + this._currentProvider = null; this._unRenderedMarkdownCells.forEach((cell: MarkdownCell) => { cell.rendered = true; }); @@ -243,7 +282,7 @@ export class NotebookSearchProvider implements ISearchProvider { */ async highlightNext(): Promise { this._currentMatch = await this._stepNext( - this._searchTarget!.content.activeCell! + this._updatedCurrentProvider(false)! ); return this._currentMatch; } @@ -255,7 +294,7 @@ export class NotebookSearchProvider implements ISearchProvider { */ async highlightPrevious(): Promise { this._currentMatch = await this._stepNext( - this._searchTarget!.content.activeCell!, + this._updatedCurrentProvider(true)!, true ); return this._currentMatch; @@ -271,8 +310,7 @@ export class NotebookSearchProvider implements ISearchProvider { const editor = notebook.activeCell!.editor as CodeMirrorEditor; let replaceOccurred = false; if (this._currentMatchIsSelected(editor)) { - const cellIndex = notebook.widgets.indexOf(notebook.activeCell!); - const { provider } = this._cmSearchProviders[cellIndex]; + const { provider } = this._currentProvider!; replaceOccurred = await provider.replaceCurrentMatch(newText); if (replaceOccurred) { this._currentMatch = provider.currentMatch; @@ -294,8 +332,8 @@ export class NotebookSearchProvider implements ISearchProvider { */ async replaceAllMatches(newText: string): Promise { let replaceOccurred = false; - for (let index in this._cmSearchProviders) { - const { provider } = this._cmSearchProviders[index]; + for (let index in this._searchProviders) { + const { provider } = this._searchProviders[index]; const singleReplaceOccurred = await provider.replaceAllMatches(newText); replaceOccurred = singleReplaceOccurred ? true : replaceOccurred; } @@ -313,7 +351,7 @@ export class NotebookSearchProvider implements ISearchProvider { } /** - * The same list of matches provided by the startQuery promise resoluton + * The same list of matches provided by the startQuery promise resolution */ get matches(): ISearchMatch[] { return ([] as ISearchMatch[]).concat(...this._getMatchesFromCells()); @@ -343,60 +381,96 @@ export class NotebookSearchProvider implements ISearchProvider { */ readonly isReadOnly = false; + readonly hasOutputs = true; + + private _updatedCurrentProvider(reverse: boolean) { + if ( + this._currentProvider && + this._currentProvider.cell === this._searchTarget!.content.activeCell + ) { + return this._currentProvider; + } + let provider; + if (!this._currentProvider) { + const find = reverse ? ArrayExt.findLastValue : ArrayExt.findFirstValue; + provider = find( + this._searchProviders, + provider => this._searchTarget!.content.activeCell === provider.cell + ); + } else { + const currentProviderIndex = ArrayExt.firstIndexOf( + this._searchProviders, + this._currentProvider + ); + const nextProviderIndex = + ((reverse ? currentProviderIndex - 1 : currentProviderIndex + 1) + + this._searchProviders.length) % + this._searchProviders.length; + provider = this._searchProviders[nextProviderIndex]; + } + this._currentProvider = provider; + return provider; + } + private async _stepNext( - activeCell: Cell, + currentSearchPair: ICellSearchPair, reverse = false, steps = 0 ): Promise { - const notebook = this._searchTarget!.content; - const cellIndex = notebook.widgets.indexOf(activeCell); - const numCells = notebook.widgets.length; - const { provider } = this._cmSearchProviders[cellIndex]; + const { provider } = currentSearchPair; // highlightNext/Previous will not be able to search rendered MarkdownCells or // hidden code cells, but that is okay here because in startQuery, we unrendered - // all cells with matches and unhid all cells + // all cells with matches and unhide all cells const match = reverse ? await provider.highlightPrevious() : await provider.highlightNext(); // If there was no match in this cell, try the next cell if (!match) { + const providerIndex = this._searchProviders.indexOf(currentSearchPair); + const numProviders = this._searchProviders.length; // We have looped around the whole notebook and have searched the original // cell once more and found no matches. Do not proceed with incrementing the // active cell index so that the active cell doesn't change - if (steps === numCells) { + if (steps === numProviders) { return undefined; } const nextIndex = - ((reverse ? cellIndex - 1 : cellIndex + 1) + numCells) % numCells; - const editor = notebook.widgets[nextIndex].editor as CodeMirrorEditor; - // move the cursor of the next cell to the start/end of the cell so it can - // search the whole thing (but don't scroll because we haven't found anything yet) - const newPosCM = reverse - ? CodeMirror.Pos(editor.lastLine()) - : CodeMirror.Pos(editor.firstLine(), 0); - const newPos = { - line: newPosCM.line, - column: newPosCM.ch - }; - editor.setCursorPosition(newPos, { scroll: false }); - return this._stepNext(notebook.widgets[nextIndex], reverse, steps + 1); + ((reverse ? providerIndex - 1 : providerIndex + 1) + numProviders) % + numProviders; + const nextSearchPair = this._searchProviders[nextIndex]; + if (nextSearchPair.provider instanceof CodeMirrorSearchProvider) { + const editor = nextSearchPair.provider.editor; + // move the cursor of the next cell to the start/end of the cell so it can + // search the whole thing (but don't scroll because we haven't found anything yet) + const newPosCM = reverse + ? CodeMirror.Pos(editor.lastLine()) + : CodeMirror.Pos(editor.firstLine(), 0); + const newPos = { + line: newPosCM.line, + column: newPosCM.ch + }; + editor.setCursorPosition(newPos, { scroll: false }); + } + this._currentProvider = nextSearchPair; + return this._stepNext(nextSearchPair, reverse, steps + 1); } - notebook.activeCellIndex = cellIndex; + const notebook = this._searchTarget!.content; + notebook.activeCellIndex = notebook.widgets.indexOf(currentSearchPair.cell); return match; } private async _restartQuery() { await this.endQuery(); - await this.startQuery(this._query, this._searchTarget!); + await this.startQuery(this._query, this._searchTarget!, this._filters); this._changed.emit(undefined); } private _getMatchesFromCells(): ISearchMatch[][] { let indexTotal = 0; const result: ISearchMatch[][] = []; - this._cmSearchProviders.forEach(({ provider }) => { + this._searchProviders.forEach(({ provider }) => { const cellMatches = provider.matches; cellMatches.forEach(match => { match.index = match.index + indexTotal; @@ -407,7 +481,7 @@ export class NotebookSearchProvider implements ISearchProvider { return result; } - private _onCmSearchProviderChanged() { + private _onSearchProviderChanged() { this._changed.emit(undefined); } @@ -430,7 +504,9 @@ export class NotebookSearchProvider implements ISearchProvider { private _searchTarget: NotebookPanel | undefined | null; private _query: RegExp; - private _cmSearchProviders: ICellSearchPair[] = []; + private _filters: INotebookFilters; + private _searchProviders: ICellSearchPair[] = []; + private _currentProvider: ICellSearchPair | null | undefined; private _currentMatch: ISearchMatch | undefined | null; private _unRenderedMarkdownCells: MarkdownCell[] = []; private _cellsWithMatches: Cell[] = []; diff --git a/packages/documentsearch/src/searchinstance.ts b/packages/documentsearch/src/searchinstance.ts index 47e9ecc4bf02..78395c6c0a93 100644 --- a/packages/documentsearch/src/searchinstance.ts +++ b/packages/documentsearch/src/searchinstance.ts @@ -1,7 +1,7 @@ // Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. -import { IDisplayState, ISearchProvider } from './interfaces'; +import { IDisplayState, ISearchProvider, IFiltersType } from './interfaces'; import { createSearchOverlay } from './searchoverlay'; import { MainAreaWidget } from '@jupyterlab/apputils'; @@ -31,7 +31,8 @@ export class SearchInstance implements IDisposable { onReplaceCurrent: this._replaceCurrent.bind(this), onReplaceAll: this._replaceAll.bind(this), onEndSearch: this.dispose.bind(this), - isReadOnly: this._activeProvider.isReadOnly + isReadOnly: this._activeProvider.isReadOnly, + hasOutputs: this._activeProvider.hasOutputs || false }); this._widget.disposed.connect(() => { @@ -93,13 +94,14 @@ export class SearchInstance implements IDisposable { this._displayUpdateSignal.emit(this._displayState); } - private async _startQuery(query: RegExp) { + private async _startQuery(query: RegExp, filters: IFiltersType) { // save the last query (or set it to the current query if this is the first) if (this._activeProvider && this._displayState.query) { await this._activeProvider.endQuery(); } this._displayState.query = query; - await this._activeProvider.startQuery(query, this._widget); + this._displayState.filters = filters; + await this._activeProvider.startQuery(query, this._widget, filters); this.updateIndices(); // this signal should get injected when the widget is @@ -202,8 +204,11 @@ export class SearchInstance implements IDisposable { replaceInputFocused: false, forceFocus: true, replaceText: '', - replaceEntryShown: false + replaceEntryShown: false, + filters: { output: true }, + filtersOpen: false }; + private _displayUpdateSignal = new Signal(this); private _activeProvider: ISearchProvider; private _searchWidget: Widget; diff --git a/packages/documentsearch/src/searchoverlay.tsx b/packages/documentsearch/src/searchoverlay.tsx index 31bd156b31da..3d3b43ca6327 100644 --- a/packages/documentsearch/src/searchoverlay.tsx +++ b/packages/documentsearch/src/searchoverlay.tsx @@ -30,9 +30,15 @@ const CASE_BUTTON_CLASS_ON = const INDEX_COUNTER_CLASS = 'jp-DocumentSearch-index-counter'; const UP_DOWN_BUTTON_WRAPPER_CLASS = 'jp-DocumentSearch-up-down-wrapper'; const UP_BUTTON_CLASS = 'jp-DocumentSearch-up-button'; +const ELLIPSES_BUTTON_CLASS = 'jp-DocumentSearch-ellipses-button'; +const ELLIPSES_BUTTON_ENABLED_CLASS = + 'jp-DocumentSearch-ellipses-button-enabled'; const DOWN_BUTTON_CLASS = 'jp-DocumentSearch-down-button'; const CLOSE_BUTTON_CLASS = 'jp-DocumentSearch-close-button'; 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'; @@ -44,6 +50,7 @@ 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; @@ -230,6 +237,65 @@ function SearchIndices(props: ISearchIndexProps) { ); } +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; @@ -241,6 +307,7 @@ interface ISearchOverlayProps { onReplaceCurrent: Function; onReplaceAll: Function; isReadOnly: boolean; + hasOutputs: boolean; } class SearchOverlay extends React.Component< @@ -250,6 +317,8 @@ class SearchOverlay extends React.Component< constructor(props: ISearchOverlayProps) { super(props); this.state = props.overlayState; + + this._toggleSearchOutput = this._toggleSearchOutput.bind(this); } componentDidMount() { @@ -288,7 +357,11 @@ class SearchOverlay extends React.Component< } } - private _executeSearch(goForward: boolean, searchText?: string) { + private _executeSearch( + goForward: boolean, + searchText?: string, + filterChanged = false + ) { // execute search! let query; const input = searchText ? searchText : this.state.searchText; @@ -304,7 +377,10 @@ class SearchOverlay extends React.Component< return; } - if (Private.regexEqual(this.props.overlayState.query, query)) { + if ( + Private.regexEqual(this.props.overlayState.query, query) && + !filterChanged + ) { if (goForward) { this.props.onHightlightNext(); } else { @@ -313,7 +389,7 @@ class SearchOverlay extends React.Component< return; } - this.props.onStartQuery(query); + this.props.onStartQuery(query, this.state.filters); } private _onClose() { @@ -339,8 +415,41 @@ class SearchOverlay extends React.Component< 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 ? ( @@ -388,6 +497,7 @@ class SearchOverlay extends React.Component< onHighlightPrevious={() => this._executeSearch(false)} onHightlightNext={() => this._executeSearch(true)} /> + {showReplace ? null : filterToggle}
,
- {!this.props.isReadOnly && this.state.replaceEntryShown ? ( - 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" - /> + {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,