2020-10-02 15:10:00 +00:00
|
|
|
/**
|
|
|
|
* The Cursor module contains functionality related to the cell with the cursor, i.e. a single
|
|
|
|
* currently selected cell.
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
2022-07-04 14:14:55 +00:00
|
|
|
import BaseView from 'app/client/components/BaseView';
|
2020-10-02 15:10:00 +00:00
|
|
|
import * as commands from 'app/client/components/commands';
|
2022-07-04 14:14:55 +00:00
|
|
|
import BaseRowModel from 'app/client/models/BaseRowModel';
|
2020-10-02 15:10:00 +00:00
|
|
|
import {LazyArrayModel} from 'app/client/models/DataTableModel';
|
2023-08-28 09:16:17 +00:00
|
|
|
import {CursorPos, UIRowId} from 'app/plugin/GristAPI';
|
2020-10-02 15:10:00 +00:00
|
|
|
import {Disposable} from 'grainjs';
|
|
|
|
import * as ko from 'knockout';
|
|
|
|
|
2021-09-23 22:47:36 +00:00
|
|
|
function nullAsUndefined<T>(value: T|null|undefined): T|undefined {
|
2020-10-02 15:10:00 +00:00
|
|
|
return value == null ? undefined : value;
|
|
|
|
}
|
|
|
|
|
2023-09-25 22:48:18 +00:00
|
|
|
// ================ SequenceNum: used to keep track of cursor edits (lastEditedAt)
|
|
|
|
// Basically just a global auto-incrementing counter, with some types to make intent more clear
|
|
|
|
// Cursors are constructed at SequenceNEVER (0). After that, changes to their sequenceNum will go through
|
|
|
|
// NextSequenceNum(), so they'll have unique, monotonically increasing numbers for their lastEditedAt()
|
|
|
|
// NOTE: (by the time the page loads they'll already be at nonzero numbers, the never is intended to be transient)
|
|
|
|
export type SequenceNum = number;
|
|
|
|
export const SequenceNEVER: SequenceNum = 0; // Cursors will start here
|
|
|
|
let latestGlobalSequenceNum = SequenceNEVER;
|
|
|
|
function nextSequenceNum() { // First call to this func should return 1
|
|
|
|
latestGlobalSequenceNum++;
|
|
|
|
return latestGlobalSequenceNum;
|
|
|
|
}
|
|
|
|
|
|
|
|
// NOTE: If latestGlobalSequenceNum overflows, I think it would stop incrementing because of floating point imprecision
|
|
|
|
// However, we don't need to worry about overflow 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
|
|
|
|
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
/**
|
|
|
|
* 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>;
|
2021-05-17 14:05:49 +00:00
|
|
|
// observable with current cursor position
|
|
|
|
public currentPosition: ko.Computed<CursorPos>;
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
public rowIndex: ko.Computed<number|null>; // May be null when there are no rows.
|
|
|
|
public fieldIndex: ko.Observable<number>;
|
|
|
|
|
2023-07-07 08:54:01 +00:00
|
|
|
private _rowId: ko.Observable<UIRowId|null>; // May be null when there are no rows.
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
// 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);
|
2021-05-17 14:05:49 +00:00
|
|
|
private _sectionId: ko.Computed<number>;
|
2020-10-02 15:10:00 +00:00
|
|
|
|
2023-07-07 08:54:01 +00:00
|
|
|
private _properRowId: ko.Computed<UIRowId|null>;
|
|
|
|
|
2023-09-25 22:48:18 +00:00
|
|
|
// lastEditedAt is updated on _properRowId or fieldIndex update (including through setCursorPos)
|
|
|
|
// Used to determine which section takes priority for cursorLinking (specifically cycles/bidirectional linking)
|
|
|
|
private _lastEditedAt: ko.Observable<SequenceNum>;
|
|
|
|
// _silentUpdatesFlag prevents lastEditedAt from being updated, when a change in cursorPos isn't driven by the user.
|
|
|
|
// It's used when cursor linking calls setCursorPos, so that linked cursor moves don't trample lastEditedAt.
|
|
|
|
// WARNING: the flag approach relies on ko observables being resolved synchronously, may break if changed to grainjs?
|
|
|
|
private _silentUpdatesFlag: boolean = false;
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
constructor(baseView: BaseView, optCursorPos?: CursorPos) {
|
|
|
|
super();
|
|
|
|
optCursorPos = optCursorPos || {};
|
|
|
|
this.viewData = baseView.viewData;
|
|
|
|
|
2021-05-23 17:43:11 +00:00
|
|
|
this._sectionId = this.autoDispose(ko.computed(() => baseView.viewSection.id()));
|
2023-07-07 08:54:01 +00:00
|
|
|
this._rowId = ko.observable<UIRowId|null>(optCursorPos.rowId || 0);
|
2020-10-02 15:10:00 +00:00
|
|
|
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) => {
|
2023-07-07 08:54:01 +00:00
|
|
|
const rowIndex = index === null ? null : this.viewData.clampIndex(index);
|
2020-10-02 15:10:00 +00:00
|
|
|
this._rowId(rowIndex == null ? null : this.viewData.getRowId(rowIndex));
|
|
|
|
},
|
|
|
|
}));
|
|
|
|
|
|
|
|
this.fieldIndex = baseView.viewSection.viewFields().makeLiveIndex(optCursorPos.fieldIndex || 0);
|
2023-09-25 22:48:18 +00:00
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
this.autoDispose(commands.createGroup(Cursor.editorCommands, this, baseView.viewSection.hasFocus));
|
|
|
|
|
2023-07-07 08:54:01 +00:00
|
|
|
// 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;
|
|
|
|
}));
|
|
|
|
|
2023-09-25 22:48:18 +00:00
|
|
|
this._lastEditedAt = ko.observable(SequenceNEVER);
|
|
|
|
|
|
|
|
// update the section's activeRowId and lastCursorEdit when needed
|
2023-07-07 08:54:01 +00:00
|
|
|
this.autoDispose(this._properRowId.subscribe((rowId) => baseView.viewSection.activeRowId(rowId)));
|
2023-09-25 22:48:18 +00:00
|
|
|
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 lastCursorEdit, but only peek at activeRowId. Therefore, updating the
|
|
|
|
// edit time triggers a re-read of activeRowId, and swapping the order will read stale values for rowId)
|
|
|
|
// NOTE: this may update sequence number twice for a single edit, but this shouldn't cause any issues.
|
|
|
|
// For determining priority, this cursor will become the latest edited whether we call it once or twice.
|
|
|
|
// For updating observables, the double-update might cause cursor-linking observables in LinkingState to
|
|
|
|
// double-update, but it should be transient and get resolved immediately.
|
|
|
|
this.autoDispose(this._properRowId.subscribe(() => { this._cursorEdited(); }));
|
|
|
|
this.autoDispose(this.fieldIndex.subscribe(() => { this._cursorEdited(); }));
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
// On dispose, save the current cursor position to the section model.
|
|
|
|
this.onDispose(() => { baseView.viewSection.lastCursorPos = this.getCursorPos(); });
|
2021-05-17 14:05:49 +00:00
|
|
|
|
|
|
|
// calculate current position
|
|
|
|
this.currentPosition = this.autoDispose(ko.computed(() => this._isLive() ? this.getCursorPos() : {}));
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// Returns the cursor position with rowId, rowIndex, and fieldIndex.
|
|
|
|
public getCursorPos(): CursorPos {
|
|
|
|
return {
|
2023-07-07 08:54:01 +00:00
|
|
|
rowId: nullAsUndefined(this._properRowId()),
|
2020-10-02 15:10:00 +00:00
|
|
|
rowIndex: nullAsUndefined(this.rowIndex()),
|
2021-05-17 14:05:49 +00:00
|
|
|
fieldIndex: this.fieldIndex(),
|
|
|
|
sectionId: this._sectionId()
|
2020-10-02 15:10:00 +00:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Moves the cursor to the given position. Only moves the row if rowId or rowIndex is valid,
|
|
|
|
* preferring rowId.
|
2023-09-25 22:48:18 +00:00
|
|
|
*
|
|
|
|
* isFromLink prevents lastEditedAt from being updated, so lastEdit reflects only user-driven edits
|
2020-10-02 15:10:00 +00:00
|
|
|
* @param cursorPos: Position as { rowId?, rowIndex?, fieldIndex? }, as from getCursorPos().
|
2023-09-25 22:48:18 +00:00
|
|
|
* @param isFromLink: should be set if this is a cascading update from cursor-linking
|
2020-10-02 15:10:00 +00:00
|
|
|
*/
|
2023-09-25 22:48:18 +00:00
|
|
|
public setCursorPos(cursorPos: CursorPos, isFromLink: boolean = false): void {
|
|
|
|
|
|
|
|
try {
|
|
|
|
// If updating as a result of links, we want to NOT update lastEditedAt
|
|
|
|
if (isFromLink) { this._silentUpdatesFlag = true; }
|
|
|
|
|
|
|
|
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);
|
|
|
|
}
|
|
|
|
|
|
|
|
// NOTE: _cursorEdited
|
|
|
|
// We primarily update cursorEdited counter from a this._properRowId.subscribe(), since that catches updates
|
|
|
|
// from many sources (setCursorPos, arrowKeys, save/load, filter/sort-changes, etc)
|
|
|
|
// However, there's some cases where we user touches a section and properRowId doesn't change. Obvious one is
|
|
|
|
// clicking in a section on the cell the cursor is already on. This doesn't change the cursor position, but it
|
|
|
|
// SHOULD still update cursors to use that section as most up-to-date (user just clicked on a cell!), so we do
|
|
|
|
// it here. (normally is minor issue, but can matter when a section has rows filtered out so cursors desync)
|
|
|
|
// Also a more subtle case: when deleting a row with several sections linked together, properRowId can fail to
|
|
|
|
// update. When GridView.deleteRows calls setCursorPos to keep cursor from jumping after delete, the observable
|
|
|
|
// doesn't trigger cursorEdited(), because (I think) _properRowId has already been updated that cycle.
|
|
|
|
// This caused a bug when several viewSections were cursor-linked to each other and a row was deleted
|
|
|
|
// NOTE: Calling it explicitly here will cause cursorEdited to be called twice sometimes,
|
|
|
|
// but that shouldn't cause any problems, since we don't care about edit counts, just who was edited latest.
|
|
|
|
this._cursorEdited();
|
|
|
|
|
|
|
|
} finally { // Make sure we reset this even on error
|
|
|
|
this._silentUpdatesFlag = false;
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
2023-09-25 22:48:18 +00:00
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
|
|
|
|
2023-09-25 22:48:18 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
public setLive(isLive: boolean): void {
|
|
|
|
this._isLive(isLive);
|
|
|
|
}
|
2023-09-25 22:48:18 +00:00
|
|
|
|
|
|
|
// 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
|
|
|
|
private _cursorEdited(): void {
|
|
|
|
// If updating as a result of links, we want to NOT update lastEdited
|
|
|
|
if (!this._silentUpdatesFlag)
|
|
|
|
{ this._lastEditedAt(nextSequenceNum()); }
|
|
|
|
}
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|