(core) Document keeps track of latest cursor position and latest editor value and is able to restore them when it is reloaded.
Summary: Grist document, when reloaded, is able to restore the latest cursor position and the editor state. Test Plan: Browser test were created. Reviewers: dsagal Reviewed By: dsagal Subscribers: paulfitz Differential Revision: https://phab.getgrist.com/D2808pull/23/head
parent
f5e3a0a94d
commit
5f182841b9
@ -0,0 +1,65 @@
|
||||
import { CursorPos } from "app/client/components/Cursor";
|
||||
import { DocModel } from "app/client/models/DocModel";
|
||||
|
||||
/**
|
||||
* Absolute position of a cell in a document
|
||||
*/
|
||||
export interface CellPosition {
|
||||
sectionId: number;
|
||||
rowId: number;
|
||||
colRef: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if two positions are equal.
|
||||
* @param a First position
|
||||
* @param b Second position
|
||||
*/
|
||||
export function samePosition(a: CellPosition, b: CellPosition) {
|
||||
return a && b && a.colRef == b.colRef &&
|
||||
a.sectionId == b.sectionId &&
|
||||
a.rowId == b.rowId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts cursor position to cell absolute positions. Return null if the conversion is not
|
||||
* possible (if cursor position doesn't have enough information)
|
||||
* @param position Cursor position
|
||||
* @param docModel Document model
|
||||
*/
|
||||
export function fromCursor(position: CursorPos, docModel: DocModel): CellPosition | null {
|
||||
if (!position.sectionId || !position.rowId || position.fieldIndex == null)
|
||||
return null;
|
||||
|
||||
const section = docModel.viewSections.getRowModel(position.sectionId);
|
||||
const colRef = section.viewFields().peek()[position.fieldIndex]?.colRef.peek();
|
||||
|
||||
const cursorPosition = {
|
||||
rowId: position.rowId,
|
||||
colRef,
|
||||
sectionId: position.sectionId,
|
||||
};
|
||||
|
||||
return cursorPosition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts cell's absolute position to current cursor position.
|
||||
* @param position Cell's absolute position
|
||||
* @param docModel DocModel
|
||||
*/
|
||||
export function toCursor(position: CellPosition, docModel: DocModel): CursorPos {
|
||||
|
||||
// translate colRef to fieldIndex
|
||||
const fieldIndex = docModel.viewSections.getRowModel(position.sectionId)
|
||||
.viewFields().peek()
|
||||
.findIndex(x => x.colRef.peek() == position.colRef);
|
||||
|
||||
const cursorPosition = {
|
||||
rowId: position.rowId,
|
||||
fieldIndex,
|
||||
sectionId: position.sectionId
|
||||
};
|
||||
|
||||
return cursorPosition;
|
||||
}
|
@ -0,0 +1,119 @@
|
||||
import { CursorPos } from "app/client/components/Cursor";
|
||||
import { getStorage } from "app/client/lib/localStorageObs";
|
||||
import { IDocPage } from "app/common/gristUrls";
|
||||
import { Disposable } from "grainjs";
|
||||
import { GristDoc } from "app/client/components/GristDoc";
|
||||
|
||||
/**
|
||||
* Enriched cursor position with a view id
|
||||
*/
|
||||
export type ViewCursorPos = CursorPos & { viewId: number }
|
||||
|
||||
/**
|
||||
* 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;
|
||||
// document id that this monitor is attached
|
||||
private _docId: 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);
|
||||
this._docId = doc.docId();
|
||||
|
||||
/**
|
||||
* 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
|
||||
if (!this._restored) return;
|
||||
if (pos) this.storePosition(pos);
|
||||
}))
|
||||
}
|
||||
|
||||
private _whenDocumentLoadsRestorePosition(doc: GristDoc) {
|
||||
// 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);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private storePosition(pos: ViewCursorPos) {
|
||||
this._store.update(this._docId, pos);
|
||||
}
|
||||
|
||||
private restoreLastPosition(view: IDocPage) {
|
||||
const lastPosition = this._store.read(this._docId);
|
||||
this._store.clear(this._docId);
|
||||
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}`;
|
||||
}
|
||||
}
|
@ -0,0 +1,178 @@
|
||||
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";
|
||||
|
||||
/**
|
||||
* Feature for GristDoc that allows it to keep track of current editor's state.
|
||||
* State is stored in local storage by default.
|
||||
*/
|
||||
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);
|
||||
|
||||
constructor(
|
||||
doc: GristDoc,
|
||||
store?: Storage) {
|
||||
super();
|
||||
|
||||
// create store
|
||||
this._store = new EditMemoryStorage(doc.docId(), store);
|
||||
|
||||
// listen to document events to handle view load event
|
||||
this._listenToReload(doc)
|
||||
}
|
||||
|
||||
/**
|
||||
* Monitors a field editor and updates latest edit position
|
||||
* @param editor Field editor to track
|
||||
*/
|
||||
public monitorEditor(editor: FieldEditor) {
|
||||
// typed helper to connect to the emitter
|
||||
const on = typedListener(this);
|
||||
// When user cancels the edit process, discard the memory of the last edited cell.
|
||||
on(editor.cancelEmitter, (event) => {
|
||||
this._store.clear();
|
||||
});
|
||||
// When saves a cell, discard the memory of the last edited cell.
|
||||
on(editor.saveEmitter, (event) => {
|
||||
this._store.clear();
|
||||
});
|
||||
// When user types in the editor, store its state
|
||||
on(editor.changeEmitter, (event) => {
|
||||
this._store.updateValue(event.position, event.currentState);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// 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 });
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// read the value from the storage
|
||||
private _restorePosition() {
|
||||
const entry = this._store.readValue();
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
// Internal implementation, not relevant to the main use case
|
||||
|
||||
// typed listener for the Emitter class
|
||||
function typedListener(owner: IDisposableOwner) {
|
||||
return function (emitter: Emitter, clb: (e: FieldEditorStateEvent) => any) {
|
||||
owner.autoDispose(emitter.addListener(clb));
|
||||
};
|
||||
}
|
||||
|
||||
// Marker for a editor state - each editor can report any data as long as it is serialized
|
||||
type EditorState = any;
|
||||
|
||||
// Schema for value stored in the local storage
|
||||
type LastEditData = {
|
||||
// absolute position for a cell
|
||||
position: CellPosition,
|
||||
// editor's state
|
||||
value: EditorState
|
||||
}
|
||||
|
||||
// Abstraction for working with local storage
|
||||
class EditMemoryStorage {
|
||||
|
||||
private _entry: LastEditData | null = null;
|
||||
private _timestamp = 0;
|
||||
|
||||
constructor(private _docId: string, private storage = getStorage()) {
|
||||
}
|
||||
|
||||
public updateValue(pos: CellPosition, value: EditorState): void {
|
||||
this._entry = { position: pos, value: value };
|
||||
this.save();
|
||||
}
|
||||
|
||||
public readValue(): LastEditData | null {
|
||||
this.load();
|
||||
return this._entry;
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this._entry = null;
|
||||
this.save();
|
||||
}
|
||||
|
||||
public timestamp(): number {
|
||||
return this._timestamp;
|
||||
}
|
||||
|
||||
protected _key() {
|
||||
return `grist-last-edit-${this._docId}`;
|
||||
}
|
||||
|
||||
protected load() {
|
||||
const storage = this.storage;
|
||||
const data = storage.getItem(this._key());
|
||||
this._entry = null;
|
||||
this._timestamp = 0;
|
||||
|
||||
if (data) {
|
||||
try {
|
||||
const { entry, timestamp } = JSON.parse(data);
|
||||
if (typeof entry === 'undefined' || typeof timestamp != 'number') {
|
||||
console.error("[EditMemory] Data in local storage has a different structure");
|
||||
return;
|
||||
}
|
||||
this._entry = entry
|
||||
this._timestamp = timestamp;
|
||||
} catch (e) {
|
||||
console.error("[EditMemory] Can't deserialize date from local storage");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected save(): void {
|
||||
const storage = this.storage;
|
||||
|
||||
// if entry was removed - clear the storage
|
||||
if (!this._entry) {
|
||||
storage.removeItem(this._key());
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this._timestamp = Date.now();
|
||||
const data = { timestamp: this._timestamp, entry: this._entry };
|
||||
storage.setItem(this._key(), JSON.stringify(data));
|
||||
} catch (ex) {
|
||||
console.error("Can't save current edited cell state. Error message: " + ex?.message);
|
||||
}
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 742 B |
Loading…
Reference in new issue