diff --git a/app/client/components/CellPosition.ts b/app/client/components/CellPosition.ts index 3624f806..e846147e 100644 --- a/app/client/components/CellPosition.ts +++ b/app/client/components/CellPosition.ts @@ -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 }; diff --git a/app/client/components/Drafts.ts b/app/client/components/Drafts.ts new file mode 100644 index 00000000..2a6e752f --- /dev/null +++ b/app/client/components/Drafts.ts @@ -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; +} + +/** + * 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.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 = 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 = 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>) { + 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 >(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; diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index 67b7888a..fb86857c 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -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 = Observable.create(this, null); // Holds current view that is currently rendered public currentView: Observable; @@ -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); } diff --git a/app/client/models/NotifyModel.ts b/app/client/models/NotifyModel.ts index 6033ad53..5df7996e 100644 --- a/app/client/models/NotifyModel.ts +++ b/app/client/models/NotifyModel.ts @@ -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. * diff --git a/app/client/ui/tooltips.ts b/app/client/ui/tooltips.ts index 850c5b02..a950a0c9 100644 --- a/app/client/ui/tooltips.ts +++ b/app/client/ui/tooltips.ts @@ -56,12 +56,16 @@ const openTooltips = new Map(); * 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; } /** diff --git a/app/client/widgets/FieldBuilder.ts b/app/client/widgets/FieldBuilder.ts index 7f977f38..0b71472e 100644 --- a/app/client/widgets/FieldBuilder.ts +++ b/app/client/widgets/FieldBuilder.ts @@ -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() { diff --git a/app/client/widgets/FieldEditor.ts b/app/client/widgets/FieldEditor.ts index 7dc27138..b80f0f80 100644 --- a/app/client/widgets/FieldEditor.ts +++ b/app/client/widgets/FieldEditor.ts @@ -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 = 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); diff --git a/app/client/widgets/FormulaEditor.ts b/app/client/widgets/FormulaEditor.ts index a6f335d3..8d03baf5 100644 --- a/app/client/widgets/FormulaEditor.ts +++ b/app/client/widgets/FormulaEditor.ts @@ -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(); }