(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/D2808
This commit is contained in:
Jarosław Sadziński
2021-05-17 16:05:49 +02:00
parent f5e3a0a94d
commit 5f182841b9
27 changed files with 800 additions and 117 deletions

View File

@@ -25,6 +25,7 @@ function AceEditor(options) {
this.saveValueOnBlurEvent = !(options && (options.saveValueOnBlurEvent === false));
this.calcSize = (options && options.calcSize) || ((elem, size) => size);
this.gristDoc = (options && options.gristDoc) || null;
this.editorState = (options && options.editorState) || null;
this.editor = null;
this.editorDom = null;
@@ -159,6 +160,15 @@ AceEditor.prototype.adjustContentToWidth = function() {
}
};
/**
* Provides opportunity to execute some functionality when value in the editor has changed.
* Happens every time user types something to the control.
*/
AceEditor.prototype.onChange = function() {
if (this.editorState) this.editorState.set(this.getValue());
this.resize();
};
AceEditor.prototype.setFontSize = function(pxVal) {
this.editor.setFontSize(pxVal);
this.resize();
@@ -186,7 +196,7 @@ AceEditor.prototype._setup = function() {
this.session.setTabSize(2);
this.session.setUseWrapMode(true);
this.editor.on('change', this.resize.bind(this));
this.editor.on('change', this.onChange.bind(this));
this.editor.$blockScrolling = Infinity;
this.editor.setFontSize(11);
this.resize();

View File

@@ -440,7 +440,7 @@ export class ActionLog extends dispose.Disposable implements IDomComponent {
const fieldIndex = viewSection.viewFields().peek().findIndex((f: any) => f.colId.peek() === colId);
// Finally, move cursor position to the section, column (if we found it), and row.
this._gristDoc.moveToCursorPos({rowId, sectionId, fieldIndex});
this._gristDoc.moveToCursorPos({rowId, sectionId, fieldIndex}).catch(() => { /* do nothing */});
}
}

View File

@@ -230,7 +230,7 @@ _.extend(Base.prototype, BackboneEvents);
* These commands are common to GridView and DetailView.
*/
BaseView.commonCommands = {
input: function(input) { this.activateEditorAtCursor(input); },
input: function(input) { this.activateEditorAtCursor({init: input}); },
editField: function() { this.activateEditorAtCursor(); },
insertRecordBefore: function() { this.insertRow(this.cursor.rowIndex()); },
@@ -269,7 +269,7 @@ BaseView.prototype.getLoadingDonePromise = function() {
* @param {String} input: If given, initialize the editor with the given input (rather than the
* original content of the cell).
*/
BaseView.prototype.activateEditorAtCursor = function(input) {
BaseView.prototype.activateEditorAtCursor = function(options) {
var builder = this.activeFieldBuilder();
if (builder.isEditorActive()) {
return;
@@ -286,7 +286,7 @@ BaseView.prototype.activateEditorAtCursor = function(input) {
return;
}
this.editRowModel.assign(rowId);
builder.buildEditorDom(this.editRowModel, lazyRow, { 'init': input });
builder.buildEditorDom(this.editRowModel, lazyRow, options || {});
}
};

View File

@@ -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;
}

View File

@@ -60,6 +60,8 @@ export class Cursor extends Disposable {
};
public viewData: LazyArrayModel<BaseRowModel>;
// observable with current cursor position
public currentPosition: ko.Computed<CursorPos>;
public rowIndex: ko.Computed<number|null>; // May be null when there are no rows.
public fieldIndex: ko.Observable<number>;
@@ -70,12 +72,14 @@ export class Cursor extends Disposable {
// the rowIndex of the cursor is recalculated to match _rowId. When false, they will
// be out of sync.
private _isLive: ko.Observable<boolean> = ko.observable(true);
private _sectionId: ko.Computed<number>;
constructor(baseView: BaseView, optCursorPos?: CursorPos) {
super();
optCursorPos = optCursorPos || {};
this.viewData = baseView.viewData;
this._sectionId = this.autoDispose(ko.computed(() => baseView.viewSection.id()))
this._rowId = ko.observable(optCursorPos.rowId || 0);
this.rowIndex = this.autoDispose(ko.computed({
read: () => {
@@ -97,6 +101,9 @@ export class Cursor extends Disposable {
// On dispose, save the current cursor position to the section model.
this.onDispose(() => { baseView.viewSection.lastCursorPos = this.getCursorPos(); });
// calculate current position
this.currentPosition = this.autoDispose(ko.computed(() => this._isLive() ? this.getCursorPos() : {}));
}
// Returns the cursor position with rowId, rowIndex, and fieldIndex.
@@ -104,7 +111,8 @@ export class Cursor extends Disposable {
return {
rowId: nullAsUndefined(this._rowId()),
rowIndex: nullAsUndefined(this.rowIndex()),
fieldIndex: this.fieldIndex()
fieldIndex: this.fieldIndex(),
sectionId: this._sectionId()
};
}

View File

@@ -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}`;
}
}

View File

@@ -47,6 +47,10 @@
outline: 2px dashed var(--grist-color-cursor);
}
.g_record_detail_value.draft {
padding-right: 18px;
}
.detail_row_num {
text-align: right;
font-size: var(--grist-x-small-font-size);

View File

@@ -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);
}
}
}

View File

@@ -424,7 +424,7 @@ GridView.prototype.clearValues = function(selection) {
const options = this._getColumnMenuOptions(selection);
if (options.isFormula === true) {
this.activateEditorAtCursor('');
this.activateEditorAtCursor({ init: ''});
} else {
let clearAction = tableUtil.makeDeleteAction(selection);
if (clearAction) {

View File

@@ -51,6 +51,9 @@ import {IDisposable, Observable, styled} from 'grainjs';
import * as ko from 'knockout';
import cloneDeepWith = require('lodash/cloneDeepWith');
import isEqual = require('lodash/isEqual');
import * as BaseView from 'app/client/components/BaseView';
import { CursorMonitor, ViewCursorPos } from "app/client/components/CursorMonitor";
import { EditorMonitor } from "app/client/components/EditorMonitor";
const G = getBrowserGlobals('document', 'window');
@@ -94,6 +97,10 @@ export class GristDoc extends DisposableWithEvents {
public isReadonly = this.docPageModel.isReadonly;
public isReadonlyKo = toKo(ko, this.isReadonly);
public comparison: DocStateComparison|null;
// component for keeping track of latest cursor position
public cursorMonitor: CursorMonitor;
// component for keeping track of a cell that is being edited
public editorMonitor: EditorMonitor;
// Emitter triggered when the main doc area is resized.
public readonly resizeEmitter = this.autoDispose(new Emitter());
@@ -103,6 +110,12 @@ export class GristDoc extends DisposableWithEvents {
// most one instance of FieldEditor at any time.
public readonly fieldEditorHolder = Holder.create(this);
// Holds current view that is currently rendered
public currentView : Observable<BaseView | null>;
// Holds current cursor position with a view id
public cursorPosition : Computed<ViewCursorPos | undefined>;
private _actionLog: ActionLog;
private _undoStack: UndoStack;
private _lastOwnActionGroup: ActionGroupWithCursorPos|null = null;
@@ -160,8 +173,8 @@ export class GristDoc extends DisposableWithEvents {
this.autoDispose(subscribe(urlState().state, async (use, state) => {
if (state.hash) {
try {
const cursorPos = getCursorPosFromHash(state.hash);
await this._recursiveMoveToCursorPos(cursorPos, true, state.hash && state.hash.colRef);
const cursorPos = this._getCursorPosFromHash(state.hash);
await this.recursiveMoveToCursorPos(cursorPos, true);
} catch (e) {
reportError(e);
} finally {
@@ -226,6 +239,45 @@ export class GristDoc extends DisposableWithEvents {
// On window resize, trigger the resizeEmitter to update ViewLayout and individual BaseViews.
this.autoDispose(dom.onElem(window, 'resize', () => this.resizeEmitter.emit()));
// create current view observer
this.currentView = Observable.create<BaseView | null>(this, null);
// first create a computed observable for current view
const viewInstance = Computed.create(this, (use) => {
const section = use(this.viewModel.activeSection);
const view = use(section.viewInstance);
return view;
});
// 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();
this.currentView.set(view);
}))
// create observable for current cursor position
this.cursorPosition = Computed.create<ViewCursorPos | undefined>(this, use => {
// get the BaseView
const view = use(viewInstance);
if (!view) return undefined;
// get current viewId
const viewId = use(this.activeViewId);
if (typeof viewId != 'number') return undefined;
// read latest position
const currentPosition = use(view.cursor.currentPosition);
if (currentPosition) return { ...currentPosition, viewId }
return undefined;
});
this.cursorMonitor = CursorMonitor.create(this, this);
this.editorMonitor = EditorMonitor.create(this, this);
}
/**
* Returns current document's id
*/
public docId() {
return this.docPageModel.currentDocId.get()!;
}
public addOptionsTab(label: string, iconElem: any, contentObj: TabContent[], options: TabOptions): IDisposable {
@@ -271,7 +323,7 @@ export class GristDoc extends DisposableWithEvents {
* Switch to the view/section and scroll to the record indicated by cursorPos. If cursorPos is
* null, then moves to a position best suited for optActionGroup (not yet implemented).
*/
public moveToCursorPos(cursorPos?: CursorPos, optActionGroup?: ActionGroup): void {
public async moveToCursorPos(cursorPos?: CursorPos, optActionGroup?: ActionGroup): Promise<void> {
if (!cursorPos || cursorPos.sectionId == null) {
// TODO We could come up with a suitable cursorPos here based on the action itself.
// This should only come up if trying to undo/redo after reloading a page (since the cursorPos
@@ -280,10 +332,14 @@ export class GristDoc extends DisposableWithEvents {
// place from any action in the action log.
return;
}
this._switchToSectionId(cursorPos.sectionId)
.then(viewInstance => (viewInstance && viewInstance.setCursorPos(cursorPos)))
.catch(reportError);
try {
const viewInstance = await this._switchToSectionId(cursorPos.sectionId)
if (viewInstance) {
viewInstance.setCursorPos(cursorPos);
}
} catch(e) {
reportError(e);
}
}
/**
@@ -530,7 +586,99 @@ export class GristDoc extends DisposableWithEvents {
return rulesTable.numRecords() > rulesTable.filterRowIds({permissionsText: '', permissions: 63}).length;
}
private _getToolContent(tool: typeof RightPanelTool.type): IExtraTool|null {
/**
* Move to the desired cursor position. If colRef is supplied, the cursor will be
* moved to a field with that colRef. Any linked sections that need their cursors
* moved in order to achieve the desired outcome are handled recursively.
* If setAsActiveSection is true, the section in cursorPos is set as the current
* active section.
*/
public async recursiveMoveToCursorPos(cursorPos: CursorPos, setAsActiveSection: boolean): Promise<void> {
try {
if (!cursorPos.sectionId) { throw new Error('sectionId required'); }
if (!cursorPos.rowId) { throw new Error('rowId required'); }
const section = this.docModel.viewSections.getRowModel(cursorPos.sectionId);
const srcSection = section.linkSrcSection.peek();
if (srcSection.id.peek()) {
// We're in a linked section, so we need to recurse to make sure the row we want
// will be visible.
const linkTargetCol = section.linkTargetCol.peek();
let controller: any;
if (linkTargetCol.colId.peek()) {
const destTable = await this._getTableData(section);
controller = destTable.getValue(cursorPos.rowId, linkTargetCol.colId.peek());
} else {
controller = cursorPos.rowId;
}
const colId = section.linkSrcCol.peek().colId.peek();
let srcRowId: any;
const isSrcSummary = srcSection.table.peek().summarySource.peek().id.peek();
if (!colId && !isSrcSummary) {
// Simple case - source linked by rowId, not a summary.
srcRowId = controller;
} else {
const srcTable = await this._getTableData(srcSection);
if (!colId) {
// must be a summary -- otherwise dealt with earlier.
const destTable = await this._getTableData(section);
const filter: { [key: string]: any } = {};
for (const c of srcSection.table.peek().columns.peek().peek()) {
if (c.summarySourceCol.peek()) {
const filterColId = c.summarySource.peek().colId.peek();
const destValue = destTable.getValue(cursorPos.rowId, filterColId);
filter[filterColId] = destValue;
}
}
const result = srcTable.filterRecords(filter); // Should just have one record, or 0.
srcRowId = result[0] && result[0].id;
} else {
srcRowId = srcTable.findRow(colId, controller);
}
}
if (!srcRowId || typeof srcRowId !== 'number') { throw new Error('cannot trace rowId'); }
await this.recursiveMoveToCursorPos({
rowId: srcRowId,
sectionId: srcSection.id.peek()
}, false);
}
const view: ViewRec = section.view.peek();
const viewId = view.getRowId();
if (viewId != this.activeViewId.get()) await this.openDocPage(view.getRowId());
if (setAsActiveSection) { view.activeSectionId(cursorPos.sectionId); }
const fieldIndex = cursorPos.fieldIndex;
const viewInstance = await waitObs(section.viewInstance);
if (!viewInstance) { throw new Error('view not found'); }
// Give any synchronous initial cursor setting a chance to happen.
await delay(0);
viewInstance.setCursorPos({ ...cursorPos, fieldIndex });
// TODO: column selection not working on card/detail view, or getting overridden -
// look into it (not a high priority for now since feature not easily discoverable
// in this view).
} catch (e) {
console.debug(`_recursiveMoveToCursorPos(${JSON.stringify(cursorPos)}): ${e}`);
throw new UserError('There was a problem finding the desired cell.');
}
}
/**
* Opens up an editor at cursor position
* @param input Optional. Cell's initial value
*/
public async activateEditorAtCursor(options: { init?: string, state?: any}) {
const view = await this.waitForView();
view?.activateEditorAtCursor(options);
}
/**
* Waits for a view to be ready
*/
private async waitForView() {
const view = await waitObs(this.viewModel.activeSection.peek().viewInstance);
await view?.getLoadingDonePromise();
return view;
}
private _getToolContent(tool: typeof RightPanelTool.type): IExtraTool | null {
switch (tool) {
case 'docHistory': {
return {icon: 'Log', label: 'Document History', content: this._docHistory};
@@ -623,80 +771,6 @@ export class GristDoc extends DisposableWithEvents {
return waitObs(section.viewInstance);
}
/**
* Move to the desired cursor position. If colRef is supplied, the cursor will be
* moved to a field with that colRef. Any linked sections that need their cursors
* moved in order to achieve the desired outcome are handled recursively.
* If setAsActiveSection is true, the section in cursorPos is set as the current
* active section.
*/
private async _recursiveMoveToCursorPos(cursorPos: CursorPos, setAsActiveSection: boolean,
colRef?: number): Promise<void> {
try {
if (!cursorPos.sectionId) { throw new Error('sectionId required'); }
if (!cursorPos.rowId) { throw new Error('rowId required'); }
const section = this.docModel.viewSections.getRowModel(cursorPos.sectionId);
const srcSection = section.linkSrcSection.peek();
if (srcSection.id.peek()) {
// We're in a linked section, so we need to recurse to make sure the row we want
// will be visible.
const linkTargetCol = section.linkTargetCol.peek();
let controller: any;
if (linkTargetCol.colId.peek()) {
const destTable = await this._getTableData(section);
controller = destTable.getValue(cursorPos.rowId, linkTargetCol.colId.peek());
} else {
controller = cursorPos.rowId;
}
const colId = section.linkSrcCol.peek().colId.peek();
let srcRowId: any;
const isSrcSummary = srcSection.table.peek().summarySource.peek().id.peek();
if (!colId && !isSrcSummary) {
// Simple case - source linked by rowId, not a summary.
srcRowId = controller;
} else {
const srcTable = await this._getTableData(srcSection);
if (!colId) {
// must be a summary -- otherwise dealt with earlier.
const destTable = await this._getTableData(section);
const filter: {[key: string]: any} = {};
for (const c of srcSection.table.peek().columns.peek().peek()) {
if (c.summarySourceCol.peek()) {
const filterColId = c.summarySource.peek().colId.peek();
const destValue = destTable.getValue(cursorPos.rowId, filterColId);
filter[filterColId] = destValue;
}
}
const result = srcTable.filterRecords(filter); // Should just have one record, or 0.
srcRowId = result[0] && result[0].id;
} else {
srcRowId = srcTable.findRow(colId, controller);
}
}
if (!srcRowId || typeof srcRowId !== 'number') { throw new Error('cannot trace rowId'); }
await this._recursiveMoveToCursorPos({
rowId: srcRowId,
sectionId: srcSection.id.peek()
}, false);
}
const view: ViewRec = section.view.peek();
await this.openDocPage(view.getRowId());
if (setAsActiveSection) { view.activeSectionId(cursorPos.sectionId); }
const fieldIndex = colRef ? section.viewFields().peek().findIndex(f => f.colRef.peek() === colRef) : undefined;
const viewInstance = await waitObs(section.viewInstance);
if (!viewInstance) { throw new Error('view not found'); }
// Give any synchronous initial cursor setting a chance to happen.
await delay(0);
viewInstance.setCursorPos({...cursorPos, fieldIndex});
// TODO: column selection not working on card/detail view, or getting overridden -
// look into it (not a high priority for now since feature not easily discoverable
// in this view).
} catch (e) {
console.debug(`_recursiveMoveToCursorPos(${JSON.stringify(cursorPos)}): ${e}`);
throw new UserError('There was a problem finding the desired cell.');
}
}
private async _getTableData(section: ViewSectionRec): Promise<TableData> {
const viewInstance = await waitObs(section.viewInstance);
if (!viewInstance) { throw new Error('view not found'); }
@@ -705,13 +779,21 @@ export class GristDoc extends DisposableWithEvents {
if (!table) { throw new Error('no section table'); }
return table;
}
}
/**
* Convert a url hash to a cursor position.
*/
function getCursorPosFromHash(hash: HashLink): CursorPos {
return { rowId: hash.rowId, sectionId: hash.sectionId };
/**
* Convert a url hash to a cursor position.
*/
private _getCursorPosFromHash(hash: HashLink): CursorPos {
const cursorPos : CursorPos = { rowId: hash.rowId, sectionId: hash.sectionId };
if (cursorPos.sectionId != undefined && hash.colRef !== undefined){
// translate colRef to a fieldIndex
const section = this.docModel.viewSections.getRowModel(cursorPos.sectionId);
const fieldIndex = section.viewFields.peek().all()
.findIndex(x=> x.colRef.peek() == hash.colRef);
if (fieldIndex >= 0) cursorPos.fieldIndex = fieldIndex;
}
return cursorPos;
}
}
async function finalizeAnchor() {

View File

@@ -117,13 +117,13 @@ export class UndoStack extends dispose.Disposable {
// context where the change was originally made. We jump first immediately to feel more
// responsive, then again when the action is done. The second jump matters more for most
// changes, but the first is the important one when Undoing an AddRecord.
this._gristDoc.moveToCursorPos(ag.cursorPos, ag);
this._gristDoc.moveToCursorPos(ag.cursorPos, ag).catch(() => {/* do nothing */})
await this._gristDoc.docComm.applyUserActionsById(
actionGroups.map(a => a.actionNum),
actionGroups.map(a => a.actionHash),
isUndo,
{ otherId: ag.actionNum });
this._gristDoc.moveToCursorPos(ag.cursorPos, ag);
this._gristDoc.moveToCursorPos(ag.cursorPos, ag).catch(() => {/* do nothing */})
} catch (err) {
err.message = `Failed to apply ${isUndo ? 'undo' : 'redo'} action: ${err.message}`;
throw err;

View File

@@ -47,6 +47,10 @@
background-color: var(--grist-color-selection);
}
.field.draft {
padding-right: 18px;
}
.field_clip {
padding: 3px 3px 0px 3px;
font-family: var(--grist-font-family-data);