2021-07-22 09:14:35 +00:00
|
|
|
import {CursorPos} from 'app/client/components/Cursor';
|
|
|
|
import {GristDoc} from 'app/client/components/GristDoc';
|
|
|
|
import {getStorage} from 'app/client/lib/localStorageObs';
|
2022-03-22 12:38:56 +00:00
|
|
|
import {IDocPage, isViewDocPage, ViewDocPage} from 'app/common/gristUrls';
|
|
|
|
import {Disposable, Listener, Observable} from 'grainjs';
|
|
|
|
import {reportError} from 'app/client/models/errors';
|
2021-05-17 14:05:49 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Enriched cursor position with a view id
|
|
|
|
*/
|
2022-03-22 12:38:56 +00:00
|
|
|
export type ViewCursorPos = CursorPos & { viewId: ViewDocPage }
|
2021-05-17 14:05:49 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Component for GristDoc that allows it to keep track of the latest cursor position.
|
|
|
|
* In case, when a document is reloaded abnormally, the latest cursor
|
|
|
|
* position should be restored from a local storage.
|
|
|
|
*/
|
|
|
|
export class CursorMonitor extends Disposable {
|
|
|
|
|
|
|
|
// abstraction to work with local storage
|
|
|
|
private _store: StorageWrapper;
|
2021-11-18 22:54:37 +00:00
|
|
|
// key for storing position in the memory (docId + userId)
|
|
|
|
private _key: string;
|
2021-05-17 14:05:49 +00:00
|
|
|
// flag that tells if the position was already restored
|
|
|
|
// we track document's view change event, so we only want
|
|
|
|
// to react to that event once
|
|
|
|
private _restored = false;
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
doc: GristDoc,
|
|
|
|
store?: Storage) {
|
|
|
|
super();
|
|
|
|
|
|
|
|
this._store = new StorageWrapper(store);
|
2021-11-18 22:54:37 +00:00
|
|
|
|
|
|
|
// Use document id and user id as a key for storage.
|
|
|
|
const userId = doc.app.topAppModel.appObs.get()?.currentUser?.id ?? null;
|
|
|
|
this._key = doc.docId() + userId;
|
2021-05-17 14:05:49 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* When document loads last cursor position should be restored from local storage.
|
|
|
|
*/
|
|
|
|
this._whenDocumentLoadsRestorePosition(doc);
|
|
|
|
|
|
|
|
/**
|
|
|
|
* When a cursor position changes, its value is stored in a local storage.
|
|
|
|
*/
|
|
|
|
this._whenCursorHasChangedStoreInMemory(doc);
|
|
|
|
}
|
|
|
|
|
|
|
|
private _whenCursorHasChangedStoreInMemory(doc: GristDoc) {
|
|
|
|
// whenever current position changes, store it in the memory
|
|
|
|
this.autoDispose(doc.cursorPosition.addListener(pos => {
|
|
|
|
// if current position is not restored yet, don't change it
|
2021-05-23 17:43:11 +00:00
|
|
|
if (!this._restored) { return; }
|
2021-11-26 10:43:55 +00:00
|
|
|
// store position only when we have valid rowId
|
|
|
|
// for some views (like CustomView) cursor position might not reflect actual row
|
|
|
|
if (pos && pos.rowId !== undefined) { this._storePosition(pos); }
|
2021-05-23 17:43:11 +00:00
|
|
|
}));
|
2021-05-17 14:05:49 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
private _whenDocumentLoadsRestorePosition(doc: GristDoc) {
|
2021-07-22 09:14:35 +00:00
|
|
|
// if doc was opened with a hash link, don't restore last position
|
|
|
|
if (doc.hasCustomNav.get()) {
|
|
|
|
this._restored = true;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2022-03-22 12:38:56 +00:00
|
|
|
// 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;
|
|
|
|
}
|
|
|
|
|
2021-05-17 14:05:49 +00:00
|
|
|
// on view shown
|
2022-03-22 12:38:56 +00:00
|
|
|
this.autoDispose(oneTimeListener(doc.currentView, async () => {
|
|
|
|
await this._doRestorePosition(doc);
|
2021-05-17 14:05:49 +00:00
|
|
|
}));
|
|
|
|
}
|
|
|
|
|
2022-03-22 12:38:56 +00:00
|
|
|
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);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-05-23 17:43:11 +00:00
|
|
|
private _storePosition(pos: ViewCursorPos) {
|
2021-11-18 22:54:37 +00:00
|
|
|
this._store.update(this._key, pos);
|
2021-05-17 14:05:49 +00:00
|
|
|
}
|
|
|
|
|
2022-03-22 12:38:56 +00:00
|
|
|
private _readPosition(view: IDocPage) {
|
2021-11-18 22:54:37 +00:00
|
|
|
const lastPosition = this._store.read(this._key);
|
|
|
|
this._store.clear(this._key);
|
2021-05-17 14:05:49 +00:00
|
|
|
if (lastPosition && lastPosition.position.viewId == view) {
|
|
|
|
return lastPosition.position;
|
|
|
|
}
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Internal implementations for working with local storage
|
|
|
|
class StorageWrapper {
|
|
|
|
|
2021-05-23 17:43:11 +00:00
|
|
|
constructor(private _storage = getStorage()) {
|
2021-05-17 14:05:49 +00:00
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
public update(docId: string, position: ViewCursorPos): void {
|
|
|
|
try {
|
2021-05-23 17:43:11 +00:00
|
|
|
const storage = this._storage;
|
2021-05-17 14:05:49 +00:00
|
|
|
const data = { docId, position, timestamp: Date.now() };
|
|
|
|
storage.setItem(this._key(docId), JSON.stringify(data));
|
|
|
|
} catch (e) {
|
|
|
|
console.error("Can't store latest position in storage. Detail error " + e.message);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
public clear(docId: string,): void {
|
2021-05-23 17:43:11 +00:00
|
|
|
const storage = this._storage;
|
2021-05-17 14:05:49 +00:00
|
|
|
storage.removeItem(this._key(docId));
|
|
|
|
}
|
|
|
|
|
|
|
|
public read(docId: string): { docId: string; position: ViewCursorPos; } | undefined {
|
2021-05-23 17:43:11 +00:00
|
|
|
const storage = this._storage;
|
2021-05-17 14:05:49 +00:00
|
|
|
const result = storage.getItem(this._key(docId));
|
2021-05-23 17:43:11 +00:00
|
|
|
if (!result) { return undefined; }
|
2021-05-17 14:05:49 +00:00
|
|
|
return JSON.parse(result);
|
|
|
|
}
|
|
|
|
|
|
|
|
protected _key(docId: string) {
|
|
|
|
return `grist-last-position-${docId}`;
|
|
|
|
}
|
|
|
|
}
|
2022-03-22 12:38:56 +00:00
|
|
|
|
|
|
|
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 };
|
|
|
|
}
|