diff --git a/packages/documentsearch/package.json b/packages/documentsearch/package.json index f0226908316c..580efdd9ec97 100644 --- a/packages/documentsearch/package.json +++ b/packages/documentsearch/package.json @@ -44,6 +44,7 @@ "@phosphor/signaling": "^1.3.0", "@phosphor/widgets": "^1.9.0", "codemirror": "~5.47.0", + "lodash": "~4.17.15", "react": "~16.8.4" }, "devDependencies": { diff --git a/packages/documentsearch/src/providers/codemirrorsearchprovider.ts b/packages/documentsearch/src/providers/codemirrorsearchprovider.ts index 45331da41459..70e6b720aefe 100644 --- a/packages/documentsearch/src/providers/codemirrorsearchprovider.ts +++ b/packages/documentsearch/src/providers/codemirrorsearchprovider.ts @@ -83,8 +83,6 @@ export class CodeMirrorSearchProvider // canSearchOn is a type guard that guarantees the type of .editor this._cm = searchTarget.content.editor; return this._startQuery(query); - - throw new Error('Cannot find Codemirror instance to search'); } /** @@ -300,6 +298,9 @@ export class CodeMirrorSearchProvider return null; } + 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..ece29a1ce10d --- /dev/null +++ b/packages/documentsearch/src/providers/genericsearchprovider.ts @@ -0,0 +1,413 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { ISearchProvider, ISearchMatch } from '../interfaces'; +import { ISignal, Signal } from '@phosphor/signaling'; +import { Widget } from '@phosphor/widgets'; +import _ from 'lodash'; + +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 + * + * @returns A promise that resolves with a list of all matches + */ + async startQuery( + query: RegExp, + searchTarget: Widget + ): 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 = []; + // We MUST gather nodes first, otherwise the updates below will find each result twice + let node = walker.nextNode(); + while (node) { + nodes.push(node); + 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 => { + 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 = node.parentElement.cloneNode(true); + 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 null; + } + } + // 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 => _.clone(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 { + 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 null; + } + + /** + * 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; + 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 ab7abb59a310..1d46557325ce 100644 --- a/packages/documentsearch/src/providers/notebooksearchprovider.ts +++ b/packages/documentsearch/src/providers/notebooksearchprovider.ts @@ -2,19 +2,20 @@ // 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 { CodeMirrorEditor } from '@jupyterlab/codemirror'; -import { Cell, MarkdownCell } from '@jupyterlab/cells'; +import { Cell, MarkdownCell, CodeCell } from '@jupyterlab/cells'; import { Signal, ISignal } from '@phosphor/signaling'; import { Widget } from '@phosphor/widgets'; - import CodeMirror from 'codemirror'; +import _ from 'lodash'; interface ICellSearchPair { cell: Cell; - provider: CodeMirrorSearchProvider; + provider: CodeMirrorSearchProvider | GenericSearchProvider; } export class NotebookSearchProvider implements ISearchProvider { @@ -107,23 +108,43 @@ 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) { + 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 // and so that the next step will scroll correctly to the first match this._searchTarget.show(); - this._currentMatch = await this._stepNext( - this._searchTarget.content.activeCell - ); + this._currentMatch = await this._stepNext(this._updatedCurrentProvider(false)); this._refreshCurrentCellEditor(); this._refreshCellsEditorsInBackground(this._cellsWithMatches); @@ -168,13 +189,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) { @@ -206,12 +228,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; }); @@ -240,9 +263,7 @@ export class NotebookSearchProvider implements ISearchProvider { * @returns A promise that resolves once the action has completed. */ async highlightNext(): Promise { - this._currentMatch = await this._stepNext( - this._searchTarget.content.activeCell - ); + this._currentMatch = await this._stepNext(this._updatedCurrentProvider(false)); return this._currentMatch; } @@ -253,7 +274,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; @@ -269,8 +290,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; @@ -292,8 +312,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; } @@ -311,7 +331,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 [].concat(...this._getMatchesFromCells()); @@ -341,47 +361,75 @@ export class NotebookSearchProvider implements ISearchProvider { */ readonly isReadOnly = false; + 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 ? _.findLast : _.find; + provider = find( + this._searchProviders, + provider => this._searchTarget.content.activeCell === provider.cell + ); + } else { + const currentProviderIndex = _.findIndex(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; } @@ -394,7 +442,7 @@ export class NotebookSearchProvider implements ISearchProvider { 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; @@ -405,7 +453,7 @@ export class NotebookSearchProvider implements ISearchProvider { return result; } - private _onCmSearchProviderChanged() { + private _onSearchProviderChanged() { this._changed.emit(undefined); } @@ -428,7 +476,8 @@ export class NotebookSearchProvider implements ISearchProvider { private _searchTarget: NotebookPanel; private _query: RegExp; - private _cmSearchProviders: ICellSearchPair[] = []; + private _searchProviders: ICellSearchPair[] = []; + private _currentProvider: ICellSearchPair; private _currentMatch: ISearchMatch; private _unRenderedMarkdownCells: MarkdownCell[] = []; private _cellsWithMatches: Cell[] = [];