mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Restoring cursor position on raw data views
Summary: This diff introduces cursor features for raw data views: - Restoring cursor position when the browser window is reloaded - Restoring the last edit position when the browser window is reloaded Test Plan: Added tests Reviewers: alexmojaki Reviewed By: alexmojaki Subscribers: jarek Differential Revision: https://phab.getgrist.com/D3314
This commit is contained in:
parent
3b76b33423
commit
96a34122a5
@ -236,6 +236,9 @@ BaseView.commonCommands = {
|
||||
* loading.
|
||||
*/
|
||||
BaseView.prototype.setCursorPos = function(cursorPos) {
|
||||
if (this.isDisposed()) {
|
||||
return;
|
||||
}
|
||||
if (!this._isLoading.peek()) {
|
||||
this.cursor.setCursorPos(cursorPos);
|
||||
} else {
|
||||
|
@ -1,13 +1,14 @@
|
||||
import {CursorPos} from 'app/client/components/Cursor';
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {getStorage} from 'app/client/lib/localStorageObs';
|
||||
import {IDocPage} from 'app/common/gristUrls';
|
||||
import {Disposable} from 'grainjs';
|
||||
import {IDocPage, isViewDocPage, ViewDocPage} from 'app/common/gristUrls';
|
||||
import {Disposable, Listener, Observable} from 'grainjs';
|
||||
import {reportError} from 'app/client/models/errors';
|
||||
|
||||
/**
|
||||
* Enriched cursor position with a view id
|
||||
*/
|
||||
export type ViewCursorPos = CursorPos & { viewId: number }
|
||||
export type ViewCursorPos = CursorPos & { viewId: ViewDocPage }
|
||||
|
||||
/**
|
||||
* Component for GristDoc that allows it to keep track of the latest cursor position.
|
||||
@ -65,27 +66,38 @@ export class CursorMonitor extends Disposable {
|
||||
return;
|
||||
}
|
||||
|
||||
// if we are on raw data view, we need to set the position manually
|
||||
// as currentView observable will not be changed.
|
||||
if (doc.activeViewId.get() === 'data') {
|
||||
this._doRestorePosition(doc).catch((e) => reportError(e));
|
||||
return;
|
||||
}
|
||||
|
||||
// on view shown
|
||||
this.autoDispose(doc.currentView.addListener(async view => {
|
||||
// if the position was restored for this document do nothing
|
||||
if (this._restored) { return; }
|
||||
// set that we already restored the position, as some view is shown to the user
|
||||
this._restored = true;
|
||||
// if view wasn't rendered (page is displaying history or code view) do nothing
|
||||
if (!view) { return; }
|
||||
const viewId = doc.activeViewId.get();
|
||||
const position = this._restoreLastPosition(viewId);
|
||||
if (position) {
|
||||
await doc.recursiveMoveToCursorPos(position, true);
|
||||
}
|
||||
this.autoDispose(oneTimeListener(doc.currentView, async () => {
|
||||
await this._doRestorePosition(doc);
|
||||
}));
|
||||
}
|
||||
|
||||
private async _doRestorePosition(doc: GristDoc) {
|
||||
// if the position was restored for this document do nothing
|
||||
if (this._restored) { return; }
|
||||
// set that we already restored the position, as some view is shown to the user
|
||||
this._restored = true;
|
||||
const viewId = doc.activeViewId.get();
|
||||
if (!isViewDocPage(viewId)) { return; }
|
||||
const position = this._readPosition(viewId);
|
||||
if (position) {
|
||||
// Ignore error with finding desired cell.
|
||||
await doc.recursiveMoveToCursorPos(position, true, true);
|
||||
}
|
||||
}
|
||||
|
||||
private _storePosition(pos: ViewCursorPos) {
|
||||
this._store.update(this._key, pos);
|
||||
}
|
||||
|
||||
private _restoreLastPosition(view: IDocPage) {
|
||||
private _readPosition(view: IDocPage) {
|
||||
const lastPosition = this._store.read(this._key);
|
||||
this._store.clear(this._key);
|
||||
if (lastPosition && lastPosition.position.viewId == view) {
|
||||
@ -128,3 +140,17 @@ class StorageWrapper {
|
||||
return `grist-last-position-${docId}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function oneTimeListener<T>(obs: Observable<T>, handler: (value: T) => any) {
|
||||
let listener: Listener|null = obs.addListener((value) => {
|
||||
setImmediate(dispose);
|
||||
handler(value);
|
||||
});
|
||||
function dispose() {
|
||||
if (listener) {
|
||||
listener.dispose();
|
||||
listener = null;
|
||||
}
|
||||
}
|
||||
return { dispose };
|
||||
}
|
||||
|
@ -1,8 +1,11 @@
|
||||
import { getStorage } from "app/client/lib/localStorageObs";
|
||||
import { Disposable, Emitter, Holder, IDisposableOwner } from "grainjs";
|
||||
import { GristDoc } from "app/client/components/GristDoc";
|
||||
import { FieldEditor, FieldEditorStateEvent } from "app/client/widgets/FieldEditor";
|
||||
import { CellPosition, toCursor } from "app/client/components/CellPosition";
|
||||
import {CellPosition, toCursor} from 'app/client/components/CellPosition';
|
||||
import {oneTimeListener} from 'app/client/components/CursorMonitor';
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {getStorage} from 'app/client/lib/localStorageObs';
|
||||
import {UserError} from 'app/client/models/errors';
|
||||
import {FieldEditor, FieldEditorStateEvent} from 'app/client/widgets/FieldEditor';
|
||||
import {isViewDocPage} from 'app/common/gristUrls';
|
||||
import {Disposable, Emitter, IDisposableOwner} from 'grainjs';
|
||||
|
||||
/**
|
||||
* Feature for GristDoc that allows it to keep track of current editor's state.
|
||||
@ -12,9 +15,7 @@ export class EditorMonitor extends Disposable {
|
||||
|
||||
// abstraction to work with local storage
|
||||
private _store: EditMemoryStorage;
|
||||
// Holds a listener that is attached to the current view.
|
||||
// It will be cleared after first trigger.
|
||||
private _currentViewListener = Holder.create(this);
|
||||
private _restored = false;
|
||||
|
||||
constructor(
|
||||
doc: GristDoc,
|
||||
@ -28,7 +29,14 @@ export class EditorMonitor extends Disposable {
|
||||
this._store = new EditMemoryStorage(key, store);
|
||||
|
||||
// listen to document events to handle view load event
|
||||
this._listenToReload(doc);
|
||||
this._listenToReload(doc).catch((err) => {
|
||||
if (!(err instanceof UserError)) {
|
||||
throw err;
|
||||
}
|
||||
// Don't report UserErrors for this feature (should not happen as
|
||||
// the only error that is thrown was silenced by recursiveMoveToCursorPos)
|
||||
console.error(`Error while restoring last edit position`, err);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -56,38 +64,36 @@ export class EditorMonitor extends Disposable {
|
||||
* When document gets reloaded, restore last cursor position and a state of the editor.
|
||||
* Returns last edited cell position and saved editor state or undefined.
|
||||
*/
|
||||
private _listenToReload(doc: GristDoc) {
|
||||
// subscribe to the current view event on the GristDoc, but make sure that the handler
|
||||
// will be invoked only once
|
||||
let executed = false;
|
||||
|
||||
private async _listenToReload(doc: GristDoc) {
|
||||
// don't restore on readonly mode or when there is custom nav
|
||||
if (doc.isReadonly.get() || doc.hasCustomNav.get()) { return; }
|
||||
|
||||
// on view shown
|
||||
this._currentViewListener.autoDispose(doc.currentView.addListener(async view => {
|
||||
if (executed) {
|
||||
// remove the listener - we can't do it while the listener is actively executing
|
||||
setImmediate(() => this._currentViewListener.clear());
|
||||
return;
|
||||
}
|
||||
executed = true;
|
||||
// if view wasn't rendered (page is displaying history or code view) do nothing
|
||||
if (!view) { return; }
|
||||
const lastEdit = this._restorePosition();
|
||||
if (lastEdit) {
|
||||
// set the cursor at right cell
|
||||
await doc.recursiveMoveToCursorPos(toCursor(lastEdit.position, doc.docModel), true);
|
||||
// activate the editor
|
||||
await doc.activateEditorAtCursor({ state: lastEdit.value });
|
||||
}
|
||||
}));
|
||||
// if we are on raw data view, we need to set the position manually
|
||||
// as currentView observable will not be changed.
|
||||
if (doc.activeViewId.get() === 'data') {
|
||||
await this._doRestorePosition(doc);
|
||||
} else {
|
||||
// on view shown
|
||||
this.autoDispose(oneTimeListener(doc.currentView, async () => {
|
||||
await this._doRestorePosition(doc);
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// read the value from the storage
|
||||
private _restorePosition() {
|
||||
const entry = this._store.readValue();
|
||||
return entry;
|
||||
private async _doRestorePosition(doc: GristDoc) {
|
||||
if (this._restored) {
|
||||
return;
|
||||
}
|
||||
this._restored = true;
|
||||
const viewId = doc.activeViewId.get();
|
||||
// if view wasn't rendered (page is displaying history or code view) do nothing
|
||||
if (!isViewDocPage(viewId)) { return; }
|
||||
const lastEdit = this._store.readValue();
|
||||
if (lastEdit) {
|
||||
// set the cursor at right cell
|
||||
await doc.recursiveMoveToCursorPos(toCursor(lastEdit.position, doc.docModel), true, true);
|
||||
// activate the editor
|
||||
await doc.activateEditorAtCursor({ state: lastEdit.value });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -54,7 +54,7 @@ import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
|
||||
import {isSchemaAction, UserAction} from 'app/common/DocActions';
|
||||
import {OpenLocalDocResult} from 'app/common/DocListAPI';
|
||||
import {isList, isRefListType, RecalcWhen} from 'app/common/gristTypes';
|
||||
import {HashLink, IDocPage, SpecialDocPage} from 'app/common/gristUrls';
|
||||
import {HashLink, IDocPage, isViewDocPage, SpecialDocPage, ViewDocPage} from 'app/common/gristUrls';
|
||||
import {undef, waitObs} from 'app/common/gutil';
|
||||
import {LocalPlugin} from "app/common/plugin";
|
||||
import {StringUnion} from 'app/common/StringUnion';
|
||||
@ -243,7 +243,6 @@ export class GristDoc extends DisposableWithEvents {
|
||||
tourStarting = true;
|
||||
try {
|
||||
await this._waitForView();
|
||||
await delay(0); // we need to wait an extra bit.
|
||||
|
||||
// Remove any tour-related hash-tags from the URL. So #repeat-welcome-tour and
|
||||
// #repeat-doc-tour are used as triggers, but will immediately disappear.
|
||||
@ -326,12 +325,13 @@ export class GristDoc extends DisposableWithEvents {
|
||||
const section = use(this.viewModel.activeSection);
|
||||
const viewId = use(activeViewId);
|
||||
const view = use(section.viewInstance);
|
||||
return (typeof viewId === 'number') ? view : null;
|
||||
return isViewDocPage(viewId) ? view : null;
|
||||
});
|
||||
// then listen if the view is present, because we still need to wait for it load properly
|
||||
this.autoDispose(viewInstance.addListener(async (view) => {
|
||||
if (!view) { return; }
|
||||
await view.getLoadingDonePromise();
|
||||
if (view) {
|
||||
await view.getLoadingDonePromise();
|
||||
}
|
||||
// finally set the current view as fully loaded
|
||||
this.currentView.set(view);
|
||||
}));
|
||||
@ -343,7 +343,7 @@ export class GristDoc extends DisposableWithEvents {
|
||||
if (!view) { return undefined; }
|
||||
// get current viewId
|
||||
const viewId = use(this.activeViewId);
|
||||
if (typeof viewId != 'number') { return undefined; }
|
||||
if (!isViewDocPage(viewId)) { return undefined; }
|
||||
// read latest position
|
||||
const currentPosition = use(view.cursor.currentPosition);
|
||||
if (currentPosition) { return { ...currentPosition, viewId }; }
|
||||
@ -737,7 +737,10 @@ export class GristDoc extends DisposableWithEvents {
|
||||
* If setAsActiveSection is true, the section in cursorPos is set as the current
|
||||
* active section.
|
||||
*/
|
||||
public async recursiveMoveToCursorPos(cursorPos: CursorPos, setAsActiveSection: boolean): Promise<void> {
|
||||
public async recursiveMoveToCursorPos(
|
||||
cursorPos: CursorPos,
|
||||
setAsActiveSection: boolean,
|
||||
silent: boolean = false): Promise<void> {
|
||||
try {
|
||||
if (!cursorPos.sectionId) { throw new Error('sectionId required'); }
|
||||
if (!cursorPos.rowId) { throw new Error('rowId required'); }
|
||||
@ -785,12 +788,12 @@ export class GristDoc extends DisposableWithEvents {
|
||||
if (!srcRowId || typeof srcRowId !== 'number') { throw new Error('cannot trace rowId'); }
|
||||
await this.recursiveMoveToCursorPos({
|
||||
rowId: srcRowId,
|
||||
sectionId: srcSection.id.peek()
|
||||
}, false);
|
||||
sectionId: srcSection.id.peek(),
|
||||
}, false, silent);
|
||||
}
|
||||
const view: ViewRec = section.view.peek();
|
||||
const viewId = view.getRowId();
|
||||
if (viewId != this.activeViewId.get()) { await this.openDocPage(view.getRowId()); }
|
||||
const docPage: ViewDocPage = section.isRaw.peek() ? "data" : view.getRowId();
|
||||
if (docPage != this.activeViewId.get()) { await this.openDocPage(docPage); }
|
||||
if (setAsActiveSection) { view.activeSectionId(cursorPos.sectionId); }
|
||||
const fieldIndex = cursorPos.fieldIndex;
|
||||
const viewInstance = await waitObs(section.viewInstance);
|
||||
@ -807,7 +810,9 @@ export class GristDoc extends DisposableWithEvents {
|
||||
await delay(0);
|
||||
} catch (e) {
|
||||
console.debug(`_recursiveMoveToCursorPos(${JSON.stringify(cursorPos)}): ${e}`);
|
||||
throw new UserError('There was a problem finding the desired cell.');
|
||||
if (!silent) {
|
||||
throw new UserError('There was a problem finding the desired cell.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -826,10 +831,15 @@ export class GristDoc extends DisposableWithEvents {
|
||||
private async _waitForView() {
|
||||
// For pages like ACL's, there isn't a view instance to wait for.
|
||||
if (!this.viewModel.activeSection.peek().getRowId()) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
const view = await waitObs(this.viewModel.activeSection.peek().viewInstance);
|
||||
await view?.getLoadingDonePromise();
|
||||
if (!view) {
|
||||
return null;
|
||||
}
|
||||
await view.getLoadingDonePromise();
|
||||
// Wait extra bit for scroll to happen.
|
||||
await delay(0);
|
||||
return view;
|
||||
}
|
||||
|
||||
|
@ -14,6 +14,14 @@ export const SpecialDocPage = StringUnion('code', 'acl', 'data', 'GristDocTour')
|
||||
type SpecialDocPage = typeof SpecialDocPage.type;
|
||||
export type IDocPage = number | SpecialDocPage;
|
||||
|
||||
export type ViewDocPage = number | 'data';
|
||||
/**
|
||||
* ViewDocPage is a page that shows table data (either normal or raw data view).
|
||||
*/
|
||||
export function isViewDocPage(docPage: IDocPage): docPage is ViewDocPage {
|
||||
return typeof docPage === 'number' || docPage === 'data';
|
||||
}
|
||||
|
||||
// What page to show in the user's home area. Defaults to 'workspace' if a workspace is set, and
|
||||
// to 'all' otherwise.
|
||||
export const HomePage = StringUnion('all', 'workspace', 'templates', 'trash');
|
||||
|
@ -2045,6 +2045,23 @@ export async function filterBy(col: IColHeader|string, save: boolean, values: (s
|
||||
await waitForServer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh browser and dismiss alert that is shown (for refreshing during edits).
|
||||
*/
|
||||
export async function refreshDismiss() {
|
||||
await driver.navigate().refresh();
|
||||
await (await driver.switchTo().alert()).accept();
|
||||
await waitForDocToLoad();
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirms that anchor link was used for navigation.
|
||||
*/
|
||||
export async function waitForAnchor() {
|
||||
await waitForDocToLoad();
|
||||
await driver.wait(async () => (await getTestState()).anchorApplied, 2000);
|
||||
}
|
||||
|
||||
} // end of namespace gristUtils
|
||||
|
||||
stackWrapOwnMethods(gristUtils);
|
||||
|
Loading…
Reference in New Issue
Block a user