// tslint:disable:no-console // TODO: Add documentation and clean up log statements. import {CursorPos} from 'app/client/components/Cursor'; import {GristDoc} from 'app/client/components/GristDoc'; import {ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel'; import {reportError} from 'app/client/models/errors'; import {delay} from 'app/common/delay'; import {waitObs} from 'app/common/gutil'; import {TableData} from 'app/common/TableData'; import {BaseFormatter} from 'app/common/ValueFormatter'; import {Disposable, Observable} from 'grainjs'; import debounce = require('lodash/debounce'); /** * SearchModel used to maintain the state of the search UI. */ export interface SearchModel { value: Observable; // string in the search input isOpen: Observable; // indicates whether the search bar is expanded to show the input noMatch: Observable; // indicates if there are no search matches isEmpty: Observable; // indicates whether the value is empty isRunning: Observable; // indicates that matching is in progress multiPage: Observable; // if true will search across all pages findNext(): Promise; // find next match findPrev(): Promise; // find previous match } interface SearchPosition { pageIndex: number; sectionIndex: number; rowIndex: number; fieldIndex: number; } /** * Stepper is an helper class that is used to implement stepping through all the cells of a * document. Fields belongs to rows, rows belongs to section and sections to pages. So this is four * steppers that must be used together, one for each level (field, rows, section and pages). When a * stepper reaches the end of its array, this is the `nextArrayFunc` callback, passed to the * `next()`, that is responsible for both taking a step at the higher level and updating the * stepper's array. */ class Stepper { public array: ReadonlyArray = []; public index: number = 0; public inRange() { return this.index >= 0 && this.index < this.array.length; } // Doing await at every step adds a ton of overhead; we can optimize by returning and waiting on // Promises only when needed. public next(step: number, nextArrayFunc: () => Promise|void): Promise|void { this.index += step; if (!this.inRange()) { // If index reached the end of the array, take a step at a higher level to get a new array. // For efficiency, only wait asynchronously if the callback returned a promise. const p = nextArrayFunc(); if (p) { return p.then(() => this.setStart(step)); } else { this.setStart(step); } } } public setStart(step: number) { this.index = step > 0 ? 0 : this.array.length - 1; } public get value(): T { return this.array[this.index]; } } /** * Interface that represents an ongoing search job which stops on the first match found. */ interface IFinder { matchFound: boolean; // true if a match was found startPosition: SearchPosition; // position at which to stop searching for a new match abort(): void; // abort current search matchNext(step: number): Promise; // next match nextField(step: number): Promise|void; // move the current position getCurrentPosition(): SearchPosition; // get the current position } // A callback to opening a page: useful to switch to next page during an ongoing search. type DocPageOpener = (viewId: number) => Promise; /** * An implementation of an IFinder. */ class FinderImpl implements IFinder { public matchFound = false; public startPosition: SearchPosition; private _searchRegexp: RegExp; private _pageStepper = new Stepper(); private _sectionStepper = new Stepper(); private _sectionTableData: TableData; private _rowStepper = new Stepper(); private _fieldStepper = new Stepper(); private _fieldFormatters: BaseFormatter[]; private _pagesSwitched: number = 0; private _aborted = false; private _clearCursorHighlight: (() => void)|undefined; constructor(private _gristDoc: GristDoc, value: string, private _openDocPageCB: DocPageOpener, public multiPage: Observable) { this._searchRegexp = makeRegexp(value); } public abort() { this._aborted = true; if (this._clearCursorHighlight) { this._clearCursorHighlight(); } } public getCurrentPosition(): SearchPosition { return { pageIndex: this._pageStepper.index, sectionIndex: this._sectionStepper.index, rowIndex: this._rowStepper.index, fieldIndex: this._fieldStepper.index, }; } // Initialize the steppers. Returns false if anything goes wrong. public init(): boolean { const pages: any[] = this._gristDoc.docModel.visibleDocPages.peek(); this._pageStepper.array = pages; this._pageStepper.index = pages.findIndex(t => t.viewRef() === this._gristDoc.activeViewId.get()); if (this._pageStepper.index < 0) { return false; } const view = this._pageStepper.value.view.peek(); const sections: any[] = view.viewSections().peek(); this._sectionStepper.array = sections; this._sectionStepper.index = sections.findIndex(s => s.getRowId() === view.activeSectionId()); if (this._sectionStepper.index < 0) { return false; } this._initNewSectionShown(); // Find the current cursor position in the current section. const viewInstance = this._sectionStepper.value.viewInstance.peek()!; const pos = viewInstance.cursor.getCursorPos(); this._rowStepper.index = pos.rowIndex!; this._fieldStepper.index = pos.fieldIndex!; return true; } public async matchNext(step: number): Promise { let count = 0; let lastBreak = Date.now(); this._pagesSwitched = 0; while (!this._matches() || ((await this._loadSection(step)) && !this._matches())) { // If search was aborted, simply returns. if (this._aborted) { return; } // To avoid hogging the CPU for too long, check time periodically, and if we've been running // for long enough, take a brief break. We choose a 5ms break every 20ms; and only check // time every 100 iterations, to avoid excessive overhead purely due to time checks. if ((++count) % 100 === 0 && Date.now() >= lastBreak + 20) { await delay(5); lastBreak = Date.now(); } const p = this.nextField(step); if (p) { await p; } // Detect when we get back to the start position; this is where we break on no match. if (this._isCurrentPosition(this.startPosition) && !this._matches()) { console.log("SearchBar: reached start position without finding anything"); this.matchFound = false; return; } // A fail-safe to prevent certain bugs from causing infinite loops; break also if we scan // through pages too many times. // TODO: test it by disabling the check above. if (this._pagesSwitched > this._pageStepper.array.length) { console.log("SearchBar: aborting search due to too many page switches"); this.matchFound = false; return; } } console.log("SearchBar: found a match at %s", JSON.stringify(this.getCurrentPosition())); this.matchFound = true; await this._highlight(); } public nextField(step: number): Promise|void { return this._fieldStepper.next(step, () => this._nextRow(step)); } private _nextRow(step: number) { return this._rowStepper.next(step, () => this._nextSection(step)); } private async _nextSection(step: number) { // Switching sections is rare enough that we don't worry about optimizing away `await` calls. await this._sectionStepper.next(step, () => this._nextPage(step)); await this._initNewSectionAny(); } // TODO There are issues with filtering. A section may have filters applied, and it may be // auto-filtered (linked sections). If a tab is shown, we have the filtered list of rowIds; if // the tab is not shown, it takes work to apply explicit filters. For linked sections, the // sensible behavior seems to scan through ALL values, then once a match is found, set the // cursor that determines the linking to include the matched row. And even that may not always // be possible. So this is an open question. private _initNewSectionCommon() { const section = this._sectionStepper.value; const tableModel = this._gristDoc.getTableModel(section.table.peek().tableId.peek()); this._sectionTableData = tableModel.tableData; this._fieldStepper.array = section.viewFields().peek(); this._fieldFormatters = this._fieldStepper.array.map(f => f.visibleColFormatter.peek()); return tableModel; } private _initNewSectionShown() { this._initNewSectionCommon(); const viewInstance = this._sectionStepper.value.viewInstance.peek()!; this._rowStepper.array = viewInstance.sortedRows.getKoArray().peek() as number[]; } private async _initNewSectionAny() { const tableModel = this._initNewSectionCommon(); const viewInstance = this._sectionStepper.value.viewInstance.peek(); if (viewInstance) { this._rowStepper.array = viewInstance.sortedRows.getKoArray().peek() as number[]; } else { // If we are searching through another page (not currently loaded), we will NOT have a // viewInstance, but we use the unsorted unfiltered row list, and if we find a match, the // _loadSection() method will load the page and we'll repeat the search with a viewInstance. await tableModel.fetch(); this._rowStepper.array = this._sectionTableData.getRowIds(); } } private async _nextPage(step: number) { if (!this.multiPage.get()) { return; } await this._pageStepper.next(step, () => undefined); this._pagesSwitched++; const view = this._pageStepper.value.view.peek(); this._sectionStepper.array = view.viewSections().peek(); } private _matches(): boolean { if (this._pageStepper.index < 0 || this._sectionStepper.index < 0 || this._rowStepper.index < 0 || this._fieldStepper.index < 0) { console.warn("match outside"); return false; } const field = this._fieldStepper.value; const formatter = this._fieldFormatters[this._fieldStepper.index]; const rowId = this._rowStepper.value; const displayCol = field.displayColModel.peek(); const value = this._sectionTableData.getValue(rowId, displayCol.colId.peek()); // TODO: Note that formatting dates is now the bulk of the performance cost. const text = formatter.formatAny(value); return this._searchRegexp.test(text); } private async _loadSection(step: number): Promise { // If we found a match in a section for which we don't have a valid BaseView instance, we need // to load the BaseView and start searching the section again, since the match we found does // not take into account sort or filters. So we switch to the right page, wait for the // viewInstance to be created, reset the section info, and return true to continue searching. const section = this._sectionStepper.value; if (!section.viewInstance.peek()) { const view = this._pageStepper.value.view.peek(); await this._openDocPage(view.getRowId()); console.log("SearchBar: loading view %s section %s", view.getRowId(), section.getRowId()); const viewInstance: any = await waitObs(section.viewInstance); await viewInstance.getLoadingDonePromise(); this._initNewSectionShown(); this._rowStepper.setStart(step); this._fieldStepper.setStart(step); console.log("SearchBar: loaded view %s section %s", view.getRowId(), section.getRowId()); return true; } return false; } // Highlights the cell at the current position. private async _highlight() { if (this._aborted) { return; } const section = this._sectionStepper.value; const sectionId = section.getRowId(); const cursorPos: CursorPos = { sectionId, rowId: this._rowStepper.value, fieldIndex: this._fieldStepper.index, }; await this._gristDoc.recursiveMoveToCursorPos(cursorPos, true).catch(reportError); if (this._aborted) { return; } // Highlight the selected cursor, after giving it a chance to update. We find the cursor in // this ad-hoc way rather than use observables, to avoid the overhead of *every* cell // depending on an additional observable. await delay(0); const viewInstance = (await waitObs(section.viewInstance))!; await viewInstance.getLoadingDonePromise(); if (this._aborted) { return; } // Make sure we are at good place. This is important when the cursor // was already in a matched record, but the record was scrolled away. viewInstance.scrollToCursor(true).catch(reportError); const cursor = viewInstance.viewPane.querySelector('.selected_cursor'); if (cursor) { cursor.classList.add('search-match'); this._clearCursorHighlight = () => { cursor.classList.remove('search-match'); clearTimeout(timeout); this._clearCursorHighlight = undefined; }; const timeout = setTimeout(this._clearCursorHighlight, 20); } } private _isCurrentPosition(pos: SearchPosition): boolean { return ( this._pageStepper.index === pos.pageIndex && this._sectionStepper.index === pos.sectionIndex && this._rowStepper.index === pos.rowIndex && this._fieldStepper.index === pos.fieldIndex ); } private _openDocPage(viewId: number) { if (this._aborted) { return; } return this._openDocPageCB(viewId); } } /** * Implementation of SearchModel used to construct the search UI. */ export class SearchModelImpl extends Disposable implements SearchModel { public readonly value = Observable.create(this, ''); public readonly isOpen = Observable.create(this, false); public readonly isRunning = Observable.create(this, false); public readonly noMatch = Observable.create(this, true); public readonly isEmpty = Observable.create(this, true); public readonly multiPage = Observable.create(this, false); private _isRestartNeeded = false; private _finder: IFinder|null = null; constructor(private _gristDoc: GristDoc) { super(); // Listen to input value changes (debounced) to activate searching. const findFirst = debounce((_value: string) => this._findFirst(_value), 100); this.autoDispose(this.value.addListener(v => { this.isRunning.set(true); void findFirst(v); })); // Set this.noMatch to false when multiPage gets turned ON. this.autoDispose(this.multiPage.addListener(v => { if (v) { this.noMatch.set(false); } })); // Schedule a search restart when user changes pages (otherwise search would resume from the // previous page that is not shown anymore). Also revert noMatch flag when in single page mode. this.autoDispose(this._gristDoc.activeViewId.addListener(() => { if (!this.multiPage.get()) { this.noMatch.set(false); } this._isRestartNeeded = true; })); } public async findNext() { if (this.isRunning.get() || this.noMatch.get()) { return; } if (this._isRestartNeeded) { return this._findFirst(this.value.get()); } await this._run(async (finder) => { await finder.nextField(1); await finder.matchNext(1); }); } public async findPrev() { if (this.isRunning.get() || this.noMatch.get()) { return; } if (this._isRestartNeeded) { return this._findFirst(this.value.get()); } await this._run(async (finder) => { await finder.nextField(-1); await finder.matchNext(-1); }); } private async _findFirst(value: string) { this._isRestartNeeded = false; this.isEmpty.set(!value); this._updateFinder(value); if (!value || !this._finder) { this.noMatch.set(true); return; } await this._run(async (finder) => { await finder.matchNext(1); }); } private _updateFinder(value: string) { if (this._finder) { this._finder.abort(); } const impl = new FinderImpl(this._gristDoc, value, this._openDocPage.bind(this), this.multiPage); const isValid = impl.init(); this._finder = isValid ? impl : null; } // Internal helper that runs cb, passing it the current `this._finder` as first argument and sets // this.isRunning to true until the call resolves. It also takes care of updating this.noMatch. private async _run(cb: (finder: IFinder) => Promise) { const finder = this._finder; if (!finder) { throw new Error("SearchModel: finder is not defined"); } try { this.isRunning.set(true); finder.startPosition = finder.getCurrentPosition(); await cb(finder); } finally { this.isRunning.set(false); this.noMatch.set(!finder.matchFound); } } // Opens doc page without triggering a restart. private async _openDocPage(viewId: number) { await this._gristDoc.openDocPage(viewId); this._isRestartNeeded = false; } } function makeRegexp(value: string) { // From https://stackoverflow.com/a/3561711/328565 const escaped = value.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); return new RegExp(escaped, 'i'); }