gristlabs_grist-core/app/client/components/Cursor.ts
Janet Vorobyeva 598b8260b6 Fixed cursorEdited() for properRowId()
Previously I updated cursorEdited on rowIndex() change

However, this ran into the exact same bug that properRowId was
created for, namely that if the data changes (filtered out), then
rowIndex won't update. Switched the dependency to properRowId
seems to fix it.

Also, weird timing/dependency stuff, need to subscribe version
AFTER subscribing activeRowId
2023-09-25 15:19:39 -07:00

194 lines
9.1 KiB
TypeScript

/**
* The Cursor module contains functionality related to the cell with the cursor, i.e. a single
* currently selected cell.
*/
import BaseView from 'app/client/components/BaseView';
import * as commands from 'app/client/components/commands';
import BaseRowModel from 'app/client/models/BaseRowModel';
import {LazyArrayModel} from 'app/client/models/DataTableModel';
import {CursorPos, UIRowId} from 'app/plugin/GristAPI';
import {Disposable} from 'grainjs';
import * as ko from 'knockout';
function nullAsUndefined<T>(value: T|null|undefined): T|undefined {
return value == null ? undefined : value;
}
// ================ SequenceNum: used to keep track of cursor edits (lastEditedAt)
// basically just a global auto-incrementing counter, with some types to make intent a bit more clear
// Each cursor starts at SequenceNEVER (0), and after that all cursors using NextSequenceNum() will have
// a unique, monotonically increasing number for their lastEditedAt()
export type SequenceNum = number;
export const SequenceNEVER: SequenceNum = 0;
let latestGlobalSequenceNum = SequenceNEVER;
const nextSequenceNum = () => { return latestGlobalSequenceNum++; };
// Note: we don't make any provisions for handling overflow. It's fine because:
// - Number.MAX_SAFE_INTEGER is 9,007,199,254,740,991 (9 * 10^15)
// - even at 1000 cursor-edits per second, it would take ~300,000 yrs to overflow
// - Plus it's client-side, so that's a single continuous 300-millenia-long session, which would be impressive uptime
/**
* Cursor represents the location of the cursor in the viewsection. It is maintained by BaseView,
* and implements the shared functionality related to the cursor cell.
* @param {BaseView} baseView: The BaseView object to which this Cursor belongs.
* @param {Object} optCursorPos: Optional object containing rowId and fieldIndex properties
* to which the cursor should be initialized.
*/
export class Cursor extends Disposable {
/**
* The commands closely tied to the cursor. They are active when the BaseView containing this
* Cursor has focus. Some may need to be overridden by particular views.
*/
public static editorCommands = {
// The cursor up/down commands may need to be a bit different in non-grid views.
cursorUp(this: Cursor) { this.rowIndex(this.rowIndex()! - 1); },
cursorDown(this: Cursor) { this.rowIndex(this.rowIndex()! + 1); },
cursorLeft(this: Cursor) { this.fieldIndex(this.fieldIndex() - 1); },
cursorRight(this: Cursor) { this.fieldIndex(this.fieldIndex() + 1); },
skipUp(this: Cursor) { this.rowIndex(this.rowIndex()! - 5); },
skipDown(this: Cursor) { this.rowIndex(this.rowIndex()! + 5); },
pageUp(this: Cursor) { this.rowIndex(this.rowIndex()! - 20); }, // TODO Not really pageUp
pageDown(this: Cursor) { this.rowIndex(this.rowIndex()! + 20); }, // TODO Not really pageDown
prevField(this: Cursor) { this.fieldIndex(this.fieldIndex() - 1); },
nextField(this: Cursor) { this.fieldIndex(this.fieldIndex() + 1); },
moveToFirstRecord(this: Cursor) { this.rowIndex(0); },
moveToLastRecord(this: Cursor) { this.rowIndex(Infinity); },
moveToFirstField(this: Cursor) { this.fieldIndex(0); },
moveToLastField(this: Cursor) { this.fieldIndex(Infinity); },
};
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>;
private _rowId: ko.Observable<UIRowId|null>; // May be null when there are no rows.
// The cursor's _rowId property is always fixed across data changes. When isLive is true,
// 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>;
private _properRowId: ko.Computed<UIRowId|null>;
private _lastEditedAt: ko.Observable<SequenceNum>;
private _silentUpdatesFlag: boolean = false;
// lastEditedAt is updated on rowIndex or fieldIndex update (including through setCursorPos)
// _silentUpdatesFlag disables this, set when setCursorPos called from cursor link to prevent infinite loops
// WARNING: the flag approach will only work if ko observables work synchronously, which they appear to do.
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<UIRowId|null>(optCursorPos.rowId || 0);
this.rowIndex = this.autoDispose(ko.computed({
read: () => {
if (!this._isLive()) { return this.rowIndex.peek(); }
const rowId = this._rowId();
return rowId == null ? null : this.viewData.clampIndex(this.viewData.getRowIndexWithSub(rowId));
},
write: (index) => {
const rowIndex = index === null ? null : this.viewData.clampIndex(index);
this._rowId(rowIndex == null ? null : this.viewData.getRowId(rowIndex));
},
}));
this.fieldIndex = baseView.viewSection.viewFields().makeLiveIndex(optCursorPos.fieldIndex || 0);
this.autoDispose(commands.createGroup(Cursor.editorCommands, this, baseView.viewSection.hasFocus));
// RowId might diverge from the one stored in _rowId when the data changes (it is filtered out). So here
// we will calculate rowId based on rowIndex (so in reverse order), to have a proper value.
this._properRowId = this.autoDispose(ko.computed(() => {
const rowIndex = this.rowIndex();
const rowId = rowIndex === null ? null : this.viewData.getRowId(rowIndex);
return rowId;
}));
this._lastEditedAt = ko.observable(SequenceNEVER);
// update the section's activeRowId and lastCursorEdit when needed
this.autoDispose(this._properRowId.subscribe((rowId) => baseView.viewSection.activeRowId(rowId)));
this.autoDispose(this._lastEditedAt.subscribe((seqNum) => baseView.viewSection.lastCursorEdit(seqNum)));
// Update the cursor edit time if either the row or column change
// IMPORTANT: need to subscribe AFTER the properRowId->activeRowId subscription.
// (Cursor-linking observables depend on edit-version, and only peek at activeRowId. Therefore, make sure
// we set all values correctly, and only update version after that's been done)
this.autoDispose(this._properRowId.subscribe(() => { this.cursorEdited(); }));
this.autoDispose(this.fieldIndex.subscribe(() => { this.cursorEdited(); }));
// 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.
public getCursorPos(): CursorPos {
return {
rowId: nullAsUndefined(this._properRowId()),
rowIndex: nullAsUndefined(this.rowIndex()),
fieldIndex: this.fieldIndex(),
sectionId: this._sectionId()
};
}
/**
* Moves the cursor to the given position. Only moves the row if rowId or rowIndex is valid,
* preferring rowId.
*
* silentUpdate prevents lastEditedAt from being updated, so linking doesn't cause an infinite loop of updates
* @param cursorPos: Position as { rowId?, rowIndex?, fieldIndex? }, as from getCursorPos().
* @param silentUpdate: should only be set if this is a cascading update from cursor-linking
*/
public setCursorPos(cursorPos: CursorPos, silentUpdate: boolean = false): void {
//If updating as a result of links, we want to NOT update lastEditedAt
if(silentUpdate) { this._silentUpdatesFlag = true; }
//console.log(`CURSOR: ${silentUpdate}, silentUpdate=${this._silentUpdatesFlag}, lastUpdated = ${this.lastUpdated.peek()}`) //TODO JV DEBUG TEMP
if (cursorPos.rowId !== undefined && this.viewData.getRowIndex(cursorPos.rowId) >= 0) {
this.rowIndex(this.viewData.getRowIndex(cursorPos.rowId) );
} else if (cursorPos.rowIndex !== undefined && cursorPos.rowIndex >= 0) {
this.rowIndex(cursorPos.rowIndex);
} else {
// Write rowIndex to itself to force an update of rowId if needed.
this.rowIndex(this.rowIndex.peek());
}
if (cursorPos.fieldIndex !== undefined) {
this.fieldIndex(cursorPos.fieldIndex);
}
//console.log(`CURSOR-END: silentUpdate=${this._silentUpdatesFlag}, lastEditedAt = ${this._lastEditedAt.peek()} `); //TODO JV DEBUG TEMP
this._silentUpdatesFlag = false;
}
public setLive(isLive: boolean): void {
this._isLive(isLive);
}
//Should be called whenever the cursor is updated
//EXCEPT FOR: when cursor is set by linking
//this is used to determine which widget/cursor has most recently been touched,
//and therefore which one should be used to drive linking if there's a conflict
public cursorEdited(): void {
//If updating as a result of links, we want to NOT update lastEdited
if(!this._silentUpdatesFlag)
{ this._lastEditedAt(nextSequenceNum()); }
}
}