(core) Draft cells

Summary: Cells will remember their previous state when user pressed the escape key. Grist will offer a way to continue with the draft, by showing notification and a tooltip above the editor.

Test Plan: Browser tests were created

Reviewers: dsagal

Reviewed By: dsagal

Differential Revision: https://phab.getgrist.com/D2822
pull/9/head
Jarosław Sadziński 3 years ago
parent 8c6148dd9f
commit 5c0494fe29

@ -1,24 +1,25 @@
import { CursorPos } from "app/client/components/Cursor";
import { DocModel } from "app/client/models/DocModel";
import { DocModel, ViewFieldRec } from "app/client/models/DocModel";
import BaseRowModel = require("app/client/models/BaseRowModel");
/**
* 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;
export abstract class CellPosition {
public static equals(a: CellPosition, b: CellPosition) {
return a && b && a.colRef == b.colRef &&
a.sectionId == b.sectionId &&
a.rowId == b.rowId;
}
public static create(row: BaseRowModel, field: ViewFieldRec): CellPosition {
const rowId = row.id.peek();
const colRef = field.colRef.peek();
const sectionId = field.viewSection.peek().id.peek();
return { rowId, colRef, sectionId };
}
public sectionId: number;
public rowId: number | string;
public colRef: number;
}
/**
@ -36,7 +37,7 @@ export function fromCursor(position: CursorPos, docModel: DocModel): CellPositio
const colRef = section.viewFields().peek()[position.fieldIndex]?.colRef.peek();
const cursorPosition = {
rowId: position.rowId,
rowId: position.rowId as (string | number), // TODO: cursor position is wrongly typed
colRef,
sectionId: position.sectionId,
};
@ -57,7 +58,7 @@ export function toCursor(position: CellPosition, docModel: DocModel): CursorPos
.findIndex(x => x.colRef.peek() == position.colRef);
const cursorPosition = {
rowId: position.rowId,
rowId: position.rowId as number, // this is hack, as cursor position can accept string
fieldIndex,
sectionId: position.sectionId
};

@ -0,0 +1,466 @@
import { CellPosition, toCursor } from "app/client/components/CellPosition";
import {
Disposable, dom, Emitter, Holder, IDisposable, IDisposableOwner,
IDomArgs, MultiHolder, styled, TagElem
} from "grainjs";
import { GristDoc } from "app/client/components/GristDoc";
import { ITooltipControl, showTooltip, tooltipCloseButton } from "app/client/ui/tooltips";
import { FieldEditorStateEvent } from "app/client/widgets/FieldEditor";
import { colors, testId } from "app/client/ui2018/cssVars";
import { cssLink } from "app/client/ui2018/links";
/**
* Component that keeps track of editor's state (draft value). If user hits an escape button
* by accident, this component will provide a way to continue the work.
* Each editor can report its current state, that will be remembered and restored
* when user whishes to continue his work.
* Each document can have only one draft at a particular time, that
* is cleared when changes occur on any other cell or the cursor navigates await from a cell.
*
* This component is built as a plugin for GristDoc. GristDoc, FieldBuilder, FieldEditor were just
* extended in order to provide some public interface that this objects plugs into.
* To disable the drafts, just simple remove it from GristDoc.
*/
export class Drafts extends Disposable {
constructor(
doc: GristDoc
) {
super();
// Here are all the parts that play some role in this feature
// Cursor will navigate the cursor on a view to a proper cell
const cursor: Cursor = CursorAdapter.create(this, doc);
// Storage will remember last draft
const storage: Storage = StorageAdapter.create(this);
// Notification will show notification with button to undo discard
const notification: Notification = NotificationAdapter.create(this, doc);
// Tooltip will hover above the editor and offer to continue from last edit
const tooltip: Tooltip = TooltipAdapter.create(this, doc);
// Editor will restore its previous state and inform about keyboard events
const editor: Editor = EditorAdapter.create(this, doc);
// Here is the main use case describing how parts are connected
const when = makeWhen(this);
// When user cancels the editor
when(editor.cellCancelled, (ev: StateChanged) => {
// if the state of the editor hasn't changed
if (!ev.modified) {
// close the tooltip and notification
tooltip.close();
notification.close();
// don't store the draft - we assume that user
// actually wanted to discard the draft by pressing
// escape again
return;
}
// Show notification
notification.showUndoDiscard();
// Save draft in memory
storage.save(ev);
// Make sure that tooltip is not visible
tooltip.close();
});
// When user clicks notification to continue with the draft
when(notification.pressed, async () => {
// if the draft is there
const draft = storage.get();
if (draft) {
// restore the position of a cell
await cursor.goToCell(draft.position);
// activate the editor
await editor.activate();
// and restore last draft
editor.setState(draft.state);
}
// We don't need the draft any more.
// If user presses escape one more time it will be crated
// once again
storage.clear();
// Close the notification
notification.close();
// tooltip is not visible here, and will be shown
// when editor is activated
});
// When user doesn't do anything while the notification is visible
// remove the draft when it disappears
when(notification.disappeared, () => {
storage.clear();
});
// When editor is activated (user typed something or double clicked a cell)
when(editor.activated, (pos: CellPosition) => {
// if there was a draft for a cell
if (storage.hasDraftFor(pos)) {
// show tooltip to continue with a draft
tooltip.showContinueDraft();
}
// make sure that notification is not visible
notification.close();
});
// When editor is modified, close tooltip after some time
when(editor.cellModified, (_: StateChanged) => {
tooltip.scheduleClose();
});
// When user saves a cell
when(editor.cellSaved, (_: StateChanged) => {
// just close everything and clear draft
storage.clear();
tooltip.close();
notification.close();
});
// When a user clicks a tooltip to continue with a draft
when(tooltip.click, () => {
const draft = storage.get();
// if there was a draft
if (draft) {
// restore the draft
editor.setState(draft.state);
}
// close the tooltip
tooltip.close();
});
}
}
///////////////////////////////////////////////////////////
// Roles definition that abstract the way this feature interacts with Grist
/**
* Cursor role can navigate the cursor to a proper cell
*/
interface Cursor {
goToCell(pos: CellPosition): Promise<void>;
}
/**
* Editor role represents active editor that is attached to a cell.
*/
interface Editor {
// Occurs when user triggers the save operation (by the enter key, clicking away)
cellSaved: TypedEmitter<StateChanged>;
// Occurs when user triggers the save operation (by the enter key, clicking away)
cellModified: TypedEmitter<StateChanged>;
// Occurs when user typed something on a cell or double clicked it
activated: TypedEmitter<CellPosition>;
// Occurs when user cancels the edit (mainly by the escape key or by icon on mobile)
cellCancelled: TypedEmitter<StateChanged>;
// Editor can restore its state
setState(state: any): void;
// Editor can be shown up to the user on active cell
activate(): Promise<void>;
}
/**
* Notification that is shown to the user on the right bottom corner
*/
interface Notification {
// Occurs when user clicked the notification
pressed: Signal;
// Occurs when notification disappears with no action from a user
disappeared: Signal;
// Notification can be closed if it is visible
close(): void;
// Show notification to the user, to inform him that he can continue with the draft
showUndoDiscard(): void;
}
/**
* Storage abstraction. Is responsible for storing latest
* draft (position and state)
*/
interface Storage {
// Retrieves latest draft data
get(): State | null;
// Stores latest draft data
save(ev: State): void;
// Checks if there is draft data at the position
hasDraftFor(position: CellPosition): boolean;
// Removes draft data
clear(): void;
}
/**
* Tooltip role is responsible for showing tooltip over active field editor with an information
* that the drafts is available, and a button to continue with the draft
*/
interface Tooltip {
// Occurs when user clicks the button on the tooltip - so he wants
// to continue with the draft
click: Signal;
// Show tooltip over active cell editor
showContinueDraft(): void;
// Close tooltip
close(): void;
// Close tooltip after some time
scheduleClose(): void;
}
/**
* Schema of the information that is stored in the storage.
*/
interface State {
// State of the editor
state: any;
// Cell position where the draft was created
position: CellPosition;
}
/**
* Event that is emitted when editor state has changed
*/
interface StateChanged extends State {
modified: boolean;
}
///////////////////////////////////////////////////////////
// Here are all the adapters for the roles above. They
// abstract the way this feature interacts with the GristDoc
class CursorAdapter extends Disposable implements Cursor {
constructor(private _doc: GristDoc) {
super();
}
public async goToCell(pos: CellPosition): Promise<void> {
await this._doc.recursiveMoveToCursorPos(toCursor(pos, this._doc.docModel), true);
}
}
class StorageAdapter extends Disposable implements Storage {
private _memory: State | null;
public get(): State | null {
return this._memory;
}
public save(ev: State) {
this._memory = ev;
}
public hasDraftFor(position: CellPosition): boolean {
const item = this._memory;
if (item && CellPosition.equals(item.position, position)) {
return true;
}
return false;
}
public clear(): void {
this._memory = null;
}
}
class NotificationAdapter extends Disposable implements Notification {
public readonly pressed: Signal;
public readonly disappeared: Signal;
private _hadAction = false;
private _holder = Holder.create(this);
constructor(private _doc: GristDoc) {
super();
this.pressed = this.autoDispose(new Emitter());
this.disappeared = this.autoDispose(new Emitter());
}
public close(): void {
this._hadAction = true;
this._holder.clear();
}
public showUndoDiscard() {
const notifier = this._doc.app.topAppModel.notifier;
const notification = notifier.createUserError("Undo discard", {
message: () =>
discardNotification(
dom.on("click", () => {
this._hadAction = true;
this.pressed.emit();
})
)
});
notification.onDispose(() => {
if (!this._hadAction) {
this.disappeared.emit();
}
});
this._holder.autoDispose(notification);
this._hadAction = false;
}
}
class TooltipAdapter extends Disposable implements Tooltip {
public readonly click: Signal;
// there can be only one tooltip at a time
private _tooltip: ITooltipControl | null = null;
private _scheduled = false;
constructor(private _doc: GristDoc) {
super();
this.click = this.autoDispose(new Emitter());
// make sure that the tooltip is closed when this object gets disposed
this.onDispose(() => {
this.close();
});
}
public scheduleClose(): void {
if (this._tooltip && !this._scheduled) {
this._scheduled = true;
const origClose = this._tooltip.close;
this._tooltip.close = () => { clearTimeout(timer); origClose(); };
const timer = setTimeout(this._tooltip.close, 6000);
}
}
public showContinueDraft(): void {
// close tooltip if there was a previous one
this.close();
// get the editor dom
const editorDom = this._doc.activeEditor.get()?.getDom();
if (!editorDom) {
return;
}
// attach the tooltip
this._tooltip = showTooltip(
editorDom,
cellTooltip(() => this.click.emit()));
}
public close(): void {
this._scheduled = false;
this._tooltip?.close();
this._tooltip = null;
}
}
class EditorAdapter extends Disposable implements Editor {
public readonly cellSaved: TypedEmitter<StateChanged> = this.autoDispose(new Emitter());
public readonly cellModified: TypedEmitter<StateChanged> = this.autoDispose(new Emitter());
public readonly activated: TypedEmitter<CellPosition> = this.autoDispose(new Emitter());
public readonly cellCancelled: TypedEmitter<StateChanged> = this.autoDispose(new Emitter());
private _holder = MultiHolder.create(this);
constructor(private _doc: GristDoc) {
super();
// observe active editor
this.autoDispose(_doc.activeEditor.addListener((editor) => {
if (!editor) {
return;
}
// when the editor is created we assume that it is visible to the user
this.activated.emit(editor.cellPosition());
// auto dispose all the previous listeners
this._holder.dispose();
this._holder = MultiHolder.create(this);
this._holder.autoDispose(editor.changeEmitter.addListener((e: FieldEditorStateEvent) => {
this.cellModified.emit({
position: e.position,
state: e.currentState,
modified: e.wasModified
});
}));
// when user presses escape
this._holder.autoDispose(editor.cancelEmitter.addListener((e: FieldEditorStateEvent) => {
this.cellCancelled.emit({
position: e.position,
state: e.currentState,
modified: e.wasModified
});
}));
// when user presses enter to save the value
this._holder.autoDispose(editor.saveEmitter.addListener((e: FieldEditorStateEvent) => {
this.cellSaved.emit({
position: e.position,
state: e.currentState,
modified: e.wasModified
});
}));
}));
}
public setState(state: any): void {
// rebuild active editor with a state from a draft
this._doc.activeEditor.get()?.rebuildEditor(undefined, Number.POSITIVE_INFINITY, state);
}
public async activate() {
// open up the editor at current position
await this._doc.activateEditorAtCursor({});
}
}
///////////////////////////////////////////////////////////
// Ui components
// Cell tooltip to restore the draft - it is visible over active editor
const styledTooltip = styled('div', `
display: flex;
align-items: center;
--icon-color: ${colors.lightGreen};
& > .${cssLink.className} {
margin-left: 8px;
}
`);
function cellTooltip(clb: () => any) {
return function (ctl: ITooltipControl) {
return styledTooltip(
cssLink('Restore last edit',
dom.on('mousedown', (ev) => { ev.preventDefault(); ctl.close(); clb(); }),
testId('draft-tooltip'),
),
tooltipCloseButton(ctl),
);
};
}
// Discard notification dom
const styledNotification = styled('div', `
cursor: pointer;
color: ${colors.lightGreen};
&:hover {
text-decoration: underline;
}
`);
function discardNotification(...args: IDomArgs<TagElem<"div">>) {
return styledNotification(
"Undo Discard",
testId("draft-notification"),
...args
);
}
///////////////////////////////////////////////////////////
// Internal implementations - not relevant to main use case
// helper method to listen to the Emitter and dispose the listener with a parent
function makeWhen(owner: IDisposableOwner) {
return function <T extends EmitterType<any>>(emitter: T, handler: EmitterHandler<T>) {
owner.autoDispose(emitter.addListener(handler as any));
};
}
// Default emitter is not typed, this augments the Emitter interface
interface TypedEmitter<T> {
emit(item: T): void;
addListener(clb: (e: T) => any): IDisposable;
}
interface Signal {
emit(): void;
addListener(clb: () => any): IDisposable;
}
type EmitterType<T> = T extends TypedEmitter<infer E> ? TypedEmitter<E> : Signal;
type EmitterHandler<T> = T extends TypedEmitter<infer E> ? ((e: E) => any) : () => any;

