mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
]));
|
||||
}
|
||||
|
||||
129
app/client/widgets/FloatingEditor.ts
Normal file
129
app/client/widgets/FloatingEditor.ts
Normal 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;
|
||||
}
|
||||
`);
|
||||
1101
app/client/widgets/FormulaAssistant.ts
Normal file
1101
app/client/widgets/FormulaAssistant.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
`);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user