(core) Floating formula editor

Summary:
Adding a way to detach an editor. Initially only implemented for the formula editor, includes redesign for the AI part.
- Initially, the detached editor is tight with the formula assistant and both are behind GRIST_FORMULA_ASSISTANT flag, but this can be relaxed
later on, as the detached editor can be used on its own.

- Detached editor is only supported in regular fields and on the creator panel. It is not supported yet for conditional styles, due to preview limitations.
- Old code for the assistant was removed completely, as it was only a temporary solution, but the AI conversation part was copied to the new one.
- Prompting was not modified in this diff, it will be included in the follow-up with more test cases.

Test Plan: Added only new tests; existing tests should pass.

Reviewers: JakubSerafin

Reviewed By: JakubSerafin

Differential Revision: https://phab.getgrist.com/D3863
This commit is contained in:
Jarosław Sadziński
2023-06-02 13:25:14 +02:00
parent e10067ff78
commit da323fb741
36 changed files with 2022 additions and 823 deletions

View File

@@ -4,6 +4,8 @@ import { FormulaTransform } from 'app/client/components/FormulaTransform';
import { GristDoc } from 'app/client/components/GristDoc';
import { addColTypeSuffix } from 'app/client/components/TypeConversion';
import { TypeTransform } from 'app/client/components/TypeTransform';
import { FloatingEditor } from 'app/client/widgets/FloatingEditor';
import { UnsavedChange } from 'app/client/components/UnsavedChanges';
import dom from 'app/client/lib/dom';
import { KoArray } from 'app/client/lib/koArray';
import * as kd from 'app/client/lib/koDom';
@@ -23,7 +25,7 @@ import { theme } from 'app/client/ui2018/cssVars';
import { IOptionFull, menu, select } from 'app/client/ui2018/menus';
import { DiffBox } from 'app/client/widgets/DiffBox';
import { buildErrorDom } from 'app/client/widgets/ErrorDom';
import { FieldEditor, saveWithoutEditor, setupEditorCleanup } from 'app/client/widgets/FieldEditor';
import { FieldEditor, saveWithoutEditor } from 'app/client/widgets/FieldEditor';
import { CellDiscussionPopup, EmptyCell } from 'app/client/widgets/DiscussionEditor';
import { openFormulaEditor } from 'app/client/widgets/FormulaEditor';
import { NewAbstractWidget } from 'app/client/widgets/NewAbstractWidget';
@@ -34,7 +36,7 @@ import * as gristTypes from 'app/common/gristTypes';
import { getReferencedTableId, isFullReferencingType } from 'app/common/gristTypes';
import { CellValue } from 'app/plugin/GristData';
import { Computed, Disposable, fromKo, dom as grainjsDom,
Holder, IDisposable, makeTestId, MultiHolder, styled, toKo } from 'grainjs';
makeTestId, MultiHolder, Observable, styled, toKo } from 'grainjs';
import * as ko from 'knockout';
import * as _ from 'underscore';
@@ -100,19 +102,19 @@ export class FieldBuilder extends Disposable {
private readonly _rowMap: Map<DataRowModel, Element>;
private readonly _isTransformingFormula: ko.Computed<boolean>;
private readonly _isTransformingType: ko.Computed<boolean>;
private readonly _fieldEditorHolder: Holder<IDisposable>;
private readonly _widgetCons: ko.Computed<{create: (...args: any[]) => NewAbstractWidget}>;
private readonly _docModel: DocModel;
private readonly _readonly: Computed<boolean>;
private readonly _comments: ko.Computed<boolean>;
private readonly _showRefConfigPopup: ko.Observable<boolean>;
private readonly _isEditorActive = Observable.create(this, false);
public constructor(public readonly gristDoc: GristDoc, public readonly field: ViewFieldRec,
private _cursor: Cursor, private _options: { isPreview?: boolean } = {}) {
super();
this._docModel = gristDoc.docModel;
this.origColumn = field.column();
this.origColumn = field.origCol();
this.options = field.widgetOptionsJson;
this._comments = ko.pureComputed(() => toKo(ko, COMMENTS())());
@@ -183,10 +185,6 @@ export class FieldBuilder extends Disposable {
(this.columnTransform instanceof TypeTransform);
}));
// This holds a single FieldEditor. When a new FieldEditor is created (on edit), it replaces the
// previous one if any.
this._fieldEditorHolder = Holder.create(this);
// Map from rowModel to cell dom for the field to which this fieldBuilder applies.
this._rowMap = new Map();
@@ -580,7 +578,7 @@ export class FieldBuilder extends Disposable {
if (this.isDisposed()) { return null; } // Work around JS errors during field removal.
const value = row.cells[this.field.colId()];
const cell = value && value();
if ((value) && this._isRightType()(cell, this.options) || row._isAddRow.peek()) {
if ((value as any) && this._isRightType()(cell, this.options) || row._isAddRow.peek()) {
return this.widgetImpl();
} else if (gristTypes.isVersions(cell)) {
return this.diffImpl;
@@ -677,39 +675,40 @@ export class FieldBuilder extends Disposable {
return;
}
// Clear previous editor. Some caveats:
// - The floating editor has an async cleanup routine, but it promises that it won't affect as.
// - All other editors should be synchronous, so this line will remove all opened editors.
const holder = this.gristDoc.fieldEditorHolder;
// If the global editor is from our own field, we will dispose it immediately, otherwise we will
// rely on the clipboard to dispose it by grabbing focus.
const clearOwn = () => this.isEditorActive() && holder.clear();
// If this is censored value, don't open up the editor, unless it is a formula field.
const cell = editRow.cells[this.field.colId()];
const value = cell && cell();
if (gristTypes.isCensored(value) && !this.origColumn.isFormula.peek()) {
this._fieldEditorHolder.clear();
return;
return clearOwn();
}
const editorCtor: typeof NewBaseEditor =
UserTypeImpl.getEditorConstructor(this.options().widget, this._readOnlyPureType());
// constructor may be null for a read-only non-formula field, though not today.
if (!editorCtor) {
// Actually, we only expect buildEditorDom() to be called when isEditorActive() is false (i.e.
// _fieldEditorHolder is already clear), but clear here explicitly for clarity.
this._fieldEditorHolder.clear();
return;
return clearOwn();
}
// if editor doesn't support readonly mode, don't show it
if (this._readonly.get() && editorCtor.supportsReadonly && !editorCtor.supportsReadonly()) {
this._fieldEditorHolder.clear();
return;
return clearOwn();
}
if (!this._readonly.get() && saveWithoutEditor(editorCtor, editRow, this.field, options.init)) {
this._fieldEditorHolder.clear();
return;
return clearOwn();
}
const cellElem = this._rowMap.get(mainRowModel)!;
// The editor may dispose itself; the Holder will know to clear itself in this case.
const fieldEditor = FieldEditor.create(this._fieldEditorHolder, {
const fieldEditor = FieldEditor.create(holder, {
gristDoc: this.gristDoc,
field: this.field,
cursor: this._cursor,
@@ -720,15 +719,13 @@ export class FieldBuilder extends Disposable {
startVal: this._readonly.get() ? undefined : options.init, // don't start with initial value
readonly: this._readonly.get() // readonly for editor will not be observable
});
// Put the FieldEditor into a holder in GristDoc too. This way any existing FieldEditor (perhaps
// for another field, or for another BaseView) will get disposed at this time. The reason to
// 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);
this._isEditorActive.set(true);
// expose the active editor in a grist doc as an observable
fieldEditor.onDispose(() => this.gristDoc.activeEditor.set(null));
fieldEditor.onDispose(() => {
this._isEditorActive.set(false);
this.gristDoc.activeEditor.set(null);
});
this.gristDoc.activeEditor.set(fieldEditor);
}
@@ -742,11 +739,12 @@ export class FieldBuilder extends Disposable {
if (editRow._isAddRow.peek() || this._readonly.get()) {
return;
}
const holder = this.gristDoc.fieldEditorHolder;
const cell = editRow.cells[this.field.colId()];
const value = cell && cell();
if (gristTypes.isCensored(value)) {
this._fieldEditorHolder.clear();
holder.clear();
return;
}
@@ -770,7 +768,8 @@ export class FieldBuilder extends Disposable {
}
public isEditorActive() {
return !this._fieldEditorHolder.isEmpty();
const holder = this.gristDoc.fieldEditorHolder;
return !holder.isEmpty() && this._isEditorActive.get();
}
/**
@@ -782,19 +781,74 @@ export class FieldBuilder extends Disposable {
editValue?: string,
onSave?: (column: ColumnRec, formula: string) => Promise<void>,
onCancel?: () => void) {
const editorHolder = openFormulaEditor({
// Remember position when the popup was opened.
const position = this.gristDoc.cursorPosition.get();
// Create a controller for the floating editor. It is primarily responsible for moving the editor
// dom from the place where it was rendered to the popup (and moving it back).
const floatController = {
attach: async (content: HTMLElement) => {
// If we haven't change page and the element is still in the DOM, move the editor to the
// back to where it was rendered. It still has it's content, so no need to dispose it.
if (refElem.isConnected) {
formulaEditor.attach(refElem);
} else {
// Else, we will navigate to the position we left off, dispose the editor and the content.
formulaEditor.dispose();
grainjsDom.domDispose(content);
await this.gristDoc.recursiveMoveToCursorPos(position!, true);
}
},
detach() {
return formulaEditor.detach();
},
autoDispose(el: Disposable) {
return formulaEditor.autoDispose(el);
},
dispose() {
formulaEditor.dispose();
}
};
// Create a custom cleanup method, that won't destroy us when we loose focus while being detached.
function setupEditorCleanup(
owner: MultiHolder, gristDoc: GristDoc,
editingFormula: ko.Computed<boolean>, _saveEdit: () => Promise<unknown>
) {
// Just override the behavior on focus lost.
const saveOnFocus = () => floatingExtension.active.get() ? void 0 : _saveEdit().catch(reportError);
UnsavedChange.create(owner, async () => { await saveOnFocus(); });
gristDoc.app.on('clipboard_focus', saveOnFocus);
owner.onDispose(() => {
gristDoc.app.off('clipboard_focus', saveOnFocus);
editingFormula(false);
});
}
// Get the field model from metatables, as the one provided by the caller might be some floating one, that
// will change when user navigates around.
const field = this.gristDoc.docModel.viewFields.getRowModel(this.field.getRowId());
// Finally create the editor passing only the field, which will enable detachable flavor of formula editor.
const formulaEditor = openFormulaEditor({
gristDoc: this.gristDoc,
column: this.field.column(),
field,
editingFormula: this.field.editingFormula,
setupCleanup: setupEditorCleanup,
editRow,
refElem,
editValue,
canDetach: true,
onSave,
onCancel
});
// And now create the floating editor itself. It is just a floating wrapper that will grab the dom
// from the editor and show it in the popup. It also overrides various parts of Grist to make smoother experience.
const floatingExtension = FloatingEditor.create(formulaEditor, floatController, this.gristDoc);
// Add editor to document holder - this will prevent multiple formula editor instances.
this.gristDoc.fieldEditorHolder.autoDispose(editorHolder);
this.gristDoc.fieldEditorHolder.autoDispose(formulaEditor);
}
}