@ -54,6 +54,8 @@ 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";
import { FieldEditor } from "app/client/widgets/FieldEditor";
import { Drafts } from "app/client/components/Drafts";
const G = getBrowserGlobals('document', 'window');
@ -101,6 +103,8 @@ export class GristDoc extends DisposableWithEvents {
public cursorMonitor: CursorMonitor;
// component for keeping track of a cell that is being edited
public editorMonitor: EditorMonitor;
// component for keeping track of a cell that is being edited
public draftMonitor: Drafts;
// Emitter triggered when the main doc area is resized.
public readonly resizeEmitter = this.autoDispose(new Emitter());
@ -109,6 +113,8 @@ export class GristDoc extends DisposableWithEvents {
// previous one if any. The holder is maintained by GristDoc, so that we are guaranteed at
// most one instance of FieldEditor at any time.
public readonly fieldEditorHolder = Holder.create(this);
// active field editor
public readonly activeEditor: Observable<FieldEditor | null> = Observable.create(this, null);
// Holds current view that is currently rendered
public currentView: Observable<BaseView | null>;
@ -269,6 +275,7 @@ export class GristDoc extends DisposableWithEvents {
return undefined;
});
this.draftMonitor = Drafts.create(this, this);
this.cursorMonitor = CursorMonitor.create(this, this);
this.editorMonitor = EditorMonitor.create(this, this);
}

