(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
pull/23/head
Jarosław Sadziński 3 years ago
parent f5e3a0a94d
commit 5f182841b9

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

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

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

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

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

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

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

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

@ -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) {

@ -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() {

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

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

@ -45,6 +45,11 @@ declare module "app/client/components/BaseView" {
import {DomArg} from 'grainjs';
import {IOpenController} from 'popweasel';
type Options = {
init? : string,
state? : any
}
namespace BaseView {}
class BaseView extends Disposable {
public viewSection: ViewSectionRec;
@ -63,6 +68,7 @@ declare module "app/client/components/BaseView" {
public createFilterMenu(ctl: IOpenController, field: ViewFieldRec, onClose?: () => void): HTMLElement;
public buildTitleControls(): DomArg;
public getLoadingDonePromise(): Promise<void>;
public activateEditorAtCursor(options?: Options) : void;
public onResize(): void;
public prepareToPrint(onOff: boolean): void;
public moveEditRowToCursor(): DataRowModel;

@ -65,6 +65,7 @@ export type IconName = "ChartArea" |
"Page" |
"PanelLeft" |
"PanelRight" |
"Pencil" |
"PinBig" |
"PinSmall" |
"Pivot" |
@ -155,6 +156,7 @@ export const IconList: IconName[] = ["ChartArea",
"Page",
"PanelLeft",
"PanelRight",
"Pencil",
"PinBig",
"PinSmall",
"Pivot",

@ -41,7 +41,7 @@ function DateEditor(options) {
TextEditor.call(this, _.defaults(options, { placeholder: placeholder }));
// Set the edited value, if not explicitly given, to the formatted version of cellValue.
this.textInput.value = gutil.undefDefault(options.editValue,
this.textInput.value = gutil.undef(options.state, options.editValue,
this.formatValue(options.cellValue, this.safeFormat));
// Indicates whether keyboard navigation is active for the datepicker.

@ -42,10 +42,22 @@ function DateTimeEditor(options) {
kd.attr('placeholder', moment.tz('0', 'H', this.timezone).format(this._timeFormat)),
kd.value(this.formatValue(options.cellValue, this._timeFormat)),
this.commandGroup.attach(),
dom.on('input', () => this._resizeInput())
dom.on('input', () => this.onChange())
)
)
);
// If the edit value is encoded json, use those values as a starting point
if (typeof options.state == 'string') {
try {
const { date, time } = JSON.parse(options.state);
this._dateInput.value = date;
this._timeInput.value = time;
this.onChange();
} catch(e) {
console.error("DateTimeEditor can't restore its previous state")
}
}
}
dispose.makeDisposable(DateTimeEditor);
@ -77,6 +89,18 @@ DateTimeEditor.prototype._setFocus = function(index) {
}
};
/**
* Occurs when user types something into the editor
*/
DateTimeEditor.prototype.onChange = function() {
this._resizeInput();
// store editor state as an encoded JSON string
const date = this._dateInput.value;
const time = this._timeInput.value;
this.editorState.set(JSON.stringify({ date, time}));
}
DateTimeEditor.prototype.getCellValue = function() {
let date = this._dateInput.value;
let time = this._timeInput.value;

@ -27,7 +27,8 @@ import * as gristTypes from 'app/common/gristTypes';
import * as gutil from 'app/common/gutil';
import { CellValue } from 'app/plugin/GristData';
import { delay } from 'bluebird';
import { Computed, Disposable, fromKo, dom as grainjsDom, Holder, IDisposable, makeTestId } from 'grainjs';
import { Computed, Disposable, fromKo, dom as grainjsDom,
Holder, IDisposable, makeTestId } from 'grainjs';
import * as ko from 'knockout';
import * as _ from 'underscore';
@ -451,7 +452,8 @@ export class FieldBuilder extends Disposable {
}
public buildEditorDom(editRow: DataRowModel, mainRowModel: DataRowModel, options: {
init?: string
init?: string,
state?: any
}) {
// If the user attempts to edit a value during transform, finalize (i.e. cancel or execute)
// the transform.
@ -485,6 +487,7 @@ export class FieldBuilder extends Disposable {
cellElem,
editorCtor,
startVal: options.init,
state : options.state
});
// Put the FieldEditor into a holder in GristDoc too. This way any existing FieldEditor (perhaps

@ -13,8 +13,9 @@ import {asyncOnce} from "app/common/AsyncCreate";
import {CellValue} from "app/common/DocActions";
import {isRaisedException} from 'app/common/gristTypes';
import * as gutil from 'app/common/gutil';
import {Disposable, Holder, IDisposable, MultiHolder, Observable} from 'grainjs';
import {Disposable, Emitter, Holder, IDisposable, MultiHolder, Observable} from 'grainjs';
import isEqual = require('lodash/isEqual');
import { CellPosition } from "app/client/components/CellPosition";
type IEditorConstructor = typeof NewBaseEditor;
@ -46,7 +47,18 @@ export async function setAndSave(editRow: DataRowModel, field: ViewFieldRec, val
}
}
export type FieldEditorStateEvent = {
position : CellPosition,
currentState : any,
type: string
}
export class FieldEditor extends Disposable {
public readonly saveEmitter = this.autoDispose(new Emitter());
public readonly cancelEmitter = this.autoDispose(new Emitter());
public readonly changeEmitter = this.autoDispose(new Emitter());
private _gristDoc: GristDoc;
private _field: ViewFieldRec;
private _cursor: Cursor;
@ -65,6 +77,7 @@ export class FieldEditor extends Disposable {
cellElem: Element,
editorCtor: IEditorConstructor,
startVal?: string,
state?: any
}) {
super();
this._gristDoc = options.gristDoc;
@ -105,24 +118,30 @@ export class FieldEditor extends Disposable {
.catch(reportError);
},
fieldEditSaveHere: () => { this._saveEdit().catch(reportError); },
fieldEditCancel: () => { this.dispose(); },
fieldEditCancel: () => { this._cancelEdit(); },
prevField: () => { this._saveEdit().then(commands.allCommands.prevField.run).catch(reportError); },
nextField: () => { this._saveEdit().then(commands.allCommands.nextField.run).catch(reportError); },
makeFormula: () => this._makeFormula(),
unmakeFormula: () => this._unmakeFormula(),
};
this.rebuildEditor(isFormula, editValue, Number.POSITIVE_INFINITY);
const state : any = options.state;
this.rebuildEditor(isFormula, editValue, Number.POSITIVE_INFINITY, state);
if (offerToMakeFormula) {
this._offerToMakeFormula();
}
// connect this editor to editor monitor, it will restore this editor
// when user or server refreshes the browser
this._gristDoc.editorMonitor.monitorEditor(this);
setupEditorCleanup(this, this._gristDoc, this._field, this._saveEdit);
}
// cursorPos refers to the position of the caret within the editor.
public rebuildEditor(isFormula: boolean, editValue: string|undefined, cursorPos: number) {
public rebuildEditor(isFormula: boolean, editValue: string|undefined, cursorPos: number, state? : any) {
const editorCtor: IEditorConstructor = isFormula ? FormulaEditor : this._editorCtor;
const column = this._field.column();
@ -142,11 +161,38 @@ export class FieldEditor extends Disposable {
formulaError: getFormulaError(this._gristDoc, this._editRow, column),
editValue,
cursorPos,
state,
commands: this._editCommands,
}));
// if editor supports live changes, connect it to the change emitter
if (editor.editorState) {
editor.autoDispose(editor.editorState.addListener((currentState) => {
const event : FieldEditorStateEvent = {
position : this.cellPosition(),
currentState,
type : this._field.column.peek().pureType.peek()
}
this.changeEmitter.emit(event);
}));
}
editor.attach(this._cellElem);
}
// calculate current cell's absolute position
private cellPosition() {
const rowId = this._editRow.getRowId();
const colRef = this._field.colRef.peek();
const sectionId = this._field.viewSection.peek().id.peek();
const position = {
rowId,
colRef,
sectionId
}
return position;
}
private _makeFormula() {
const editor = this._editorHolder.get();
// On keyPress of "=" on textInput, consider turning the column into a formula.
@ -192,6 +238,17 @@ export class FieldEditor extends Disposable {
}
}
// Cancels the edit
private _cancelEdit() {
const event : FieldEditorStateEvent = {
position : this.cellPosition(),
currentState : this._editorHolder.get()?.editorState?.get(),
type : this._field.column.peek().pureType.peek()
}
this.cancelEmitter.emit(event);
this.dispose();
}
// Returns true if Enter/Shift+Enter should NOT move the cursor, for instance if the current
// record got reordered (i.e. the cursor jumped), or when editing a formula.
private async _doSaveEdit(): Promise<boolean> {
@ -238,6 +295,14 @@ export class FieldEditor extends Disposable {
waitPromise = setAndSave(this._editRow, this._field, value);
}
}
const event : FieldEditorStateEvent = {
position : this.cellPosition(),
currentState : this._editorHolder.get()?.editorState?.get(),
type : this._field.column.peek().pureType.peek()
}
this.saveEmitter.emit(event);
const cursor = this._cursor;
// Deactivate the editor. We are careful to avoid using `this` afterwards.
this.dispose();

@ -7,7 +7,7 @@ import {icon} from 'app/client/ui2018/icons';
import {createMobileButtons, getButtonMargins} from 'app/client/widgets/EditorButtons';
import {EditorPlacement, ISize} from 'app/client/widgets/EditorPlacement';
import {NewBaseEditor, Options} from 'app/client/widgets/NewBaseEditor';
import {undefDefault} from 'app/common/gutil';
import {undef} from 'app/common/gutil';
import {dom, Observable, styled} from 'grainjs';
// How wide to expand the FormulaEditor when an error is shown in it.
@ -37,12 +37,18 @@ export class FormulaEditor extends NewBaseEditor {
constructor(options: IFormulaEditorOptions) {
super(options);
const initialValue = undef(options.state as string | undefined, options.editValue, String(options.cellValue));
// create editor state observable (used by draft and latest position memory)
this.editorState = Observable.create(this, initialValue);
this._formulaEditor = AceEditor.create({
// A bit awkward, but we need to assume calcSize is not used until attach() has been called
// and _editorPlacement created.
calcSize: this._calcSize.bind(this),
gristDoc: options.gristDoc,
saveValueOnBlurEvent: true,
editorState : this.editorState
});
const allCommands = Object.assign({ setCursor: this._onSetCursor }, options.commands);
@ -70,11 +76,16 @@ export class FormulaEditor extends NewBaseEditor {
aceObj.setHighlightActiveLine(false);
aceObj.getSession().setUseWrapMode(false);
aceObj.renderer.setPadding(0);
const val = undefDefault(options.editValue, String(options.cellValue));
const val = initialValue;
const pos = Math.min(options.cursorPos, val.length);
this._formulaEditor.setValue(val, pos);
this._formulaEditor.attachCommandGroup(this._commandGroup);
// enable formula editing if state was passed
if (options.state) {
options.field.editingFormula(true);
}
// This catches any change to the value including e.g. via backspace or paste.
aceObj.once("change", () => options.field.editingFormula(true));
})

@ -7,11 +7,14 @@ import {createMobileButtons, getButtonMargins} from 'app/client/widgets/EditorBu
import {EditorPlacement} from 'app/client/widgets/EditorPlacement';
import {NewBaseEditor, Options} from 'app/client/widgets/NewBaseEditor';
import {CellValue} from "app/common/DocActions";
import {undefDefault} from 'app/common/gutil';
import {dom} from 'grainjs';
import {undef} from 'app/common/gutil';
import {dom, Observable} from 'grainjs';
export class NTextEditor extends NewBaseEditor {
// Observable with current editor state (used by drafts or latest edit/position component)
public readonly editorState : Observable<string>;
protected cellEditorDiv: HTMLElement;
protected textInput: HTMLTextAreaElement;
protected commandGroup: any;
@ -26,6 +29,11 @@ export class NTextEditor extends NewBaseEditor {
constructor(options: Options) {
super(options);
const initialValue : string = undef(
options.state as string | undefined,
options.editValue, String(options.cellValue ?? ""));
this.editorState = Observable.create<string>(this, initialValue);
this.commandGroup = this.autoDispose(createGroup(options.commands, null, true));
this._alignment = options.field.widgetOptionsJson.peek().alignment || 'left';
this._dom = dom('div.default_editor',
@ -33,10 +41,9 @@ export class NTextEditor extends NewBaseEditor {
this._contentSizer = dom('div.celleditor_content_measure'),
this.textInput = dom('textarea', dom.cls('celleditor_text_editor'),
dom.style('text-align', this._alignment),
dom.prop('value', undefDefault(options.editValue, String(options.cellValue ?? ""))),
dom.prop('value', initialValue),
this.commandGroup.attach(),
// Resize the textbox whenever user types in it.
dom.on('input', () => this.resizeInput())
dom.on('input', () => this.onInput())
)
),
createMobileButtons(options.commands),
@ -83,6 +90,18 @@ export class NTextEditor extends NewBaseEditor {
this._contentSizer.style.maxWidth = Math.ceil(maxSize.width) + 'px';
}
/**
* Occurs when user types text in the textarea
*
*/
protected onInput() {
// Resize the textbox whenever user types in it.
this.resizeInput()
// notify about current state
this.editorState.set(String(this.getTextValue()))
}
/**
* Helper which resizes textInput to match its content. It relies on having a contentSizer element
* with the same font/size settings as the textInput, and on having `calcSize` helper,

@ -21,6 +21,7 @@ export interface Options {
editValue?: string;
cursorPos: number;
commands: IEditorCommandGroup;
state? : any;
}
/**
@ -54,6 +55,11 @@ export abstract class NewBaseEditor extends Disposable {
return undefined;
}
/**
* Current state of the editor. Optional, not all editors will report theirs current state.
*/
public editorState? : Observable<any>;
constructor(protected options: Options) {
super();
}

@ -9,7 +9,7 @@ import {menuCssClass} from 'app/client/ui2018/menus';
import {Options} from 'app/client/widgets/NewBaseEditor';
import {NTextEditor} from 'app/client/widgets/NTextEditor';
import {CellValue} from 'app/common/DocActions';
import {removePrefix, undefDefault} from 'app/common/gutil';
import {removePrefix, undef} from 'app/common/gutil';
import {BaseFormatter} from 'app/common/ValueFormatter';
import {styled} from 'grainjs';
@ -55,7 +55,7 @@ export class ReferenceEditor extends NTextEditor {
// Decorate the editor to look like a reference column value (with a "link" icon).
this.cellEditorDiv.classList.add(cssRefEditor.className);
this.cellEditorDiv.appendChild(cssRefEditIcon('FieldReference'));
this.textInput.value = undefDefault(options.editValue, this._idToText(options.cellValue));
this.textInput.value = undef(options.state, options.editValue, this._idToText(options.cellValue));
const needReload = (options.editValue === undefined && !tableData.isLoaded);
@ -64,7 +64,7 @@ export class ReferenceEditor extends NTextEditor {
docData.fetchTable(refTableId).then(() => {
if (this.isDisposed()) { return; }
if (needReload && this.textInput.value === '') {
this.textInput.value = undefDefault(options.editValue, this._idToText(options.cellValue));
this.textInput.value = undef(options.state, options.editValue, this._idToText(options.cellValue));
this.resizeInput();
}
if (this._autocomplete) {

@ -8,6 +8,7 @@ var commands = require('../components/commands');
const {testId} = require('app/client/ui2018/cssVars');
const {createMobileButtons, getButtonMargins} = require('app/client/widgets/EditorButtons');
const {EditorPlacement} = require('app/client/widgets/EditorPlacement');
const { observable } = require('grainjs');
/**
* Required parameters:
@ -31,6 +32,10 @@ function TextEditor(options) {
this.options = options;
this.commandGroup = this.autoDispose(commands.createGroup(options.commands, null, true));
this._alignment = options.field.widgetOptionsJson.peek().alignment || 'left';
// calculate initial value (state, requested edited value or a current cell value)
const initialValue = gutil.undef(options.state, options.editValue, String(options.cellValue == null ? "" : options.cellValue));
// create observable with current state
this.editorState = this.autoDispose(observable(initialValue));
this.dom = dom('div.default_editor',
dom('div.celleditor_cursor_editor', dom.testId('TextEditor_editor'),
@ -39,11 +44,11 @@ function TextEditor(options) {
this.textInput = dom('textarea.celleditor_text_editor',
kd.attr('placeholder', options.placeholder || ''),
kd.style('text-align', this._alignment),
kd.value(gutil.undefDefault(options.editValue, String(options.cellValue == null ? "" : options.cellValue))),
kd.value(initialValue),
this.commandGroup.attach(),
// Resize the textbox whenever user types in it.
dom.on('input', () => this._resizeInput())
dom.on('input', () => this.onChange())
)
),
createMobileButtons(options.commands),
@ -85,6 +90,12 @@ TextEditor.prototype.getCellValue = function() {
return this.textInput.value;
};
TextEditor.prototype.onChange = function() {
if (this.editorState)
this.editorState.set(this.getTextValue());
this._resizeInput()
}
TextEditor.prototype.getTextValue = function() {
return this.textInput.value;
};

@ -102,6 +102,32 @@ export function undefDefault<T>(x: T|undefined, y: T): T {
return (x !== void 0) ? x : y;
}
// for typescript 4
// type Undef<T> = T extends [infer A, ...infer B] ? undefined extends A ? NonNullable<A> | Undef<B> : A : unknown;
type Undef1<T> = T extends [infer A] ?
undefined extends A ? NonNullable<A> : A : unknown;
type Undef2<T> = T extends [infer A, infer B] ?
undefined extends A ? NonNullable<A> | Undef1<[B]> : A : Undef1<T>;
type Undef3<T> = T extends [infer A, infer B, infer C] ?
undefined extends A ? NonNullable<A> | Undef2<[B, C]> : A : Undef2<T>;
type Undef<T> = T extends [infer A, infer B, infer C, infer D] ?
undefined extends A ? NonNullable<A> | Undef3<[B, C, D]> : A : Undef3<T>;
/**
* Returns the first defined value from the list or unknown.
* Use with typed result, so the typescript type checker can provide correct type.
*/
export function undef<T extends Array<any>>(...list : T): Undef<T> {
for(const value of list) {
if (value !== undefined) return value;
}
return undefined as any;
}
/**
* Parses json and returns the result, or returns defaultVal if parsing fails.
*/

@ -66,6 +66,7 @@
--icon-Page: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIsMSBMMiwxNSBMMTQsMTUgTDE0LDEgTDIsMSBaIE0xLjUsMCBMMTQuNSwwIEMxNC43NzYxNDI0LDAgMTUsMC4yMjM4NTc2MjUgMTUsMC41IEwxNSwxNS41IEMxNSwxNS43NzYxNDI0IDE0Ljc3NjE0MjQsMTYgMTQuNSwxNiBMMS41LDE2IEMxLjIyMzg1NzYzLDE2IDEsMTUuNzc2MTQyNCAxLDE1LjUgTDEsMC41IEMxLDAuMjIzODU3NjI1IDEuMjIzODU3NjMsMCAxLjUsMCBaIE05LjUsNCBDOS4yMjM4NTc2Myw0IDksMy43NzYxNDIzNyA5LDMuNSBDOSwzLjIyMzg1NzYzIDkuMjIzODU3NjMsMyA5LjUsMyBMMTEuNSwzIEMxMS43NzYxNDI0LDMgMTIsMy4yMjM4NTc2MyAxMiwzLjUgQzEyLDMuNzc2MTQyMzcgMTEuNzc2MTQyNCw0IDExLjUsNCBMOS41LDQgWiBNOS41LDcgQzkuMjIzODU3NjMsNyA5LDYuNzc2MTQyMzcgOSw2LjUgQzksNi4yMjM4NTc2MyA5LjIyMzg1NzYzLDYgOS41LDYgTDExLjUsNiBDMTEuNzc2MTQyNCw2IDEyLDYuMjIzODU3NjMgMTIsNi41IEMxMiw2Ljc3NjE0MjM3IDExLjc3NjE0MjQsNyAxMS41LDcgTDkuNSw3IFogTTQuNSwxMCBDNC4yMjM4NTc2MywxMCA0LDkuNzc2MTQyMzcgNCw5LjUgQzQsOS4yMjM4NTc2MyA0LjIyMzg1NzYzLDkgNC41LDkgTDExLjUsOSBDMTEuNzc2MTQyNCw5IDEyLDkuMjIzODU3NjMgMTIsOS41IEMxMiw5Ljc3NjE0MjM3IDExLjc3NjE0MjQsMTAgMTEuNSwxMCBMNC41LDEwIFogTTQuNSwxMyBDNC4yMjM4NTc2MywxMyA0LDEyLjc3NjE0MjQgNCwxMi41IEM0LDEyLjIyMzg1NzYgNC4yMjM4NTc2MywxMiA0LjUsMTIgTDExLjUsMTIgQzExLjc3NjE0MjQsMTIgMTIsMTIuMjIzODU3NiAxMiwxMi41IEMxMiwxMi43NzYxNDI0IDExLjc3NjE0MjQsMTMgMTEuNSwxMyBMNC41LDEzIFogTTUsNCBMNSw2IEw3LDYgTDcsNCBMNSw0IFogTTQuNSwzIEw3LjUsMyBDNy43NzYxNDIzNywzIDgsMy4yMjM4NTc2MyA4LDMuNSBMOCw2LjUgQzgsNi43NzYxNDIzNyA3Ljc3NjE0MjM3LDcgNy41LDcgTDQuNSw3IEM0LjIyMzg1NzYzLDcgNCw2Ljc3NjE0MjM3IDQsNi41IEw0LDMuNSBDNCwzLjIyMzg1NzYzIDQuMjIzODU3NjMsMyA0LjUsMyBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4=');
--icon-PanelLeft: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMuNzA3MTA2NzgsOC41IEw1Ljg1MzU1MzM5LDEwLjY0NjQ0NjYgQzYuMDQ4ODE1NTQsMTAuODQxNzA4OCA2LjA0ODgxNTU0LDExLjE1ODI5MTIgNS44NTM1NTMzOSwxMS4zNTM1NTM0IEM1LjY1ODI5MTI0LDExLjU0ODgxNTUgNS4zNDE3MDg3NiwxMS41NDg4MTU1IDUuMTQ2NDQ2NjEsMTEuMzUzNTUzNCBMMi4xNDY0NDY2MSw4LjM1MzU1MzM5IEMxLjk1MTE4NDQ2LDguMTU4MjkxMjQgMS45NTExODQ0Niw3Ljg0MTcwODc2IDIuMTQ2NDQ2NjEsNy42NDY0NDY2MSBMNS4xNDY0NDY2MSw0LjY0NjQ0NjYxIEM1LjM0MTcwODc2LDQuNDUxMTg0NDYgNS42NTgyOTEyNCw0LjQ1MTE4NDQ2IDUuODUzNTUzMzksNC42NDY0NDY2MSBDNi4wNDg4MTU1NCw0Ljg0MTcwODc2IDYuMDQ4ODE1NTQsNS4xNTgyOTEyNCA1Ljg1MzU1MzM5LDUuMzUzNTUzMzkgTDMuNzA3MTA2NzgsNy41IEw4LjUsNy41IEM4Ljc3NjE0MjM3LDcuNSA5LDcuNzIzODU3NjMgOSw4IEM5LDguMjc2MTQyMzcgOC43NzYxNDIzNyw4LjUgOC41LDguNSBMMy43MDcxMDY3OCw4LjUgWiBNMTAsMTMuNSBMMTAsMi41IEMxMCwyLjIyMzg1NzYzIDEwLjIyMzg1NzYsMiAxMC41LDIgTDEzLjUsMiBDMTMuNzc2MTQyNCwyIDE0LDIuMjIzODU3NjMgMTQsMi41IEwxNCwxMy41IEMxNCwxMy43NzYxNDI0IDEzLjc3NjE0MjQsMTQgMTMuNSwxNCBMMTAuNSwxNCBDMTAuMjIzODU3NiwxNCAxMCwxMy43NzYxNDI0IDEwLDEzLjUgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+');
--icon-PanelRight: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMuNzA3MTA2NzgsOC41IEw1Ljg1MzU1MzM5LDEwLjY0NjQ0NjYgQzYuMDQ4ODE1NTQsMTAuODQxNzA4OCA2LjA0ODgxNTU0LDExLjE1ODI5MTIgNS44NTM1NTMzOSwxMS4zNTM1NTM0IEM1LjY1ODI5MTI0LDExLjU0ODgxNTUgNS4zNDE3MDg3NiwxMS41NDg4MTU1IDUuMTQ2NDQ2NjEsMTEuMzUzNTUzNCBMMi4xNDY0NDY2MSw4LjM1MzU1MzM5IEMxLjk1MTE4NDQ2LDguMTU4MjkxMjQgMS45NTExODQ0Niw3Ljg0MTcwODc2IDIuMTQ2NDQ2NjEsNy42NDY0NDY2MSBMNS4xNDY0NDY2MSw0LjY0NjQ0NjYxIEM1LjM0MTcwODc2LDQuNDUxMTg0NDYgNS42NTgyOTEyNCw0LjQ1MTE4NDQ2IDUuODUzNTUzMzksNC42NDY0NDY2MSBDNi4wNDg4MTU1NCw0Ljg0MTcwODc2IDYuMDQ4ODE1NTQsNS4xNTgyOTEyNCA1Ljg1MzU1MzM5LDUuMzUzNTUzMzkgTDMuNzA3MTA2NzgsNy41IEw4LjUsNy41IEM4Ljc3NjE0MjM3LDcuNSA5LDcuNzIzODU3NjMgOSw4IEM5LDguMjc2MTQyMzcgOC43NzYxNDIzNyw4LjUgOC41LDguNSBMMy43MDcxMDY3OCw4LjUgWiBNMTAsMTMuNSBMMTAsMi41IEMxMCwyLjIyMzg1NzYzIDEwLjIyMzg1NzYsMiAxMC41LDIgTDEzLjUsMiBDMTMuNzc2MTQyNCwyIDE0LDIuMjIzODU3NjMgMTQsMi41IEwxNCwxMy41IEMxNCwxMy43NzYxNDI0IDEzLjc3NjE0MjQsMTQgMTMuNSwxNCBMMTAuNSwxNCBDMTAuMjIzODU3NiwxNCAxMCwxMy43NzYxNDI0IDEwLDEzLjUgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiB0cmFuc2Zvcm09Im1hdHJpeCgtMSAwIDAgMSAxNiAwKSIvPjwvc3ZnPg==');
--icon-Pencil: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9Ii0yIC0yIDIwIDIwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxnIGNsYXNzPSJuYy1pY29uLXdyYXBwZXIiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzIxMjEyMSIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik0xMyAuNUwxNS41IDMgNy41IDExIDQgMTIgNSA4LjV6TTExIDIuNUwxMy41IDUiLz48cGF0aCBkPSJNMTMuNSw5LjV2NSBjMCwwLjU1Mi0wLjQ0OCwxLTEsMWgtMTFjLTAuNTUyLDAtMS0wLjQ0OC0xLTF2LTExYzAtMC41NTIsMC40NDgtMSwxLTFoNSIvPjwvZz48L3N2Zz4=');
--icon-PinBig: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLjgyOTcyODEsLTcuNjAyODA3MjdlLTEzIEw0LjE5Mjk3NDkxLC03LjU3NjE2MTkyZS0xMyBDMy42MjcyODk0OCwtNy41MzE3NTNlLTEzIDMuMjAzMDI1NDEsMC40MTAyNTY0MSAzLjIwMzAyNTQxLDAuOTU3MjY0OTU3IEMzLjIwMzAyNTQxLDEuNTA0MjczNSAzLjYyNzI4OTQ4LDEuOTE0NTI5OTEgNC4xOTI5NzQ5MSwxLjkxNDUyOTkxIEw1LjA0MTUwMzA0LDEuOTE0NTI5OTEgTDIuODQ5NDcyMDIsOS4wOTQwMTcwOSBMMS40MzUyNTg0Niw5LjA5NDAxNzA5IEMwLjg2OTU3MzAzNiw5LjA5NDAxNzA5IDAuNDQ1MzA4OTY3LDkuNTA0MjczNSAwLjQ0NTMwODk2NywxMC4wNTEyODIxIEMwLjQ0NTMwODk2NywxMC41OTgyOTA2IDAuODY5NTczMDM2LDExLjAwODU0NyAxLjQzNTI1ODQ2LDExLjAwODU0NyBMMy41NTY1Nzg4LDExLjAwODU0NyBMNi45NTA2OTEzNSwxMS4wMDg1NDcgTDcuMDIxNDAyMDMsMTYgTDkuMDAxMzAxMDIsMTYgTDkuMDAxMzAxMDIsMTAuOTQwMTcwOSBMMTQuNjU4MTU1MywxMC45NDAxNzA5IEMxNS4zNjUyNjIxLDEwLjk0MDE3MDkgMTUuNjQ4MTA0OCwxMC4yNTY0MTAzIDE1LjY0ODEwNDgsOS45ODI5MDU5OCBDMTUuNjQ4MTA0OCw5LjQzNTg5NzQ0IDE1LjIyMzg0MDcsOS4wMjU2NDEwMyAxNC42NTgxNTUzLDkuMDI1NjQxMDMgTDEzLjI0Mzk0MTcsOS4wMjU2NDEwMyBMMTEuMTIyNjIxNCwxLjkxNDUyOTkxIEwxMS45NzExNDk1LDEuOTE0NTI5OTEgQzEyLjgxOTY3NzYsMS45MTQ1Mjk5MSAxMi45NjEwOTksMS4yMzA3NjkyMyAxMi45NjEwOTksMC45NTcyNjQ5NTcgQzEyLjg5MDM4ODMsMC40Nzg2MzI0NzkgMTIuMzk1NDEzNiwtNy42NjQ5Nzk3NmUtMTMgMTEuODI5NzI4MSwtNy42MDI4MDcyN2UtMTMgWiBNMTEuMDUxOTEwNyw5LjA5NDAxNzA5IEw0Ljk3MDc5MjM3LDkuMDk0MDE3MDkgTDcuMDkyMTEyNzEsMS45ODI5MDU5OCBMOC45MzA1OTAzNCwxLjk4MjkwNTk4IEwxMS4wNTE5MTA3LDkuMDk0MDE3MDkgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+');
--icon-PinSmall: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTcuNSwxMSBMMi41LDExIEMyLjIyMzg1NzYzLDExIDIsMTAuNzc2MTQyNCAyLDEwLjUgQzIsMTAuMjIzODU3NiAyLjIyMzg1NzYzLDEwIDIuNSwxMCBMNC4yNSwxMCBMNS43NSw0IEw0LjUsNCBDNC4yMjM4NTc2Myw0IDQsMy43NzYxNDIzNyA0LDMuNSBDNCwzLjIyMzg1NzYzIDQuMjIzODU3NjMsMyA0LjUsMyBMMTEuNSwzIEMxMS43NzYxNDI0LDMgMTIsMy4yMjM4NTc2MyAxMiwzLjUgQzEyLDMuNzc2MTQyMzcgMTEuNzc2MTQyNCw0IDExLjUsNCBMMTAuMjUsNCBMMTEuNzUsMTAgTDEzLjUsMTAgQzEzLjc3NjE0MjQsMTAgMTQsMTAuMjIzODU3NiAxNCwxMC41IEMxNCwxMC43NzYxNDI0IDEzLjc3NjE0MjQsMTEgMTMuNSwxMSBMOC41LDExIEw4LjUsMTQgTDcuNSwxNCBMNy41LDExIFogTTUuMjgwNzc2NDEsMTAgTDEwLjcxOTIyMzYsMTAgTDkuMjE5MjIzNTksNCBMNi43ODA3NzY0MSw0IEw1LjI4MDc3NjQxLDEwIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
--icon-Pivot: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMsMSBMMTMsMSBDMTQuMTA0NTY5NSwxIDE1LDEuODk1NDMwNSAxNSwzIEwxNSwxMyBDMTUsMTQuMTA0NTY5NSAxNC4xMDQ1Njk1LDE1IDEzLDE1IEwzLDE1IEMxLjg5NTQzMDUsMTUgMSwxNC4xMDQ1Njk1IDEsMTMgTDEsMyBDMSwxLjg5NTQzMDUgMS44OTU0MzA1LDEgMywxIFogTTUuMzc1LDQuNSBMNS4zNzUsNC43ODU0NjM0MSBMNy42MzQ2NTAxLDguMDIyNzA3MzIgTDUuMzc1LDExLjA5NDUxMjIgTDUuMzc1LDExLjUgTDEwLjYyNSwxMS41IEwxMC42MjUsMTAuMDMyODE3MSBMOS44MjY0OTkwMSwxMC43MjY0MTQ2IEw2LjkxOTczMTYxLDEwLjcyNjQxNDYgTDYuOTE5NzMxNjEsMTAuNDE2OTYzNCBMOC45MDI4MzMsNy42MTU4NTM2NiBMNi45MTk3MzE2MSw0Ljk5MDg1MzY2IEw5Ljc3NDMxMjEzLDQuOTg3MzUzNjYgTDEwLjYyNSw2LjA5NTMxNzA3IEwxMC42MjUsNC41IEw1LjM3NSw0LjUgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+');

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="16px" height="16px" viewBox="-2 -2 20 20" version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 52.5 (67469) - http://www.bohemiancoding.com/sketch -->
<title>Icons / UI / Download</title>
<desc>Created with Sketch.</desc>
<g class="nc-icon-wrapper" stroke-width="1" fill="none" stroke="#212121" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"><polygon points=" 13,0.5 15.5,3 7.5,11 4,12 5,8.5 " stroke="#212121"></polygon> <line x1="11" y1="2.5" x2="13.5" y2="5" stroke="#212121"></line> <path d="M13.5,9.5v5 c0,0.552-0.448,1-1,1h-11c-0.552,0-1-0.448-1-1v-11c0-0.552,0.448-1,1-1h5"></path> </g>
</svg>

After

Width:  |  Height:  |  Size: 742 B

@ -342,6 +342,14 @@ export async function resizeColumn(colOptions: IColHeader, deltaPx: number) {
await waitForServer();
}
/**
* Performs dbClick
* @param cell Element to click
*/
export async function dbClick(cell: WebElement) {
await driver.withActions(a => a.doubleClick(cell));
}
/**
* Returns {rowNum, col} object representing the position of the cursor in the active view
* section. RowNum is a 1-based number as in the row headers, and col is a 0-based index for
@ -411,6 +419,14 @@ export async function enterFormula(formula: string) {
await waitForServer();
}
/**
* Check that formula editor is shown and its value matches the given regexp.
*/
export async function getFormulaText() {
assert.equal(await driver.findWait('.test-formula-editor', 500).isDisplayed(), true);
return await driver.find('.code_editor_container').getText();
}
/**
* Check that formula editor is shown and its value matches the given regexp.
*/
@ -1311,6 +1327,20 @@ export class Session {
return doc;
}
// As for importFixturesDoc, but delete the document at the end of each test.
public async tempShortDoc(cleanup: Cleanup, fileName: string, options: ImportOpts = {load: true}) {
const doc = await this.importFixturesDoc(fileName, options);
const api = this.createHomeApi();
if (!noCleanup) {
cleanup.addAfterEach(async () => {
if (doc.id)
await api.deleteDoc(doc.id).catch(noop);
doc.id = '';
});
}
return doc;
}
public async tempNewDoc(cleanup: Cleanup, docName: string, {load} = {load: true}) {
const docId = await createNewDoc(this.settings.name, this.settings.org, this.settings.workspace, docName,
{email: this.settings.email});

Loading…
Cancel
Save