mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(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:
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user