(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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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