(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

@@ -200,6 +200,7 @@ export class ConditionalStyle extends Disposable {
editRow: vsi?.moveEditRowToCursor(),
refElem,
setupCleanup: setupEditorCleanup,
canDetach: false,
});
// Add editor to document holder - this will prevent multiple formula editor instances.
this._gristDoc.fieldEditorHolder.autoDispose(editorHolder);

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);
}
}

View File

@@ -1,5 +1,5 @@
import * as commands from 'app/client/components/commands';
import {Cursor} from 'app/client/components/Cursor';
import {Cursor, CursorPos} from 'app/client/components/Cursor';
import {GristDoc} from 'app/client/components/GristDoc';
import {UnsavedChange} from 'app/client/components/UnsavedChanges';
import {makeT} from 'app/client/lib/localization';
@@ -12,9 +12,10 @@ import {IEditorCommandGroup, NewBaseEditor} from 'app/client/widgets/NewBaseEdit
import {asyncOnce} from "app/common/AsyncCreate";
import {CellValue} from "app/common/DocActions";
import * as gutil from 'app/common/gutil';
import {Disposable, Emitter, Holder, MultiHolder} from 'grainjs';
import isEqual = require('lodash/isEqual');
import {CellPosition} from "app/client/components/CellPosition";
import {FloatingEditor} from 'app/client/widgets/FloatingEditor';
import isEqual = require('lodash/isEqual');
import {Disposable, dom, Emitter, Holder, MultiHolder, Observable} from 'grainjs';
type IEditorConstructor = typeof NewBaseEditor;
@@ -63,6 +64,7 @@ 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());
public floatingEditor: FloatingEditor;
private _gristDoc: GristDoc;
private _field: ViewFieldRec;
@@ -76,6 +78,8 @@ export class FieldEditor extends Disposable {
private _editorHasChanged = false;
private _isFormula = false;
private _readonly = false;
private _detached = Observable.create(this, false);
private _detachedAt: CursorPos|null = null;
constructor(options: {
gristDoc: GristDoc,
@@ -154,6 +158,9 @@ export class FieldEditor extends Disposable {
this.rebuildEditor(editValue, Number.POSITIVE_INFINITY, options.state);
// Create a floating editor, which will be used to display the editor in a popup.
this.floatingEditor = FloatingEditor.create(this, this, this._gristDoc);
if (offerToMakeFormula) {
this._offerToMakeFormula();
}
@@ -162,9 +169,16 @@ export class FieldEditor extends Disposable {
// when user or server refreshes the browser
this._gristDoc.editorMonitor.monitorEditor(this);
// For detached editor, we don't need to cleanup anything.
// It will be cleanuped automatically.
const onCleanup = async () => {
if (this._detached.get()) { return; }
await this._saveEdit();
};
// for readonly field we don't need to do anything special
if (!options.readonly) {
setupEditorCleanup(this, this._gristDoc, this._field.editingFormula, this._saveEdit);
setupEditorCleanup(this, this._gristDoc, this._field.editingFormula, onCleanup);
} else {
setupReadonlyEditorCleanup(this, this._gristDoc, this._field, () => this._cancelEdit());
}
@@ -190,7 +204,13 @@ export class FieldEditor extends Disposable {
cellValue = cellCurrentValue;
}
const error = getFormulaError(this._gristDoc, this._editRow, column);
const errorHolder = new MultiHolder();
const error = getFormulaError(errorHolder, {
gristDoc: this._gristDoc,
editRow: this._editRow,
field: this._field
});
// For readonly mode use the default behavior of Formula Editor
// TODO: cleanup this flag - it gets modified in too many places
@@ -198,9 +218,11 @@ 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(this._isFormula && editValue !== undefined);
const active = this._isFormula && editValue !== undefined;
this._field.editingFormula(active);
}
this._detached.set(false);
this._editorHasChanged = false;
// Replace the item in the Holder with a new one, disposing the previous one.
const editor = this._editorHolder.autoDispose(editorCtor.create({
@@ -214,10 +236,13 @@ export class FieldEditor extends Disposable {
editValue,
cursorPos,
state,
canDetach: true,
commands: this._editCommands,
readonly : this._readonly
}));
editor.autoDispose(errorHolder);
// if editor supports live changes, connect it to the change emitter
if (editor.editorState) {
editor.autoDispose(editor.editorState.addListener((currentState) => {
@@ -235,6 +260,28 @@ export class FieldEditor extends Disposable {
editor.attach(this._cellElem);
}
public detach() {
this._detached.set(true);
this._detachedAt = this._gristDoc.cursorPosition.get()!;
return this._editorHolder.get()!.detach()!;
}
public async attach(content: HTMLElement) {
// If we are disconnected from the dom (maybe page was changed or something), we can't
// simply attach the editor back, we need to rebuild it.
if (!this._cellElem.isConnected) {
dom.domDispose(content);
if (await this._gristDoc.recursiveMoveToCursorPos(this._detachedAt!, true)) {
await this._gristDoc.activateEditorAtCursor();
}
this.dispose();
return;
}
this._detached.set(false);
this._editorHolder.get()?.attach(this._cellElem);
this._field.viewSection.peek().hasFocus(true);
}
public getDom() {
return this._editorHolder.get()?.getDom();
}
@@ -242,7 +289,7 @@ export class FieldEditor extends Disposable {
// calculate current cell's absolute position
public cellPosition() {
const rowId = this._editRow.getRowId();
const colRef = this._field.colRef.peek();
const colRef = this._field.column.peek().origColRef.peek();
const sectionId = this._field.viewSection.peek().id.peek();
const position = {
rowId,
@@ -344,7 +391,7 @@ export class FieldEditor extends Disposable {
col.updateColValues({isFormula, formula}),
// If we're saving a non-empty formula, then also add an empty record to the table
// so that the formula calculation is visible to the user.
(this._editRow._isAddRow.peek() && formula !== "" ?
(!this._detached.get() && this._editRow._isAddRow.peek() && formula !== "" ?
this._editRow.updateColValues({}) : undefined),
]));
}

View File

@@ -0,0 +1,129 @@
import * as commands from 'app/client/components/commands';
import {GristDoc} from 'app/client/components/GristDoc';
import {detachNode} from 'app/client/lib/dom';
import {FocusLayer} from 'app/client/lib/FocusLayer';
import {FloatingPopup} from 'app/client/ui/FloatingPopup';
import {theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {Disposable, dom, Holder, IDisposableOwner, IDomArgs,
makeTestId, MultiHolder, Observable, styled} from 'grainjs';
export interface IFloatingOwner extends IDisposableOwner {
detach(): HTMLElement;
attach(content: HTMLElement): Promise<void>|void;
}
const testId = makeTestId('test-floating-editor-');
export class FloatingEditor extends Disposable {
public active = Observable.create<boolean>(this, false);
constructor(private _fieldEditor: IFloatingOwner, private _gristDoc: GristDoc) {
super();
this.autoDispose(commands.createGroup({
detachEditor: this.createPopup.bind(this),
}, this, true));
}
public createPopup() {
const editor = this._fieldEditor;
const popupOwner = Holder.create(editor);
const tempOwner = new MultiHolder();
try {
// Create a layer to grab the focus, when we will move the editor to the popup. Otherwise the focus
// will be moved to the clipboard which can destroy us (as it will be treated as a clickaway). So here
// we are kind of simulating always focused editor (even if it is not in the dom for a brief moment).
FocusLayer.create(tempOwner, { defaultFocusElem: document.activeElement as any});
// Take some data from gristDoc to create a title.
const cursor = this._gristDoc.cursorPosition.get()!;
const vs = this._gristDoc.docModel.viewSections.getRowModel(cursor.sectionId!);
const table = vs.tableId.peek();
const field = vs.viewFields.peek().at(cursor.fieldIndex!)!;
const title = `${table}.${field.label.peek()}`;
let content: HTMLElement;
// Now create the popup. It will be owned by the editor itself.
const popup = FloatingPopup.create(popupOwner, {
content: () => (content = editor.detach()), // this will be called immediately, and will move some dom between
// existing editor and the popup. We need to save it, so we can
// detach it on close.
title: () => title, // We are not reactive yet
closeButton: true, // Show the close button with a hover
closeButtonHover: () => 'Return to cell',
onClose: async () => {
const layer = FocusLayer.create(null, { defaultFocusElem: document.activeElement as any});
try {
detachNode(content);
popupOwner.dispose();
await editor.attach(content);
} finally {
layer.dispose();
}
},
args: [testId('popup')]
});
// Set a public flag that we are active.
this.active.set(true);
popup.onDispose(() => {
this.active.set(false);
});
// Show the popup with the editor.
popup.showPopup();
} finally {
// Dispose the focus layer, we only needed it for the time when the dom was moved between parents.
tempOwner.dispose();
}
}
}
export function createDetachedIcon(...args: IDomArgs<HTMLDivElement>) {
return cssResizeIconWrapper(
cssSmallIcon('Maximize'),
dom.on('click', (e) => {
e.stopPropagation();
e.preventDefault();
commands.allCommands.detachEditor.run();
}),
dom.on('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
}),
testId('detach-button'),
...args
);
}
const cssSmallIcon = styled(icon, `
width: 14px;
height: 14px;
`);
const cssResizeIconWrapper = styled('div', `
position: absolute;
right: -2px;
top: -20px;
line-height: 0px;
cursor: pointer;
z-index: 10;
--icon-color: ${theme.cellBg};
background: var(--grist-theme-control-primary-bg, var(--grist-primary-fg));
height: 20px;
width: 21px;
--icon-color: white;
display: flex;
align-items: center;
justify-content: center;
line-height: 0px;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
&:hover {
background: var(--grist-theme-control-primary-hover-bg, var(--grist-primary-fg-hover))
}
& > div {
transition: background .05s ease-in-out;
}
`);

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +1,26 @@
import * as AceEditor from 'app/client/components/AceEditor';
import {createGroup} from 'app/client/components/commands';
import {CommandName} from 'app/client/components/commandList';
import * as commands from 'app/client/components/commands';
import {GristDoc} from 'app/client/components/GristDoc';
import {makeT} from 'app/client/lib/localization';
import {DataRowModel} from 'app/client/models/DataRowModel';
import {ColumnRec} from 'app/client/models/DocModel';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {reportError} from 'app/client/models/errors';
import {GRIST_FORMULA_ASSISTANT} from 'app/client/models/features';
import {colors, testId, theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {createMobileButtons, getButtonMargins} from 'app/client/widgets/EditorButtons';
import {EditorPlacement, ISize} from 'app/client/widgets/EditorPlacement';
import {createDetachedIcon} from 'app/client/widgets/FloatingEditor';
import {buildRobotIcon, FormulaAssistant} from 'app/client/widgets/FormulaAssistant';
import {NewBaseEditor, Options} from 'app/client/widgets/NewBaseEditor';
import {undef} from 'app/common/gutil';
import {Computed, Disposable, dom, MultiHolder, Observable, styled, subscribe} from 'grainjs';
import {isRaisedException} from "app/common/gristTypes";
import {decodeObject, RaisedException} from "app/plugin/objtypes";
import {GristDoc} from 'app/client/components/GristDoc';
import {ColumnRec} from 'app/client/models/DocModel';
import {asyncOnce} from 'app/common/AsyncCreate';
import {reportError} from 'app/client/models/errors';
import {CellValue} from 'app/common/DocActions';
import {isRaisedException} from 'app/common/gristTypes';
import {undef} from 'app/common/gutil';
import {decodeObject, RaisedException} from 'app/plugin/objtypes';
import {Computed, Disposable, dom, Holder, MultiHolder, Observable, styled, subscribe} from 'grainjs';
import debounce = require('lodash/debounce');
// How wide to expand the FormulaEditor when an error is shown in it.
@@ -25,8 +29,10 @@ const t = makeT('FormulaEditor');
export interface IFormulaEditorOptions extends Options {
cssClass?: string;
editingFormula: ko.Computed<boolean>,
column: ColumnRec,
editingFormula: ko.Computed<boolean>;
column: ColumnRec;
field?: ViewFieldRec;
canDetach?: boolean;
}
@@ -42,10 +48,13 @@ export interface IFormulaEditorOptions extends Options {
* should save the value on `blur` event.
*/
export class FormulaEditor extends NewBaseEditor {
public isDetached = Observable.create(this, false);
protected options: IFormulaEditorOptions;
private _formulaEditor: any;
private _commandGroup: any;
private _dom: HTMLElement;
private _editorPlacement!: EditorPlacement;
private _placementHolder = Holder.create(this);
constructor(options: IFormulaEditorOptions) {
super(options);
@@ -67,19 +76,50 @@ export class FormulaEditor extends NewBaseEditor {
readonly: options.readonly
});
const allCommands = !options.readonly
? Object.assign({ setCursor: this._onSetCursor }, options.commands)
// for readonly mode don't grab cursor when clicked away - just move the cursor
: options.commands;
this._commandGroup = this.autoDispose(createGroup(allCommands, this, editingFormula));
// For editable editor we will grab the cursor when we are in the formula editing mode.
const cursorCommands = options.readonly ? {} : { setCursor: this._onSetCursor };
const isActive = Computed.create(this, use => Boolean(use(editingFormula)));
const commandGroup = this.autoDispose(commands.createGroup(cursorCommands, this, isActive));
// We will create a group of editor commands right away.
const editorGroup = this.autoDispose(commands.createGroup({
...options.commands,
}, this, true));
// Merge those two groups into one.
const aceCommands: any = {
knownKeys: {...commandGroup.knownKeys, ...editorGroup.knownKeys},
commands: {...commandGroup.commands, ...editorGroup.commands},
};
// Tab, Shift + Tab, Enter should be handled by the editor itself when we are in the detached mode.
// We will create disabled group, but will push those commands to the editor directly.
const passThrough = (name: CommandName) => () => {
if (this.isDetached.get()) {
// For detached editor, just leave the default behavior.
return true;
}
// Else invoke regular command.
commands.allCommands[name]?.run();
return false;
};
const detachedCommands = this.autoDispose(commands.createGroup({
nextField: passThrough('nextField'),
prevField: passThrough('prevField'),
fieldEditSave: passThrough('fieldEditSave'),
}, this, false /* don't activate, we're just borrowing constructor */));
Object.assign(aceCommands.knownKeys, detachedCommands.knownKeys);
Object.assign(aceCommands.commands, detachedCommands.commands);
const hideErrDetails = Observable.create(this, true);
const raisedException = Computed.create(this, use => {
if (!options.formulaError) {
if (!options.formulaError || !use(options.formulaError)) {
return null;
}
const error = isRaisedException(use(options.formulaError)) ?
decodeObject(use(options.formulaError)) as RaisedException:
const error = isRaisedException(use(options.formulaError)!) ?
decodeObject(use(options.formulaError)!) as RaisedException:
new RaisedException(["Unknown error"]);
return error;
});
@@ -98,10 +138,13 @@ export class FormulaEditor extends NewBaseEditor {
// Once the exception details are available, update the sizing. The extra delay is to allow
// the DOM to update before resizing.
this.autoDispose(errorDetails.addListener(() => setTimeout(() => this._formulaEditor.resize(), 0)));
this.autoDispose(errorDetails.addListener(() => setTimeout(this.resize.bind(this), 0)));
const canDetach = GRIST_FORMULA_ASSISTANT().get() && options.canDetach && !options.readonly;
this.autoDispose(this._formulaEditor);
this._dom = dom('div.default_editor.formula_editor_wrapper',
this._dom = cssFormulaEditor(
buildRobotIcon(),
// switch border shadow
dom.cls("readonly_editor", options.readonly),
createMobileButtons(options.commands),
@@ -109,9 +152,28 @@ export class FormulaEditor extends NewBaseEditor {
// This shouldn't be needed, but needed for tests.
dom.on('mousedown', (ev) => {
// If we are detached, allow user to click and select error text.
if (this.isDetached.get()) {
// If the focus is already in this editor, don't steal it. This is needed for detached editor with
// some input elements (mainly the AI assistant).
const inInput = document.activeElement instanceof HTMLInputElement
|| document.activeElement instanceof HTMLTextAreaElement;
if (inInput && this._dom.contains(document.activeElement)) {
return;
}
// Allow clicking the error message.
if (ev.target instanceof HTMLElement && (
ev.target.classList.contains('error_msg') ||
ev.target.classList.contains('error_details_inner')
)) {
return;
}
}
ev.preventDefault();
this._formulaEditor.getEditor().focus();
this.focus();
}),
canDetach ? createDetachedIcon(dom.hide(this.isDetached)) : null,
cssFormulaEditor.cls('-detached', this.isDetached),
dom('div.formula_editor.formula_field_edit', testId('formula-editor'),
this._formulaEditor.buildDom((aceObj: any) => {
aceObj.setFontSize(11);
@@ -121,7 +183,7 @@ export class FormulaEditor extends NewBaseEditor {
const val = initialValue;
const pos = Math.min(options.cursorPos, val.length);
this._formulaEditor.setValue(val, pos);
this._formulaEditor.attachCommandGroup(this._commandGroup);
this._formulaEditor.attachCommandGroup(aceCommands);
// enable formula editing if state was passed
if (options.state || options.readonly) {
@@ -132,48 +194,74 @@ export class FormulaEditor extends NewBaseEditor {
aceObj.gotoLine(0, 0); // By moving, ace editor won't highlight anything
}
// This catches any change to the value including e.g. via backspace or paste.
aceObj.once("change", () => editingFormula?.(true));
aceObj.once("change", () => {
editingFormula?.(true);
});
})
),
(options.formulaError ? [
dom('div.error_msg', testId('formula-error-msg'),
dom.on('click', () => {
if (errorDetails.get()){
hideErrDetails.set(!hideErrDetails.get());
this._formulaEditor.resize();
}
}),
dom.maybe(errorDetails, () =>
dom.domComputed(hideErrDetails, (hide) => cssCollapseIcon(hide ? 'Expand' : 'Collapse'))
),
dom.text(errorText),
dom.maybe(options.formulaError, () => [
dom('div.error_msg', testId('formula-error-msg'),
dom.on('click', () => {
if (this.isDetached.get()) { return; }
if (errorDetails.get()){
hideErrDetails.set(!hideErrDetails.get());
this._formulaEditor.resize();
}
}),
dom.maybe(errorDetails, () =>
dom.domComputed(hideErrDetails, (hide) => cssCollapseIcon(
hide ? 'Expand' : 'Collapse',
testId('formula-error-expand'),
dom.on('click', () => {
if (!this.isDetached.get()) { return; }
if (errorDetails.get()){
hideErrDetails.set(!hideErrDetails.get());
this._formulaEditor.resize();
}
})
))
),
dom.maybe(use => Boolean(use(errorDetails) && !use(hideErrDetails)), () =>
dom('div.error_details',
dom('div.error_details_inner',
dom.text(errorDetails),
),
testId('formula-error-details'),
)
dom.text(errorText),
),
dom.maybe(use => Boolean(use(errorDetails) && !use(hideErrDetails)), () =>
dom('div.error_details',
dom('div.error_details_inner',
dom.text(errorDetails),
),
testId('formula-error-details'),
)
] : null
)
)
]),
dom.maybe(this.isDetached, () => {
return dom.create(FormulaAssistant, {
column: this.options.column,
field: this.options.field,
gristDoc: this.options.gristDoc,
editor: this,
});
}),
);
}
public attach(cellElem: Element): void {
this._editorPlacement = EditorPlacement.create(this, this._dom, cellElem, {margins: getButtonMargins()});
this.isDetached.set(false);
this._editorPlacement = EditorPlacement.create(
this._placementHolder, this._dom, cellElem, {margins: getButtonMargins()});
// Reposition the editor if needed for external reasons (in practice, window resize).
this.autoDispose(this._editorPlacement.onReposition.addListener(
this._formulaEditor.resize, this._formulaEditor));
this.autoDispose(this._editorPlacement.onReposition.addListener(this._formulaEditor.resize, this._formulaEditor));
this._formulaEditor.onAttach();
this._formulaEditor.editor.focus();
this._formulaEditor.resize();
this.focus();
}
public getDom(): HTMLElement {
return this._dom;
}
public setFormula(formula: string) {
this._formulaEditor.setValue(formula);
}
public getCellValue() {
const value = this._formulaEditor.getValue();
// Strip the leading "=" sign, if any, in case users think it should start the formula body (as
@@ -190,14 +278,47 @@ export class FormulaEditor extends NewBaseEditor {
return aceObj.getSession().getDocument().positionToIndex(aceObj.getCursorPosition());
}
public focus() {
if (this.isDisposed()) { return; }
this._formulaEditor.getEditor().focus();
}
public resize() {
if (this.isDisposed()) { return; }
this._formulaEditor.resize();
}
public detach() {
// Remove the element from the dom (to prevent any autodispose) from happening.
this._dom.parentNode?.removeChild(this._dom);
// First mark that we are detached, to show the buttons,
// and halt the autosizing mechanism.
this.isDetached.set(true);
// Finally, destroy the normal inline placement helper.
this._placementHolder.clear();
// We are going in the full formula edit mode right away.
this.options.editingFormula(true);
// Set the focus in timeout, as the dom is added after this function.
setTimeout(() => !this.isDisposed() && this._formulaEditor.resize(), 0);
// Return the dom, it will be moved to the floating editor.
return this._dom;
}
private _calcSize(elem: HTMLElement, desiredElemSize: ISize) {
if (this.isDetached.get()) {
// If we are detached, we will stop autosizing.
return {
height: 0,
width: 0
};
}
const errorBox: HTMLElement|null = this._dom.querySelector('.error_details');
const errorBoxStartHeight = errorBox?.getBoundingClientRect().height || 0;
const errorBoxDesiredHeight = errorBox?.scrollHeight || 0;
// If we have an error to show, ask for a larger size for formulaEditor.
const desiredSize = {
width: Math.max(desiredElemSize.width, (this.options.formulaError ? minFormulaErrorWidth : 0)),
width: Math.max(desiredElemSize.width, (this.options.formulaError.get() ? minFormulaErrorWidth : 0)),
// Ask for extra space for the error; we'll decide how to allocate it below.
height: desiredElemSize.height + (errorBoxDesiredHeight - errorBoxStartHeight),
};
@@ -216,35 +337,42 @@ export class FormulaEditor extends NewBaseEditor {
}
// TODO: update regexes to unicode?
private _onSetCursor(row: DataRowModel, col: ViewFieldRec) {
if (!col) { return; } // if clicked on row header, no col to insert
private _onSetCursor(row?: DataRowModel, col?: ViewFieldRec) {
// Don't do anything when we are readonly.
if (this.options.readonly) { return; }
// If we don't have column information, we can't insert anything.
if (!col) { return; }
const colId = col.origCol.peek().colId.peek();
const aceObj = this._formulaEditor.getEditor();
if (!aceObj.selection.isEmpty()) { // If text selected, replace whole selection
aceObj.session.replace(aceObj.selection.getRange(), '$' + col.colId());
// Rect only to columns in the same table.
if (col.tableId.peek() !== this.options.column.table.peek().tableId.peek()) {
// aceObj.focus();
this.options.gristDoc.onSetCursorPos(row, col).catch(reportError);
return;
}
} else { // Not a selection, gotta figure out what to replace
if (!aceObj.selection.isEmpty()) {
// If text selected, replace whole selection
aceObj.session.replace(aceObj.selection.getRange(), '$' + colId);
} else {
// Not a selection, gotta figure out what to replace
const pos = aceObj.getCursorPosition();
const line = aceObj.session.getLine(pos.row);
const result = _isInIdentifier(line, pos.column); // returns {start, end, id} | null
if (!result) { // Not touching an identifier, insert colId as normal
aceObj.insert("$" + col.colId());
// We are touching an identifier
} else if (result.ident.startsWith("$")) { // If ident is a colId, replace it
const idRange = AceEditor.makeRange(pos.row, result.start, pos.row, result.end);
aceObj.session.replace(idRange, "$" + col.colId());
if (!result) {
// Not touching an identifier, insert colId as normal
aceObj.insert('$' + colId);
// We are touching an identifier
} else if (result.ident.startsWith('$')) {
// If ident is a colId, replace it
const idRange = AceEditor.makeRange(pos.row, result.start, pos.row, result.end);
aceObj.session.replace(idRange, '$' + colId);
}
// Else touching a normal identifier, dont mangle it
// Else touching a normal identifier, don't mangle it
}
// Resize editor in case it is needed.
this._formulaEditor.resize();
aceObj.focus();
@@ -276,6 +404,7 @@ export function openFormulaEditor(options: {
gristDoc: GristDoc,
// Associated formula from a different column (for example style rule).
column?: ColumnRec,
// Associated formula from a view field. If provided together with column, this field is used
field?: ViewFieldRec,
editingFormula?: ko.Computed<boolean>,
// Needed to get exception value, if any.
@@ -285,24 +414,38 @@ export function openFormulaEditor(options: {
editValue?: string,
onSave?: (column: ColumnRec, formula: string) => Promise<void>,
onCancel?: () => void,
canDetach?: boolean,
// Called after editor is created to set up editor cleanup (e.g. saving on click-away).
setupCleanup: (
owner: MultiHolder,
owner: Disposable,
doc: GristDoc,
editingFormula: ko.Computed<boolean>,
save: () => Promise<void>
) => void,
}): Disposable {
}): FormulaEditor {
const {gristDoc, editRow, refElem, setupCleanup} = options;
const holder = MultiHolder.create(null);
const attachedHolder = new MultiHolder();
if (options.field) {
options.column = options.field.origCol();
} else if (options.canDetach) {
throw new Error('Field is required for detached editor');
}
// We can't rely on the field passed in, we need to create our own.
const column = options.column ?? options.field?.column();
if (!column) {
throw new Error(t('Column or field is required'));
throw new Error('Column or field is required');
}
// AsyncOnce ensures it's called once even if triggered multiple times.
const saveEdit = asyncOnce(async () => {
const detached = editor.isDetached.get();
if (detached) {
editor.dispose();
return;
}
const formula = String(editor.getCellValue());
if (formula !== column.formula.peek()) {
if (options.onSave) {
@@ -310,9 +453,9 @@ export function openFormulaEditor(options: {
} else {
await column.updateColValues({formula});
}
holder.dispose();
editor.dispose();
} else {
holder.dispose();
editor.dispose();
options.onCancel?.();
}
});
@@ -321,23 +464,31 @@ export function openFormulaEditor(options: {
const editCommands = {
fieldEditSave: () => { saveEdit().catch(reportError); },
fieldEditSaveHere: () => { saveEdit().catch(reportError); },
fieldEditCancel: () => { holder.dispose(); options.onCancel?.(); },
fieldEditCancel: () => { editor.dispose(); options.onCancel?.(); },
};
// Replace the item in the Holder with a new one, disposing the previous one.
const editor = FormulaEditor.create(holder, {
const formulaError = editRow ? getFormulaError(attachedHolder, {
gristDoc,
editRow,
column,
field: options.field,
}) : undefined;
const editor = FormulaEditor.create(null, {
gristDoc,
column,
field: options.field,
editingFormula: options.editingFormula,
rowId: editRow ? editRow.id() : 0,
cellValue: column.formula(),
formulaError: editRow ? getFormulaError(gristDoc, editRow, column) : undefined,
formulaError,
editValue: options.editValue,
cursorPos: Number.POSITIVE_INFINITY, // Position of the caret within the editor.
commands: editCommands,
cssClass: 'formula_editor_sidepane',
readonly : false
} as IFormulaEditorOptions);
readonly : false,
canDetach: options.canDetach
} as IFormulaEditorOptions) as FormulaEditor;
editor.autoDispose(attachedHolder);
editor.attach(refElem);
const editingFormula = options.editingFormula ?? options?.field?.editingFormula;
@@ -353,30 +504,92 @@ export function openFormulaEditor(options: {
if (!column.formula()) {
editingFormula(true);
}
setupCleanup(holder, gristDoc, editingFormula, saveEdit);
return holder;
setupCleanup(editor, gristDoc, editingFormula, saveEdit);
return editor;
}
/**
* If the cell at the given row and column is a formula value containing an exception, return an
* observable with this exception, and fetch more details to add to the observable.
*/
export function getFormulaError(
gristDoc: GristDoc, editRow: DataRowModel, column: ColumnRec
): Observable<CellValue>|undefined {
const colId = column.colId.peek();
const cellCurrentValue = editRow.cells[colId].peek();
const isFormula = column.isFormula() || column.hasTriggerFormula();
if (isFormula && isRaisedException(cellCurrentValue)) {
const formulaError = Observable.create(null, cellCurrentValue);
gristDoc.docData.getFormulaError(column.table().tableId(), colId, editRow.getRowId())
.then(value => {
formulaError.set(value);
})
.catch(reportError);
export function getFormulaError(owner: Disposable, options: {
gristDoc: GristDoc,
editRow: DataRowModel,
column?: ColumnRec,
field?: ViewFieldRec,
}): Observable<CellValue|undefined> {
const {gristDoc, editRow} = options;
const formulaError = Observable.create(owner, undefined as any);
// When we don't have a field information we don't need to be reactive at all.
if (!options.field) {
const column = options.column!;
const colId = column.colId.peek();
const onValueChange = errorMonitor(gristDoc, column, editRow, owner, formulaError);
const subscription = editRow.cells[colId].subscribe(onValueChange);
owner.autoDispose(subscription);
onValueChange(editRow.cells[colId].peek());
return formulaError;
} else {
// We can't rely on the editRow we got, as this is owned by the view. When we will be detached the view will be
// gone. So, we will create our own observable that will be updated when the row is updated.
const errorRow: DataRowModel = gristDoc.getTableModel(options.field.tableId.peek()).createFloatingRowModel() as any;
errorRow.assign(editRow.getRowId());
owner.autoDispose(errorRow);
// When we have a field information we will grab the error from the column that is currently connected to the field.
// This will change when user is using the preview feature in detached editor, where a new column is created, and
// field starts showing it instead of the original column.
Computed.create(owner, use => {
// This pattern creates a subscription using compute observable.
// Create an holder for everything that is created during recomputation. It will be returned as the value
// of the computed observable, and will be disposed when the value changes.
const holder = MultiHolder.create(use.owner);
// Now subscribe to the column in the field, this is the part that will be changed when user creates a preview.
const column = use(options.field!.column);
const colId = use(column.colId);
const onValueChange = errorMonitor(gristDoc, column, errorRow, holder, formulaError);
// Unsubscribe when computed is recomputed.
holder.autoDispose(errorRow.cells[colId].subscribe(onValueChange));
// Trigger the subscription to get the initial value.
onValueChange(errorRow.cells[colId].peek());
// Return the holder, it will be disposed when the value changes.
return holder;
});
}
return undefined;
return formulaError;
}
function errorMonitor(
gristDoc: GristDoc,
column: ColumnRec,
editRow: DataRowModel,
holder: Disposable,
formulaError: Observable<CellValue|undefined> ) {
return function onValueChange(cellCurrentValue: CellValue) {
const isFormula = column.isFormula() || column.hasTriggerFormula();
if (isFormula && isRaisedException(cellCurrentValue)) {
if (!formulaError.get()) {
// Don't update it when there is already an error (to avoid flickering).
formulaError.set(cellCurrentValue);
}
gristDoc.docData.getFormulaError(column.table().tableId(), column.colId(), editRow.getRowId())
.then(value => {
if (holder.isDisposed()) { return; }
formulaError.set(value);
})
.catch((er) => {
if (!holder.isDisposed()) {
reportError(er);
}
});
} else {
formulaError.set(undefined);
}
};
}
/**
@@ -429,8 +642,44 @@ export function createFormulaErrorObs(owner: MultiHolder, gristDoc: GristDoc, or
const cssCollapseIcon = styled(icon, `
margin: -3px 4px 0 4px;
--icon-color: ${colors.slate};
cursor: pointer;
`);
export const cssError = styled('div', `
color: ${theme.errorText};
`);
const cssFormulaEditor = styled('div.default_editor.formula_editor_wrapper', `
&-detached {
height: 100%;
position: relative;
box-shadow: none;
}
&-detached .formula_editor {
flex-grow: 1;
}
&-detached .error_msg, &-detached .error_details {
flex-grow: 0;
flex-shrink: 1;
cursor: default;
}
&-detached .code_editor_container {
height: 100%;
width: 100%;
}
&-detached .ace_editor {
height: 100% !important;
width: 100% !important;
}
.floating-popup .formula_editor {
min-height: 100px;
}
.floating-popup .error_details {
min-height: 100px;
}
`);

View File

@@ -33,7 +33,7 @@ export class NTextEditor extends NewBaseEditor {
options.editValue, String(options.cellValue ?? ""));
this.editorState = Observable.create<string>(this, initialValue);
this.commandGroup = this.autoDispose(createGroup(options.commands, null, true));
this.commandGroup = this.autoDispose(createGroup(options.commands, this, true));
this._alignment = options.field.widgetOptionsJson.peek().alignment || 'left';
this._dom =
dom('div.default_editor',

View File

@@ -17,7 +17,7 @@ export interface Options {
gristDoc: GristDoc;
cellValue: CellValue;
rowId: number;
formulaError?: Observable<CellValue>;
formulaError: Observable<CellValue|undefined>;
editValue?: string;
cursorPos: number;
commands: IEditorCommandGroup;
@@ -83,6 +83,11 @@ export abstract class NewBaseEditor extends Disposable {
*/
public abstract attach(cellElem: Element): void;
/**
* Called to detach the editor and show it in the floating popup.
*/
public detach(): HTMLElement|null { return null; }
/**
* Returns DOM container with the editor, typically present and attached after attach() has been
* called.