@ -213,7 +213,7 @@ export class Notifier extends Disposable implements INotifier {
}
/**
* Creates a basic toast user error. By default, expires in 5 seconds.
* Creates a basic toast user error. By default, expires in 10 seconds.
* Takes an options objects to configure `expireSec` and `canUserClose`.
* Set `expireSec` to 0 to prevent expiration.
*

@ -56,12 +56,16 @@ const openTooltips = new Map<string, ITooltipControl>();
* Show tipContent briefly (2s by default), in a tooltip next to refElem (on top of it, by default).
* See also ITipOptions.
*/
export function showTransientTooltip(refElem: Element, tipContent: DomContents, options: ITransientTipOptions = {}) {
const ctl = showTooltip(refElem, () => tipContent, options);
export function showTransientTooltip(
refElem: Element,
tipContent: DomContents | ITooltipContentFunc,
options: ITransientTipOptions = {}) {
const ctl = showTooltip(refElem, typeof tipContent == 'function' ? tipContent : () => tipContent, options);
const origClose = ctl.close;
ctl.close = () => { clearTimeout(timer); origClose(); };
const timer = setTimeout(ctl.close, options.timeoutMs || 2000);
return ctl;
}
/**

@ -492,6 +492,10 @@ export class FieldBuilder extends Disposable {
// still maintain a Holder in this FieldBuilder is mainly to match older behavior; changing that
// will entail a number of other tweaks related to the order of creating and disposal.
this.gristDoc.fieldEditorHolder.autoDispose(fieldEditor);
// expose the active editor in a grist doc as an observable
fieldEditor.onDispose(() => this.gristDoc.activeEditor.set(null));
this.gristDoc.activeEditor.set(fieldEditor);
}
public isEditorActive() {

@ -47,10 +47,14 @@ export async function setAndSave(editRow: DataRowModel, field: ViewFieldRec, val
}
}
/**
* Event that is fired when editor stat has changed
*/
export interface FieldEditorStateEvent {
position: CellPosition;
currentState: any;
type: string;
position: CellPosition,
wasModified: boolean,
currentState: any,
type: string
}
export class FieldEditor extends Disposable {
@ -68,6 +72,8 @@ export class FieldEditor extends Disposable {
private _editorCtor: IEditorConstructor;
private _editorHolder: Holder<NewBaseEditor> = Holder.create(this);
private _saveEdit = asyncOnce(() => this._doSaveEdit());
private _editorHasChanged = false;
private _isFormula = false;
constructor(options: {
gristDoc: GristDoc,
@ -91,13 +97,13 @@ export class FieldEditor extends Disposable {
let offerToMakeFormula = false;
const column = this._field.column();
let isFormula: boolean = column.isRealFormula.peek();
this._isFormula = column.isRealFormula.peek();
let editValue: string|undefined = startVal;
if (startVal && gutil.startsWith(startVal, '=')) {
if (isFormula || this._field.column().isEmpty()) {
if (this._isFormula || this._field.column().isEmpty()) {
// If we typed '=' on an empty column, convert it to a formula. If on a formula column,
// start editing ignoring the initial '='.
isFormula = true;
this._isFormula = true;
editValue = gutil.removePrefix(startVal, '=') as string;
} else {
// If we typed '=' on a non-empty column, only suggest to convert it to a formula.
@ -127,7 +133,7 @@ export class FieldEditor extends Disposable {
const state: any = options.state;
this.rebuildEditor(isFormula, editValue, Number.POSITIVE_INFINITY, state);
this.rebuildEditor(editValue, Number.POSITIVE_INFINITY, state);
if (offerToMakeFormula) {
this._offerToMakeFormula();
@ -141,8 +147,8 @@ export class FieldEditor extends Disposable {
}
// cursorPos refers to the position of the caret within the editor.
public rebuildEditor(isFormula: boolean, editValue: string|undefined, cursorPos: number, state?: any) {
const editorCtor: IEditorConstructor = isFormula ? FormulaEditor : this._editorCtor;
public rebuildEditor(editValue: string|undefined, cursorPos: number, state?: any) {
const editorCtor: IEditorConstructor = this._isFormula ? FormulaEditor : this._editorCtor;
const column = this._field.column();
const cellCurrentValue = this._editRow.cells[this._field.colId()].peek();
@ -151,8 +157,9 @@ export class FieldEditor extends Disposable {
// Enter formula-editing mode (e.g. click-on-column inserts its ID) only if we are opening the
// editor by typing into it (and overriding previous formula). In other cases (e.g. double-click),
// we defer this mode until the user types something.
this._field.editingFormula(isFormula && editValue !== undefined);
this._field.editingFormula(this._isFormula && editValue !== undefined);
this._editorHasChanged = false;
// Replace the item in the Holder with a new one, disposing the previous one.
const editor = this._editorHolder.autoDispose(editorCtor.create({
gristDoc: this._gristDoc,
@ -168,8 +175,10 @@ export class FieldEditor extends Disposable {
// if editor supports live changes, connect it to the change emitter
if (editor.editorState) {
editor.autoDispose(editor.editorState.addListener((currentState) => {
this._editorHasChanged = true;
const event: FieldEditorStateEvent = {
position: this._cellPosition(),
position : this.cellPosition(),
wasModified : this._editorHasChanged,
currentState,
type: this._field.column.peek().pureType.peek()
};
@ -180,8 +189,12 @@ export class FieldEditor extends Disposable {
editor.attach(this._cellElem);
}
public getDom() {
return this._editorHolder.get()?.getDom();
}
// calculate current cell's absolute position
private _cellPosition() {
public cellPosition() {
const rowId = this._editRow.getRowId();
const colRef = this._field.colRef.peek();
const sectionId = this._field.viewSection.peek().id.peek();
@ -198,8 +211,9 @@ export class FieldEditor extends Disposable {
// On keyPress of "=" on textInput, consider turning the column into a formula.
if (editor && !this._field.editingFormula.peek() && editor.getCursorPos() === 0) {
if (this._field.column().isEmpty()) {
this._isFormula = true;
// If we typed '=' an empty column, convert it to a formula.
this.rebuildEditor(true, editor.getTextValue(), 0);
this.rebuildEditor(editor.getTextValue(), 0);
return false;
} else {
// If we typed '=' on a non-empty column, only suggest to convert it to a formula.
@ -217,7 +231,8 @@ export class FieldEditor extends Disposable {
!this._field.column().isRealFormula()) {
// Restore a plain '=' character. This gives a way to enter "=" at the start if line. The
// second backspace will delete it.
this.rebuildEditor(false, '=' + editor.getTextValue(), 1);
this._isFormula = false;
this.rebuildEditor('=' + editor.getTextValue(), 1);
return false;
}
return true; // don't stop propagation.
@ -234,16 +249,18 @@ export class FieldEditor extends Disposable {
if (editor) {
const editValue = editor.getTextValue();
const formulaValue = editValue.startsWith('=') ? editValue.slice(1) : editValue;
this.rebuildEditor(true, formulaValue, 0);
this._isFormula = true;
this.rebuildEditor(formulaValue, 0);
}
}
// Cancels the edit
private _cancelEdit() {
const event: FieldEditorStateEvent = {
position: this._cellPosition(),
currentState: this._editorHolder.get()?.editorState?.get(),
type: this._field.column.peek().pureType.peek()
position : this.cellPosition(),
wasModified : this._editorHasChanged,
currentState : this._editorHolder.get()?.editorState?.get(),
type : this._field.column.peek().pureType.peek()
};
this.cancelEmitter.emit(event);
this.dispose();
@ -297,9 +314,10 @@ export class FieldEditor extends Disposable {
}
const event: FieldEditorStateEvent = {
position: this._cellPosition(),
currentState: this._editorHolder.get()?.editorState?.get(),
type: this._field.column.peek().pureType.peek()
position : this.cellPosition(),
wasModified : this._editorHasChanged,
currentState : this._editorHolder.get()?.editorState?.get(),
type : this._field.column.peek().pureType.peek()
};
this.saveEmitter.emit(event);

@ -119,6 +119,10 @@ export class FormulaEditor extends NewBaseEditor {
this._formulaEditor.editor.focus();
}
public getDom(): HTMLElement {
return this._dom;
}
public getCellValue() {
return this._formulaEditor.getValue();
}

Loading…
Cancel
Save