UNPKG

@jupyterlab/cells

Version:
514 lines (462 loc) 13.5 kB
// Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. import { CodeEditor } from '@jupyterlab/codeeditor'; import { CodeMirrorEditor, EditorSearchProvider, IHighlightAdjacentMatchOptions } from '@jupyterlab/codemirror'; import { signalToPromise } from '@jupyterlab/coreutils'; import { GenericSearchProvider, IBaseSearchProvider, IFilters, IReplaceOptions, ISearchMatch } from '@jupyterlab/documentsearch'; import { OutputArea } from '@jupyterlab/outputarea'; import { ICellModel } from './model'; import { Cell, CodeCell, MarkdownCell } from './widget'; /** * Class applied on highlighted search matches */ export const SELECTED_HIGHLIGHT_CLASS = 'jp-mod-selected'; /** * Search provider for cells. */ export class CellSearchProvider extends EditorSearchProvider<ICellModel> implements IBaseSearchProvider { constructor(protected cell: Cell<ICellModel>) { super(); if (!this.cell.inViewport && !this.cell.editor) { void signalToPromise(cell.inViewportChanged).then(([, inViewport]) => { if (inViewport) { this.cmHandler.setEditor(this.editor as CodeMirrorEditor); } }); } } /** * Text editor */ protected get editor(): CodeEditor.IEditor | null { return this.cell.editor; } /** * Editor content model */ protected get model() { return this.cell.model; } } /** * Code cell search provider */ class CodeCellSearchProvider extends CellSearchProvider { /** * Constructor * * @param cell Cell widget */ constructor(cell: Cell<ICellModel>) { super(cell); this.currentProviderIndex = -1; this.outputsProvider = []; const outputs = (this.cell as CodeCell).outputArea; this._onOutputsChanged(outputs, outputs.widgets.length).catch(reason => { console.error(`Failed to initialize search on cell outputs.`, reason); }); outputs.outputLengthChanged.connect(this._onOutputsChanged, this); outputs.disposed.connect(() => { outputs.outputLengthChanged.disconnect(this._onOutputsChanged); }, this); } /** * Number of matches in the cell. */ get matchesCount(): number { if (!this.isActive) { return 0; } return ( super.matchesCount + this.outputsProvider.reduce( (sum, provider) => sum + (provider.matchesCount ?? 0), 0 ) ); } /** * Clear currently highlighted match. */ async clearHighlight(): Promise<void> { await super.clearHighlight(); await Promise.all( this.outputsProvider.map(provider => provider.clearHighlight()) ); } /** * Dispose the search provider */ dispose(): void { if (this.isDisposed) { return; } super.dispose(); this.outputsProvider.map(provider => { provider.dispose(); }); this.outputsProvider.length = 0; } /** * Highlight the next match. * * @returns The next match if there is one. */ async highlightNext( loop?: boolean, options?: IHighlightAdjacentMatchOptions ): Promise<ISearchMatch | undefined> { // If we're scanning from the previous match, test whether we're // at the end of the matches list. const from = options?.from ?? ''; if ( this.matchesCount === 0 || (from === 'previous-match' && this.currentIndex !== null && this.currentIndex + 1 >= this.cmHandler.matches.length) || !this.isActive ) { this.currentIndex = null; } else { if (this.currentProviderIndex === -1) { const match = await super.highlightNext(loop, options); if (match) { this.currentIndex = this.cmHandler.currentIndex; return match; } else { this.currentProviderIndex = 0; } } while (this.currentProviderIndex < this.outputsProvider.length) { const provider = this.outputsProvider[this.currentProviderIndex]; const match = await provider.highlightNext(false); if (match) { this.currentIndex = super.matchesCount + this.outputsProvider .slice(0, this.currentProviderIndex) .reduce( (sum, provider) => (sum += provider.matchesCount ?? 0), 0 ) + provider.currentMatchIndex!; return match; } else { this.currentProviderIndex += 1; } } this.currentProviderIndex = -1; this.currentIndex = null; return undefined; } } /** * Highlight the previous match. * * @returns The previous match if there is one. */ async highlightPrevious(): Promise<ISearchMatch | undefined> { if (this.matchesCount === 0 || !this.isActive) { this.currentIndex = null; } else { if (this.currentIndex === null) { this.currentProviderIndex = this.outputsProvider.length - 1; } while (this.currentProviderIndex >= 0) { const provider = this.outputsProvider[this.currentProviderIndex]; const match = await provider.highlightPrevious(false); if (match) { this.currentIndex = super.matchesCount + this.outputsProvider .slice(0, this.currentProviderIndex) .reduce( (sum, provider) => (sum += provider.matchesCount ?? 0), 0 ) + provider.currentMatchIndex!; return match; } else { this.currentProviderIndex -= 1; } } const match = await super.highlightPrevious(); if (match) { this.currentIndex = this.cmHandler.currentIndex; return match; } else { this.currentIndex = null; return undefined; } } } /** * Initialize the search using the provided options. Should update the UI to highlight * all matches and "select" the first match. * * @param query A RegExp to be use to perform the search * @param filters Filter parameters to pass to provider */ async startQuery(query: RegExp | null, filters?: IFilters): Promise<void> { await super.startQuery(query, filters); // Search outputs if (filters?.output !== false && this.isActive) { await Promise.all( this.outputsProvider.map(provider => provider.startQuery(query)) ); } } async endQuery(): Promise<void> { await super.endQuery(); if (this.filters?.output !== false && this.isActive) { await Promise.all( this.outputsProvider.map(provider => provider.endQuery()) ); } } /** * Replace all matches in the cell source with the provided text * * @param newText The replacement text. * @returns Whether a replace occurred. */ async replaceAllMatches( newText: string, options?: IReplaceOptions ): Promise<boolean> { if (this.model.getMetadata('editable') === false) return Promise.resolve(false); const result = await super.replaceAllMatches(newText, options); return result; } /** * Replace the currently selected match with the provided text. * If no match is selected, it won't do anything. * * @param newText The replacement text. * @returns Whether a replace occurred. */ async replaceCurrentMatch( newText: string, loop?: boolean, options?: IReplaceOptions ): Promise<boolean> { if (this.model.getMetadata('editable') === false) return Promise.resolve(false); const result = await super.replaceCurrentMatch(newText, loop, options); return result; } private async _onOutputsChanged( outputArea: OutputArea, changes: number ): Promise<void> { this.outputsProvider.forEach(provider => { provider.dispose(); }); this.outputsProvider.length = 0; this.currentProviderIndex = -1; this.outputsProvider = (this.cell as CodeCell).outputArea.widgets.map( output => new GenericSearchProvider(output) ); if (this.isActive && this.query && this.filters?.output !== false) { await Promise.all([ this.outputsProvider.map(provider => { void provider.startQuery(this.query); }) ]); } this._stateChanged.emit(); } protected outputsProvider: GenericSearchProvider[]; protected currentProviderIndex: number; } /** * Markdown cell search provider */ class MarkdownCellSearchProvider extends CellSearchProvider { /** * Constructor * * @param cell Cell widget */ constructor(cell: Cell<ICellModel>) { super(cell); this.renderedProvider = new GenericSearchProvider( (cell as MarkdownCell).renderer ); } /** * Clear currently highlighted match */ async clearHighlight(): Promise<void> { await super.clearHighlight(); await this.renderedProvider.clearHighlight(); } /** * Dispose the search provider */ dispose(): void { if (this.isDisposed) { return; } super.dispose(); this.renderedProvider.dispose(); } /** * Stop the search and clean any UI elements. */ async endQuery(): Promise<void> { await super.endQuery(); await this.renderedProvider.endQuery(); } /** * Highlight the next match. * * @returns The next match if there is one. */ async highlightNext( loop = true, options?: IHighlightAdjacentMatchOptions ): Promise<ISearchMatch | undefined> { let match: ISearchMatch | undefined = undefined; if (!this.isActive) { return match; } const cell = this.cell as MarkdownCell; if (cell.rendered && this.matchesCount > 0) { // Unrender the cell this._unrenderedByHighlight = true; const waitForRendered = signalToPromise(cell.renderedChanged); cell.rendered = false; await waitForRendered; } match = await super.highlightNext(loop, options); return match; } /** * Highlight the previous match. * * @returns The previous match if there is one. */ async highlightPrevious(): Promise<ISearchMatch | undefined> { let match: ISearchMatch | undefined = undefined; const cell = this.cell as MarkdownCell; if (cell.rendered && this.matchesCount > 0) { // Unrender the cell if there are matches within the cell this._unrenderedByHighlight = true; const waitForRendered = signalToPromise(cell.renderedChanged); cell.rendered = false; await waitForRendered; } match = await super.highlightPrevious(); return match; } /** * Initialize the search using the provided options. Should update the UI * to highlight all matches and "select" the first match. * * @param query A RegExp to be use to perform the search * @param filters Filter parameters to pass to provider */ async startQuery(query: RegExp | null, filters?: IFilters): Promise<void> { await super.startQuery(query, filters); const cell = this.cell as MarkdownCell; if (cell.rendered) { this.onRenderedChanged(cell, cell.rendered); } cell.renderedChanged.connect(this.onRenderedChanged, this); } /** * Replace all matches in the cell source with the provided text * * @param newText The replacement text. * @returns Whether a replace occurred. */ async replaceAllMatches( newText: string, options?: IReplaceOptions ): Promise<boolean> { if (this.model.getMetadata('editable') === false) return Promise.resolve(false); const result = await super.replaceAllMatches(newText, options); // if the cell is rendered force update if ((this.cell as MarkdownCell).rendered) { this.cell.update(); } return result; } /** * Replace the currently selected match with the provided text. * If no match is selected, it won't do anything. * * @param newText The replacement text. * @returns Whether a replace occurred. */ async replaceCurrentMatch( newText: string, loop?: boolean, options?: IReplaceOptions ): Promise<boolean> { if (this.model.getMetadata('editable') === false) return Promise.resolve(false); const result = await super.replaceCurrentMatch(newText, loop, options); return result; } /** * Callback on rendered state change * * @param cell Cell that emitted the change * @param rendered New rendered value */ protected onRenderedChanged(cell: MarkdownCell, rendered: boolean): void { if (!this._unrenderedByHighlight) { this.currentIndex = null; } this._unrenderedByHighlight = false; if (this.isActive) { if (rendered) { void this.renderedProvider.startQuery(this.query); } else { // Force cursor position to ensure reverse search is working as expected cell.editor?.setCursorPosition({ column: 0, line: 0 }); void this.renderedProvider.endQuery(); } } } protected renderedProvider: GenericSearchProvider; private _unrenderedByHighlight = false; } /** * Factory to create a cell search provider * * @param cell Cell widget * @returns Cell search provider */ export function createCellSearchProvider( cell: Cell<ICellModel> ): CellSearchProvider { if (cell.isPlaceholder()) { return new CellSearchProvider(cell); } switch (cell.model.type) { case 'code': return new CodeCellSearchProvider(cell); case 'markdown': return new MarkdownCellSearchProvider(cell); default: return new CellSearchProvider(cell); } }