gristlabs_grist-core/app/client/components/CursorMonitor.ts
Dmitry S 527e9670ef (core) Include linking rowIds into remembered cursor position and anchor links.
Summary:
When linking using a Reference List column, there may be multiple source
records that show the same target record. With this change, we remember those
(rather than just pick one that shows the target record).

Test Plan: Added a browser test.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D4140
2024-01-09 09:50:31 -05:00

177 lines
5.4 KiB
TypeScript

import {GristDoc} from 'app/client/components/GristDoc';
import {getStorage} from 'app/client/lib/storage';
import {IDocPage, isViewDocPage, ViewDocPage} from 'app/common/gristUrls';
import {Disposable, Listener, Observable} from 'grainjs';
import {reportError} from 'app/client/models/errors';
import {CursorPos} from 'app/plugin/GristAPI';
/**
* Enriched cursor position with a view id
*/
export type ViewCursorPos = CursorPos & { viewId: ViewDocPage }
/**
* 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;
// key for storing position in the memory (docId + userId)
private _key: string;
// 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);
// 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;
/**
* 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);
}
public clear() {
this._store.clear(this._key);
}
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
if (!this._restored) { return; }
// 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) {
if (pos.sectionId) {
pos = {...pos, linkingRowIds: doc.getLinkingRowIds(pos.sectionId)};
}
this._storePosition(pos);
}
}));
}
private _whenDocumentLoadsRestorePosition(doc: GristDoc) {
// if doc was opened with a hash link, don't restore last position
if (doc.hasCustomNav.get()) {
return this._abortRestore();
}
// 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(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 this._abortRestore();
}
const position = this._readPosition(viewId);
if (position) {
// Don't restore position if this is a collapsed section.
const collapsed = doc.viewModel.activeCollapsedSections.peek();
if (position.sectionId && collapsed.includes(position.sectionId)) {
return;
}
// Ignore error with finding desired cell.
await doc.recursiveMoveToCursorPos(position, true, true);
}
}
private _abortRestore() {
this.clear();
this._restored = true;
}
private _storePosition(pos: ViewCursorPos) {
this._store.update(this._key, pos);
}
private _readPosition(view: IDocPage) {
const lastPosition = this._store.read(this._key);
this._store.clear(this._key);
if (lastPosition && lastPosition.position.viewId == view) {
return lastPosition.position;
}
return null;
}
}
// Internal implementations for working with local storage
class StorageWrapper {
constructor(private _storage = getStorage()) {
}
public update(docId: string, position: ViewCursorPos): void {
try {
const storage = this._storage;
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 {
const storage = this._storage;
storage.removeItem(this._key(docId));
}
public read(docId: string): { docId: string; position: ViewCursorPos; } | undefined {
const storage = this._storage;
const result = storage.getItem(this._key(docId));
if (!result) { return undefined; }
return JSON.parse(result);
}
protected _key(docId: string) {
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 };
}