You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
gristlabs_grist-core/app/client/components/EditorMonitor.ts

185 lines
5.6 KiB

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
const userId = doc.app.topAppModel.appObs.get()?.currentUser?.id ?? null;
// use document id and user id as a key for storage
const key = doc.docId() + userId;
this._store = new EditMemoryStorage(key, 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;
// 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 });
}
}));
}
// 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
interface 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 _key: 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 _storageKey() {
return `grist-last-edit-${this._key}`;
}
protected load() {
const storage = this._storage;
const data = storage.getItem(this._storageKey());
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._storageKey());
return;
}
try {
this._timestamp = Date.now();
const data = { timestamp: this._timestamp, entry: this._entry };
storage.setItem(this._storageKey(), JSON.stringify(data));
} catch (ex) {
console.error("Can't save current edited cell state. Error message: " + ex?.message);
}
}
}