// tslint:disable:no-console // TODO: This file should be removed once the old search UI is phased out. import {createGroup} from 'app/client/components/commands'; import {GristDoc} from 'app/client/components/GristDoc'; import * as dom from 'app/client/lib/dom'; import * as kd from 'app/client/lib/koDom'; import * as kf from 'app/client/lib/koForm'; import {delay} from 'app/common/delay'; import {waitObs} from 'app/common/gutil'; import {TableData} from 'app/common/TableData'; import {BaseFormatter, createFormatter} from 'app/common/ValueFormatter'; import * as ko from 'knockout'; import debounce = require('lodash/debounce'); /** * Creates a search box for the toolbar. Returns a value suitable for NavBar.makeToolbarGroup(). */ export function makeSearchToolbarGroup(gristDoc: GristDoc) { const searcher = new Searcher(gristDoc); let input: HTMLInputElement; // Active normally. const commandGroup = createGroup({ find: () => { input.focus(); }, findNext: () => { searcher.findNext(); }, // eslint-disable-line @typescript-eslint/no-floating-promises findPrev: () => { searcher.findPrev(); }, // eslint-disable-line @typescript-eslint/no-floating-promises }, null, true); // Return an array of one item (for a toolbar group of a single item). The item is an array of // arguments that populate the div for this single toolbar group item. return [[ kd.toggleClass('searchbar-box', true), dom('span.searchbar-icon.glyphicon.glyphicon-search', kd.toggleClass('searchbar-icon-indicator', searcher.isRunning) ), input = dom('input.searchbar-input', {placeholder: 'Search'}, (elem: HTMLInputElement) => bindChangeOrDelay(elem, value => searcher.findFirst(value), 100), dom.testId('SearchBar_input'), dom.autoDispose(commandGroup), commandGroup.attach(), dom.on('focus', () => { input.select(); }), // Using a keyboard handler directly because command groups are hard to get to work (because // the searchbox is created so early that the actions like accept/cancel get overridden). dom.on('keydown', (e: KeyboardEvent) => { switch (e.keyCode) { case 13: searcher.findNext(); break; // eslint-disable-line @typescript-eslint/no-floating-promises case 27: input.blur(); break; } }) ), kf.buttonGroup( kd.toggleClass('searchbar-buttons', true), kf.button(() => searcher.findPrev(), '\u2329', kd.toggleClass('disabled', searcher.noMatch)), kf.button(() => searcher.findNext(), '\u232A', kd.toggleClass('disabled', searcher.noMatch)), ), ]]; } // Calls the given callback on 'change' event and within delayMs of any 'input' event. // TODO: This duplicates part of functionality of koForm.textInput(), so the two could be unified. function bindChangeOrDelay(input: HTMLInputElement, cb: (value: string) => void, delayMs: number) { const debounced = debounce((e: Event) => cb(input.value), delayMs); dom.on(input, 'input', debounced); dom.on(input, 'change', (e: Event) => { debounced(e); debounced.flush(); }); } interface SearchPosition { tabIndex: number; sectionIndex: number; rowIndex: number; fieldIndex: number; } 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]; } } class Searcher { public isRunning = ko.observable(false); public noMatch = ko.observable(true); private _searchRegexp: RegExp; private _tabStepper = new Stepper(); private _sectionStepper = new Stepper(); private _sectionTableData: TableData; private _rowStepper = new Stepper(); private _fieldStepper = new Stepper(); private _fieldFormatters: BaseFormatter[]; private _startPosition: SearchPosition; private _tabsSwitched: number = 0; constructor(private _gristDoc: GristDoc) {} public findFirst(value: string) { if (!value) { this.noMatch(true); return; } this._searchRegexp = makeRegexp(value); const tabs: any[] = this._gristDoc.docModel.allTabs.peek(); this._tabStepper.array = tabs; this._tabStepper.index = tabs.findIndex(t => t.viewRef() === this._gristDoc.activeViewId.get()); if (this._tabStepper.index < 0) { this.noMatch(true); return; } const view = this._tabStepper.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) { this.noMatch(true); return; } 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; this._startPosition = this._getCurrentPosition(); return this._matchNext(1); } public async findNext() { this._startPosition = this._getCurrentPosition(); await this._nextField(1); return this._matchNext(1); } public async findPrev() { this._startPosition = this._getCurrentPosition(); await this._nextField(-1); return this._matchNext(-1); } private async _matchNext(step: number): Promise { const indicatorTimer = setTimeout(() => this.isRunning(true), 300); try { const searchRegexp = this._searchRegexp; let count = 0; let lastBreak = Date.now(); this._tabsSwitched = 0; while (!this._matches() || ((await this._loadSection(step)) && !this._matches())) { // 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(); // After other code had a chance to run, it's possible that we are now searching for // something else, in which case abort this task. if (this._searchRegexp !== searchRegexp) { console.log("SearchBar: aborting search since a new one was started"); return; } } 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)) { console.log("SearchBar: reached start position without finding anything"); this.noMatch(true); return; } // A fail-safe to prevent certain bugs from causing infinite loops; break also if we stan // through tabs too many times. // TODO: test it by disabling the check above. if (this._tabsSwitched > this._tabStepper.array.length) { console.log("SearchBar: aborting search due to too many tab switches"); this.noMatch(true); return; } } console.log("SearchBar: found a match at %s", JSON.stringify(this._getCurrentPosition())); this.noMatch(false); await this._highlight(); } finally { clearTimeout(indicatorTimer); this.isRunning(false); } } private _getCurrentPosition(): SearchPosition { // It's important to call _getCurrentPosition() in the visible tab, since other tabs will not // use the currently visible version of the data (with the same sort and filter). return { tabIndex: this._tabStepper.index, sectionIndex: this._sectionStepper.index, rowIndex: this._rowStepper.index, fieldIndex: this._fieldStepper.index, }; } private _isCurrentPosition(pos: SearchPosition): boolean { return ( this._tabStepper.index === pos.tabIndex && this._sectionStepper.index === pos.sectionIndex && this._rowStepper.index === pos.rowIndex && this._fieldStepper.index === pos.fieldIndex ); } private _nextField(step: number): Promise|void { return this._fieldStepper.next(step, () => this._nextRow(step)); // console.log("nextField", this._fieldStepper.index); } private _nextRow(step: number) { return this._rowStepper.next(step, () => this._nextSection(step)); // console.log("nextRow", this._rowStepper.index); } 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._nextTab(step)); // console.log("nextSection", this._sectionStepper.index); 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 => createFormatter(f.displayColModel().type(), f.widgetOptionsJson(), f.documentSettings())); return tableModel; } private _initNewSectionShown() { this._initNewSectionCommon(); const viewInstance = this._sectionStepper.value.viewInstance.peek(); this._rowStepper.array = viewInstance.sortedRows.getKoArray().peek(); } private async _initNewSectionAny() { const tableModel = this._initNewSectionCommon(); const viewInstance = this._sectionStepper.value.viewInstance.peek(); if (viewInstance) { this._rowStepper.array = viewInstance.sortedRows.getKoArray().peek(); } else { // If we are searching through another tab (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 tab and we'll repeat the search with a viewInstance. await tableModel.fetch(); this._rowStepper.array = this._sectionTableData.getRowIds(); } } private async _nextTab(step: number) { await this._tabStepper.next(step, () => undefined); this._tabsSwitched++; // console.log("nextTab", this._tabStepper.index); const view = this._tabStepper.value.view.peek(); this._sectionStepper.array = view.viewSections().peek(); } private _matches(): boolean { if (this._tabStepper.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.format(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 tab, 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._tabStepper.value.view.peek(); await this._gristDoc.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() { const view = this._tabStepper.value.view.peek(); await this._gristDoc.openDocPage(view.getRowId()); const section = this._sectionStepper.value; view.activeSectionId(section.getRowId()); // We may need to wait for the BaseView instance to load. const viewInstance = await waitObs(section.viewInstance); await viewInstance.getLoadingDonePromise(); viewInstance.setCursorPos({ rowIndex: this._rowStepper.index, fieldIndex: this._fieldStepper.index, }); // 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 cursor = viewInstance.viewPane.querySelector('.selected_cursor'); if (cursor) { cursor.classList.add('search-match'); setTimeout(() => cursor.classList.remove('search-match'), 20); } } } function makeRegexp(value: string) { // From https://stackoverflow.com/a/3561711/328565 const escaped = value.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); return new RegExp(escaped, 'i'); }