gristlabs_grist-core/app/client/models/SearchModel.ts
Jarosław Sadziński c1de16aee7 (core) Scrolling to the active record on search
Summary:
Two bugs fixed:
1. On search, when the first result is in the active record, GridView wasn't scrolling to the active record.
2. When an active record was not visible, GridView wasn't scrolling to the active record when the column index was changed.

The problem was that the scrolling behavior was based only on rowIndex which isn't changed (and doesn't notify subscribers) when a column index changes or when the search highlights a cell.
This diff makes the computed depend also on the fieldIndex, and is introducing a new method that can scroll to the active record on demand (which is used by the search).

Test Plan: Updated tests.

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D3191
2021-12-21 09:57:21 +01:00

442 lines
17 KiB
TypeScript

// 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>; // string in the search input
isOpen: Observable<boolean>; // indicates whether the search bar is expanded to show the input
noMatch: Observable<boolean>; // indicates if there are no search matches
isEmpty: Observable<boolean>; // indicates whether the value is empty
isRunning: Observable<boolean>; // indicates that matching is in progress
multiPage: Observable<boolean>; // if true will search across all pages
findNext(): Promise<void>; // find next match
findPrev(): Promise<void>; // 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<T> {
public array: ReadonlyArray<T> = [];
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>|void): Promise<void>|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<void>; // next match
nextField(step: number): Promise<void>|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<void>;
/**
* An implementation of an IFinder.
*/
class FinderImpl implements IFinder {
public matchFound = false;
public startPosition: SearchPosition;
private _searchRegexp: RegExp;
private _pageStepper = new Stepper<any>();
private _sectionStepper = new Stepper<ViewSectionRec>();
private _sectionTableData: TableData;
private _rowStepper = new Stepper<number>();
private _fieldStepper = new Stepper<ViewFieldRec>();
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<boolean>) {
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<void> {
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>|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.format(value);
return this._searchRegexp.test(text);
}
private async _loadSection(step: number): Promise<boolean> {
// 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.
await viewInstance.revealActiveRecord();
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<void>) {
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');
}