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 { makeT } from 'app/client/lib/localization'; import { ITooltipControl, showTooltip, tooltipCloseButton } from "app/client/ui/tooltips"; import { FieldEditorStateEvent } from "app/client/widgets/FieldEditor"; import { testId, theme } from "app/client/ui2018/cssVars"; import { cssLink } from "app/client/ui2018/links"; const t = makeT('components.Drafts'); /** * 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 created // 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; } /** * 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; // Occurs when user triggers the save operation (by the enter key, clicking away) cellModified: TypedEmitter; // Occurs when user typed something on a cell or double clicked it activated: TypedEmitter; // Occurs when user cancels the edit (mainly by the escape key or by icon on mobile) cellCancelled: TypedEmitter; // Editor can restore its state setState(state: any): void; // Editor can be shown up to the user on active cell activate(): Promise; } /** * 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 { 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.createUserMessage(t("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 = this.autoDispose(new Emitter()); public readonly cellModified: TypedEmitter = this.autoDispose(new Emitter()); public readonly activated: TypedEmitter = this.autoDispose(new Emitter()); public readonly cellCancelled: TypedEmitter = this.autoDispose(new Emitter()); private _holder = Holder.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 the previous MultiHolder along with all the previous listeners, and create a // new MultiHolder for the new ones. const mholder = MultiHolder.create(this._holder); mholder.autoDispose(editor.changeEmitter.addListener((e: FieldEditorStateEvent) => { this.cellModified.emit({ position: e.position, state: e.currentState, modified: e.wasModified }); })); // when user presses escape mholder.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 mholder.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: ${theme.controlFg}; & > .${cssLink.className} { margin-left: 8px; } `); function cellTooltip(clb: () => any) { return function (ctl: ITooltipControl) { return styledTooltip( cssLink(t("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: ${theme.controlFg}; &:hover { text-decoration: underline; } `); function discardNotification(...args: IDomArgs>) { return styledNotification( t("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 >(emitter: T, handler: EmitterHandler) { owner.autoDispose(emitter.addListener(handler as any)); }; } // Default emitter is not typed, this augments the Emitter interface interface TypedEmitter { emit(item: T): void; addListener(clb: (e: T) => any): IDisposable; } interface Signal { emit(): void; addListener(clb: () => any): IDisposable; } type EmitterType = T extends TypedEmitter ? TypedEmitter : Signal; type EmitterHandler = T extends TypedEmitter ? ((e: E) => any) : () => any;