2020-10-02 15:10:00 +00:00
|
|
|
import * as AceEditor from 'app/client/components/AceEditor';
|
2023-06-02 11:25:14 +00:00
|
|
|
import {CommandName} from 'app/client/components/commandList';
|
|
|
|
import * as commands from 'app/client/components/commands';
|
|
|
|
import {GristDoc} from 'app/client/components/GristDoc';
|
2023-01-11 17:57:42 +00:00
|
|
|
import {makeT} from 'app/client/lib/localization';
|
2020-10-02 15:10:00 +00:00
|
|
|
import {DataRowModel} from 'app/client/models/DataRowModel';
|
2023-06-02 11:25:14 +00:00
|
|
|
import {ColumnRec} from 'app/client/models/DocModel';
|
2020-10-02 15:10:00 +00:00
|
|
|
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
2023-06-02 11:25:14 +00:00
|
|
|
import {reportError} from 'app/client/models/errors';
|
2023-08-08 09:36:02 +00:00
|
|
|
import {HAS_FORMULA_ASSISTANT} from 'app/client/models/features';
|
2023-07-13 14:00:56 +00:00
|
|
|
import {hoverTooltip} from 'app/client/ui/tooltips';
|
|
|
|
import {textButton} from 'app/client/ui2018/buttons';
|
|
|
|
import {colors, testId, theme, vars} from 'app/client/ui2018/cssVars';
|
2020-10-02 15:10:00 +00:00
|
|
|
import {icon} from 'app/client/ui2018/icons';
|
2021-02-04 03:17:17 +00:00
|
|
|
import {createMobileButtons, getButtonMargins} from 'app/client/widgets/EditorButtons';
|
2020-10-02 15:10:00 +00:00
|
|
|
import {EditorPlacement, ISize} from 'app/client/widgets/EditorPlacement';
|
2023-06-02 11:25:14 +00:00
|
|
|
import {createDetachedIcon} from 'app/client/widgets/FloatingEditor';
|
2023-07-13 14:00:56 +00:00
|
|
|
import {FormulaAssistant} from 'app/client/widgets/FormulaAssistant';
|
2020-10-02 15:10:00 +00:00
|
|
|
import {NewBaseEditor, Options} from 'app/client/widgets/NewBaseEditor';
|
2022-03-22 13:41:11 +00:00
|
|
|
import {asyncOnce} from 'app/common/AsyncCreate';
|
|
|
|
import {CellValue} from 'app/common/DocActions';
|
2023-06-02 11:25:14 +00:00
|
|
|
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';
|
2022-03-22 13:41:11 +00:00
|
|
|
import debounce = require('lodash/debounce');
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
// How wide to expand the FormulaEditor when an error is shown in it.
|
|
|
|
const minFormulaErrorWidth = 400;
|
2023-01-11 17:57:42 +00:00
|
|
|
const t = makeT('FormulaEditor');
|
2020-10-02 15:10:00 +00:00
|
|
|
|
2021-03-17 03:45:44 +00:00
|
|
|
export interface IFormulaEditorOptions extends Options {
|
|
|
|
cssClass?: string;
|
2023-06-02 11:25:14 +00:00
|
|
|
editingFormula: ko.Computed<boolean>;
|
|
|
|
column: ColumnRec;
|
|
|
|
field?: ViewFieldRec;
|
|
|
|
canDetach?: boolean;
|
2021-03-17 03:45:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
/**
|
|
|
|
* Required parameters:
|
|
|
|
* @param {RowModel} options.field: ViewSectionField (i.e. column) being edited.
|
|
|
|
* @param {Object} options.cellValue: The value in the underlying cell being edited.
|
|
|
|
* @param {String} options.editValue: String to be edited.
|
|
|
|
* @param {Number} options.cursorPos: The initial position where to place the cursor.
|
|
|
|
* @param {Object} options.commands: Object mapping command names to functions, to enable as part
|
|
|
|
* of the command group that should be activated while the editor exists.
|
|
|
|
* @param {Boolean} options.omitBlurEventForObservableMode: Flag to indicate whether ace editor
|
|
|
|
* should save the value on `blur` event.
|
|
|
|
*/
|
|
|
|
export class FormulaEditor extends NewBaseEditor {
|
2023-06-02 11:25:14 +00:00
|
|
|
public isDetached = Observable.create(this, false);
|
|
|
|
protected options: IFormulaEditorOptions;
|
|
|
|
|
2023-08-08 09:36:02 +00:00
|
|
|
private _aceEditor: any;
|
2020-10-02 15:10:00 +00:00
|
|
|
private _dom: HTMLElement;
|
2022-08-08 13:32:50 +00:00
|
|
|
private _editorPlacement!: EditorPlacement;
|
2023-06-02 11:25:14 +00:00
|
|
|
private _placementHolder = Holder.create(this);
|
2023-07-13 14:00:56 +00:00
|
|
|
private _canDetach: boolean;
|
|
|
|
private _isEmpty: Computed<boolean>;
|
2020-10-02 15:10:00 +00:00
|
|
|
|
2021-03-17 03:45:44 +00:00
|
|
|
constructor(options: IFormulaEditorOptions) {
|
2020-10-02 15:10:00 +00:00
|
|
|
super(options);
|
2021-05-17 14:05:49 +00:00
|
|
|
|
2022-08-08 13:32:50 +00:00
|
|
|
const editingFormula = options.editingFormula;
|
2022-03-22 13:41:11 +00:00
|
|
|
|
2021-05-17 14:05:49 +00:00
|
|
|
const initialValue = undef(options.state as string | undefined, options.editValue, String(options.cellValue));
|
|
|
|
// create editor state observable (used by draft and latest position memory)
|
|
|
|
this.editorState = Observable.create(this, initialValue);
|
|
|
|
|
2023-07-13 14:00:56 +00:00
|
|
|
this._isEmpty = Computed.create(this, this.editorState, (_use, state) => state === '');
|
|
|
|
|
2023-08-08 09:36:02 +00:00
|
|
|
this._aceEditor = AceEditor.create({
|
2020-10-02 15:10:00 +00:00
|
|
|
// A bit awkward, but we need to assume calcSize is not used until attach() has been called
|
|
|
|
// and _editorPlacement created.
|
|
|
|
calcSize: this._calcSize.bind(this),
|
2021-06-17 16:41:07 +00:00
|
|
|
saveValueOnBlurEvent: !options.readonly,
|
|
|
|
editorState : this.editorState,
|
2024-04-26 20:34:16 +00:00
|
|
|
readonly: options.readonly,
|
|
|
|
getSuggestions: this._getSuggestions.bind(this),
|
2020-10-02 15:10:00 +00:00
|
|
|
});
|
|
|
|
|
2023-06-02 11:25:14 +00:00
|
|
|
// 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.
|
2023-07-13 14:00:56 +00:00
|
|
|
return commands.allCommands[name]?.run() ?? false;
|
2023-06-02 11:25:14 +00:00
|
|
|
};
|
|
|
|
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);
|
2020-10-02 15:10:00 +00:00
|
|
|
|
2021-09-25 19:14:19 +00:00
|
|
|
const hideErrDetails = Observable.create(this, true);
|
|
|
|
const raisedException = Computed.create(this, use => {
|
2023-06-02 11:25:14 +00:00
|
|
|
if (!options.formulaError || !use(options.formulaError)) {
|
2021-09-25 19:14:19 +00:00
|
|
|
return null;
|
|
|
|
}
|
2023-06-02 11:25:14 +00:00
|
|
|
const error = isRaisedException(use(options.formulaError)!) ?
|
|
|
|
decodeObject(use(options.formulaError)!) as RaisedException:
|
2021-09-25 19:14:19 +00:00
|
|
|
new RaisedException(["Unknown error"]);
|
|
|
|
return error;
|
|
|
|
});
|
|
|
|
const errorText = Computed.create(this, raisedException, (_, error) => {
|
|
|
|
if (!error) {
|
|
|
|
return "";
|
|
|
|
}
|
|
|
|
return error.message ? `${error.name} : ${error.message}` : error.name;
|
|
|
|
});
|
|
|
|
const errorDetails = Computed.create(this, raisedException, (_, error) => {
|
|
|
|
if (!error) {
|
|
|
|
return "";
|
|
|
|
}
|
|
|
|
return error.details ?? "";
|
|
|
|
});
|
2020-10-02 15:10:00 +00:00
|
|
|
|
2022-07-20 23:40:22 +00:00
|
|
|
// Once the exception details are available, update the sizing. The extra delay is to allow
|
|
|
|
// the DOM to update before resizing.
|
2023-06-02 11:25:14 +00:00
|
|
|
this.autoDispose(errorDetails.addListener(() => setTimeout(this.resize.bind(this), 0)));
|
|
|
|
|
2023-08-08 09:36:02 +00:00
|
|
|
this._canDetach = Boolean(options.canDetach && !options.readonly);
|
2022-07-20 23:40:22 +00:00
|
|
|
|
2023-08-08 09:36:02 +00:00
|
|
|
this.autoDispose(this._aceEditor);
|
2023-07-13 14:00:56 +00:00
|
|
|
|
|
|
|
// Show placeholder text when the formula is blank.
|
|
|
|
this._isEmpty.addListener(() => this._updateEditorPlaceholder());
|
|
|
|
|
2023-08-08 09:36:02 +00:00
|
|
|
// Disable undo/redo while the editor is detached.
|
2023-07-19 02:27:53 +00:00
|
|
|
this.isDetached.addListener((isDetached) => {
|
|
|
|
// TODO: look into whether we can support undo/redo while the editor is detached.
|
|
|
|
if (isDetached) {
|
|
|
|
options.gristDoc.getUndoStack().disable();
|
|
|
|
} else {
|
|
|
|
options.gristDoc.getUndoStack().enable();
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
this.onDispose(() => {
|
|
|
|
options.gristDoc.getUndoStack().enable();
|
|
|
|
});
|
2023-07-13 14:00:56 +00:00
|
|
|
|
2023-06-02 11:25:14 +00:00
|
|
|
this._dom = cssFormulaEditor(
|
2021-06-17 16:41:07 +00:00
|
|
|
// switch border shadow
|
|
|
|
dom.cls("readonly_editor", options.readonly),
|
2021-02-04 03:17:17 +00:00
|
|
|
createMobileButtons(options.commands),
|
2021-03-17 03:45:44 +00:00
|
|
|
options.cssClass ? dom.cls(options.cssClass) : null,
|
2021-02-04 03:17:17 +00:00
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
// This shouldn't be needed, but needed for tests.
|
|
|
|
dom.on('mousedown', (ev) => {
|
2023-06-02 11:25:14 +00:00
|
|
|
// If we are detached, allow user to click and select error text.
|
|
|
|
if (this.isDetached.get()) {
|
2023-07-05 15:36:45 +00:00
|
|
|
// If we clicked on input element in our dom, don't do anything. We probably clicked on chat input, in AI
|
|
|
|
// tools box.
|
|
|
|
const clickedOnInput = ev.target instanceof HTMLInputElement || ev.target instanceof HTMLTextAreaElement;
|
|
|
|
if (clickedOnInput && this._dom.contains(ev.target)) {
|
|
|
|
// By not doing anything special here we assume that the input element will take the focus.
|
2023-06-02 11:25:14 +00:00
|
|
|
return;
|
|
|
|
}
|
2023-08-14 22:06:01 +00:00
|
|
|
}
|
|
|
|
// Allow clicking the error message.
|
|
|
|
if (ev.target instanceof HTMLElement && (
|
|
|
|
ev.target.classList.contains('error_msg') ||
|
2023-06-02 11:25:14 +00:00
|
|
|
ev.target.classList.contains('error_details_inner')
|
2023-08-14 22:06:01 +00:00
|
|
|
)) {
|
|
|
|
return;
|
2023-06-02 11:25:14 +00:00
|
|
|
}
|
2020-10-02 15:10:00 +00:00
|
|
|
ev.preventDefault();
|
2023-06-02 11:25:14 +00:00
|
|
|
this.focus();
|
2020-10-02 15:10:00 +00:00
|
|
|
}),
|
2023-07-13 14:00:56 +00:00
|
|
|
!this._canDetach ? null : createDetachedIcon(
|
|
|
|
hoverTooltip(t('Expand Editor')),
|
|
|
|
dom.hide(this.isDetached),
|
|
|
|
),
|
2023-06-02 11:25:14 +00:00
|
|
|
cssFormulaEditor.cls('-detached', this.isDetached),
|
2020-10-02 15:10:00 +00:00
|
|
|
dom('div.formula_editor.formula_field_edit', testId('formula-editor'),
|
2023-08-08 09:36:02 +00:00
|
|
|
this._aceEditor.buildDom((aceObj: any) => {
|
2024-04-26 20:34:16 +00:00
|
|
|
initializeAceOptions(aceObj);
|
2021-05-17 14:05:49 +00:00
|
|
|
const val = initialValue;
|
2020-10-02 15:10:00 +00:00
|
|
|
const pos = Math.min(options.cursorPos, val.length);
|
2023-08-08 09:36:02 +00:00
|
|
|
this._aceEditor.setValue(val, pos);
|
|
|
|
this._aceEditor.attachCommandGroup(aceCommands);
|
2020-10-02 15:10:00 +00:00
|
|
|
|
2021-05-17 14:05:49 +00:00
|
|
|
// enable formula editing if state was passed
|
2021-06-17 16:41:07 +00:00
|
|
|
if (options.state || options.readonly) {
|
2022-03-22 13:41:11 +00:00
|
|
|
editingFormula(true);
|
2021-05-17 14:05:49 +00:00
|
|
|
}
|
2021-06-17 16:41:07 +00:00
|
|
|
if (options.readonly) {
|
2023-08-08 09:36:02 +00:00
|
|
|
this._aceEditor.enable(false);
|
2021-06-17 16:41:07 +00:00
|
|
|
aceObj.gotoLine(0, 0); // By moving, ace editor won't highlight anything
|
|
|
|
}
|
2020-10-02 15:10:00 +00:00
|
|
|
// This catches any change to the value including e.g. via backspace or paste.
|
2023-06-02 11:25:14 +00:00
|
|
|
aceObj.once("change", () => {
|
|
|
|
editingFormula?.(true);
|
|
|
|
});
|
2023-07-13 14:00:56 +00:00
|
|
|
|
|
|
|
if (val === '') {
|
|
|
|
// Show placeholder text if the formula is blank.
|
|
|
|
this._updateEditorPlaceholder();
|
|
|
|
}
|
2020-10-02 15:10:00 +00:00
|
|
|
})
|
|
|
|
),
|
2023-06-02 11:25:14 +00:00
|
|
|
dom.maybe(options.formulaError, () => [
|
|
|
|
dom('div.error_msg', testId('formula-error-msg'),
|
2023-08-14 22:06:01 +00:00
|
|
|
dom.attr('tabindex', '-1'),
|
2023-06-02 11:25:14 +00:00
|
|
|
dom.maybe(errorDetails, () =>
|
|
|
|
dom.domComputed(hideErrDetails, (hide) => cssCollapseIcon(
|
|
|
|
hide ? 'Expand' : 'Collapse',
|
|
|
|
testId('formula-error-expand'),
|
|
|
|
dom.on('click', () => {
|
|
|
|
if (errorDetails.get()){
|
|
|
|
hideErrDetails.set(!hideErrDetails.get());
|
2023-08-08 09:36:02 +00:00
|
|
|
this._aceEditor.resize();
|
2023-06-02 11:25:14 +00:00
|
|
|
}
|
|
|
|
})
|
|
|
|
))
|
2020-10-02 15:10:00 +00:00
|
|
|
),
|
2023-06-02 11:25:14 +00:00
|
|
|
dom.text(errorText),
|
|
|
|
),
|
|
|
|
dom.maybe(use => Boolean(use(errorDetails) && !use(hideErrDetails)), () =>
|
|
|
|
dom('div.error_details',
|
2023-08-14 22:06:01 +00:00
|
|
|
dom.attr('tabindex', '-1'),
|
2023-06-02 11:25:14 +00:00
|
|
|
dom('div.error_details_inner',
|
|
|
|
dom.text(errorDetails),
|
|
|
|
),
|
|
|
|
testId('formula-error-details'),
|
2021-09-25 19:14:19 +00:00
|
|
|
)
|
2023-06-02 11:25:14 +00:00
|
|
|
)
|
|
|
|
]),
|
|
|
|
dom.maybe(this.isDetached, () => {
|
|
|
|
return dom.create(FormulaAssistant, {
|
|
|
|
column: this.options.column,
|
|
|
|
field: this.options.field,
|
|
|
|
gristDoc: this.options.gristDoc,
|
|
|
|
editor: this,
|
|
|
|
});
|
|
|
|
}),
|
2020-10-02 15:10:00 +00:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2021-02-04 03:17:17 +00:00
|
|
|
public attach(cellElem: Element): void {
|
2023-06-02 11:25:14 +00:00
|
|
|
this.isDetached.set(false);
|
|
|
|
this._editorPlacement = EditorPlacement.create(
|
|
|
|
this._placementHolder, this._dom, cellElem, {margins: getButtonMargins()});
|
2021-02-04 03:17:17 +00:00
|
|
|
// Reposition the editor if needed for external reasons (in practice, window resize).
|
2023-08-08 09:36:02 +00:00
|
|
|
this.autoDispose(this._editorPlacement.onReposition.addListener(this._aceEditor.resize, this._aceEditor));
|
|
|
|
this._aceEditor.onAttach();
|
|
|
|
this._updateEditorPlaceholder();
|
|
|
|
this._aceEditor.resize();
|
2023-06-02 11:25:14 +00:00
|
|
|
this.focus();
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
|
|
|
|
2021-05-25 09:24:00 +00:00
|
|
|
public getDom(): HTMLElement {
|
|
|
|
return this._dom;
|
|
|
|
}
|
|
|
|
|
2023-06-02 11:25:14 +00:00
|
|
|
public setFormula(formula: string) {
|
2023-08-08 09:36:02 +00:00
|
|
|
this._aceEditor.setValue(formula);
|
2023-06-02 11:25:14 +00:00
|
|
|
}
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
public getCellValue() {
|
2023-08-08 09:36:02 +00:00
|
|
|
const value = this._aceEditor.getValue();
|
2023-04-25 03:01:26 +00:00
|
|
|
// Strip the leading "=" sign, if any, in case users think it should start the formula body (as
|
|
|
|
// it does in Excel, and because the equal sign is also used for formulas in Grist UI).
|
|
|
|
return (value[0] === '=') ? value.slice(1) : value;
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
public getTextValue() {
|
2023-08-08 09:36:02 +00:00
|
|
|
return this._aceEditor.getValue();
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
public getCursorPos() {
|
2023-08-08 09:36:02 +00:00
|
|
|
const aceObj = this._aceEditor.getEditor();
|
2020-10-02 15:10:00 +00:00
|
|
|
return aceObj.getSession().getDocument().positionToIndex(aceObj.getCursorPosition());
|
|
|
|
}
|
|
|
|
|
2023-06-02 11:25:14 +00:00
|
|
|
public focus() {
|
|
|
|
if (this.isDisposed()) { return; }
|
2023-08-08 09:36:02 +00:00
|
|
|
this._aceEditor.getEditor().focus();
|
2023-06-02 11:25:14 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
public resize() {
|
|
|
|
if (this.isDisposed()) { return; }
|
2023-08-08 09:36:02 +00:00
|
|
|
this._aceEditor.resize();
|
2023-06-02 11:25:14 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
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);
|
2023-08-08 09:36:02 +00:00
|
|
|
this._updateEditorPlaceholder();
|
2023-06-02 11:25:14 +00:00
|
|
|
// Set the focus in timeout, as the dom is added after this function.
|
2023-08-08 09:36:02 +00:00
|
|
|
setTimeout(() => !this.isDisposed() && this._aceEditor.resize(), 0);
|
2023-06-02 11:25:14 +00:00
|
|
|
// Return the dom, it will be moved to the floating editor.
|
|
|
|
return this._dom;
|
|
|
|
}
|
|
|
|
|
2023-07-13 14:00:56 +00:00
|
|
|
private _updateEditorPlaceholder() {
|
2023-08-08 09:36:02 +00:00
|
|
|
const editor = this._aceEditor.getEditor();
|
2023-07-13 14:00:56 +00:00
|
|
|
const shouldShowPlaceholder = editor.session.getValue().length === 0;
|
2023-08-08 09:36:02 +00:00
|
|
|
if (editor.renderer.emptyMessageNode) {
|
2023-07-13 14:00:56 +00:00
|
|
|
// Remove the current placeholder if one is present.
|
2023-08-08 09:36:02 +00:00
|
|
|
editor.renderer.scroller.removeChild(editor.renderer.emptyMessageNode);
|
2023-07-13 14:00:56 +00:00
|
|
|
}
|
|
|
|
if (!shouldShowPlaceholder) {
|
|
|
|
editor.renderer.emptyMessageNode = null;
|
|
|
|
} else {
|
2023-08-08 09:36:02 +00:00
|
|
|
const withAiButton = this._canDetach && !this.isDetached.get() && HAS_FORMULA_ASSISTANT();
|
2023-07-13 14:00:56 +00:00
|
|
|
editor.renderer.emptyMessageNode = cssFormulaPlaceholder(
|
2023-08-08 09:36:02 +00:00
|
|
|
!withAiButton
|
2023-07-13 14:00:56 +00:00
|
|
|
? t('Enter formula.')
|
|
|
|
: t('Enter formula or {{button}}.', {
|
|
|
|
button: cssUseAssistantButton(
|
|
|
|
t('use AI Assistant'),
|
|
|
|
dom.on('click', (ev) => this._handleUseAssistantButtonClick(ev)),
|
|
|
|
testId('formula-editor-use-ai-assistant'),
|
|
|
|
),
|
|
|
|
}),
|
|
|
|
);
|
|
|
|
editor.renderer.scroller.appendChild(editor.renderer.emptyMessageNode);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private _handleUseAssistantButtonClick(ev: MouseEvent) {
|
|
|
|
ev.stopPropagation();
|
|
|
|
ev.preventDefault();
|
|
|
|
commands.allCommands.detachEditor.run();
|
|
|
|
commands.allCommands.activateAssistant.run();
|
|
|
|
}
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
private _calcSize(elem: HTMLElement, desiredElemSize: ISize) {
|
2023-06-02 11:25:14 +00:00
|
|
|
if (this.isDetached.get()) {
|
|
|
|
// If we are detached, we will stop autosizing.
|
|
|
|
return {
|
|
|
|
height: 0,
|
|
|
|
width: 0
|
|
|
|
};
|
|
|
|
}
|
2023-07-13 14:00:56 +00:00
|
|
|
|
2023-08-08 09:36:02 +00:00
|
|
|
const placeholder: HTMLElement | undefined = this._aceEditor.getEditor().renderer.emptyMessageNode;
|
2023-07-13 14:00:56 +00:00
|
|
|
if (placeholder) {
|
|
|
|
// If we are showing the placeholder, fit it all on the same line.
|
|
|
|
return this._editorPlacement.calcSizeWithPadding(elem, {
|
|
|
|
width: placeholder.scrollWidth,
|
|
|
|
height: placeholder.scrollHeight,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2022-07-20 23:40:22 +00:00
|
|
|
const errorBox: HTMLElement|null = this._dom.querySelector('.error_details');
|
|
|
|
const errorBoxStartHeight = errorBox?.getBoundingClientRect().height || 0;
|
|
|
|
const errorBoxDesiredHeight = errorBox?.scrollHeight || 0;
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
// If we have an error to show, ask for a larger size for formulaEditor.
|
|
|
|
const desiredSize = {
|
2023-06-02 11:25:14 +00:00
|
|
|
width: Math.max(desiredElemSize.width, (this.options.formulaError.get() ? minFormulaErrorWidth : 0)),
|
2022-07-20 23:40:22 +00:00
|
|
|
// Ask for extra space for the error; we'll decide how to allocate it below.
|
|
|
|
height: desiredElemSize.height + (errorBoxDesiredHeight - errorBoxStartHeight),
|
2020-10-02 15:10:00 +00:00
|
|
|
};
|
2022-07-20 23:40:22 +00:00
|
|
|
const result = this._editorPlacement.calcSizeWithPadding(elem, desiredSize);
|
|
|
|
if (errorBox) {
|
|
|
|
// Note that result.height does not include errorBoxStartHeight, but includes any available
|
|
|
|
// extra space that we requested.
|
|
|
|
const availableForError = errorBoxStartHeight + (result.height - desiredElemSize.height);
|
|
|
|
// This is the key calculation: if space is available, use it; if not, give 64px to error
|
|
|
|
// (it'll scroll within that), but don't use more than desired.
|
|
|
|
const errorBoxEndHeight = Math.min(errorBoxDesiredHeight, Math.max(availableForError, 64));
|
|
|
|
errorBox.style.height = `${errorBoxEndHeight}px`;
|
|
|
|
result.height -= (errorBoxEndHeight - errorBoxStartHeight);
|
|
|
|
}
|
|
|
|
return result;
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
|
|
|
|
2024-04-26 20:34:16 +00:00
|
|
|
private _getSuggestions(prefix: string) {
|
|
|
|
const section = this.options.gristDoc.viewModel.activeSection();
|
|
|
|
// If section is disposed or is pointing to an empty row, don't try to autocomplete.
|
|
|
|
if (!section?.getRowId()) { return []; }
|
|
|
|
|
|
|
|
const tableId = section.table().tableId();
|
|
|
|
const columnId = this.options.column.colId();
|
|
|
|
const rowId = section.activeRowId();
|
|
|
|
return this.options.gristDoc.docComm.autocomplete(prefix, tableId, columnId, rowId);
|
|
|
|
}
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
// TODO: update regexes to unicode?
|
2023-06-02 11:25:14 +00:00
|
|
|
private _onSetCursor(row?: DataRowModel, col?: ViewFieldRec) {
|
|
|
|
// Don't do anything when we are readonly.
|
2021-06-17 16:41:07 +00:00
|
|
|
if (this.options.readonly) { return; }
|
2023-06-02 11:25:14 +00:00
|
|
|
// If we don't have column information, we can't insert anything.
|
|
|
|
if (!col) { return; }
|
|
|
|
|
|
|
|
const colId = col.origCol.peek().colId.peek();
|
2021-06-17 16:41:07 +00:00
|
|
|
|
2023-06-02 11:25:14 +00:00
|
|
|
if (col.tableId.peek() !== this.options.column.table.peek().tableId.peek()) {
|
2023-08-04 08:01:29 +00:00
|
|
|
// Fall back to default behavior if cursor didn't move to a column in the same table.
|
2023-06-02 11:25:14 +00:00
|
|
|
this.options.gristDoc.onSetCursorPos(row, col).catch(reportError);
|
|
|
|
return;
|
|
|
|
}
|
2020-10-02 15:10:00 +00:00
|
|
|
|
2023-08-04 08:01:29 +00:00
|
|
|
const aceObj = this._aceEditor.getEditor();
|
2023-06-02 11:25:14 +00:00
|
|
|
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
|
2020-10-02 15:10:00 +00:00
|
|
|
const pos = aceObj.getCursorPosition();
|
|
|
|
const line = aceObj.session.getLine(pos.row);
|
|
|
|
const result = _isInIdentifier(line, pos.column); // returns {start, end, id} | null
|
2023-06-02 11:25:14 +00:00
|
|
|
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);
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
2023-06-02 11:25:14 +00:00
|
|
|
// Else touching a normal identifier, don't mangle it
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
|
|
|
// Resize editor in case it is needed.
|
2023-08-08 09:36:02 +00:00
|
|
|
this._aceEditor.resize();
|
2023-06-12 22:39:53 +00:00
|
|
|
|
|
|
|
// This focus method will try to focus a textarea immediately and again on setTimeout. But
|
|
|
|
// other things may happen by the setTimeout time, messing up focus. The reason the immediate
|
|
|
|
// call doesn't usually help is that this is called on 'mousedown' before its corresponding
|
|
|
|
// focus/blur occur. We can do a bit better by restoring focus immediately after blur occurs.
|
2020-10-02 15:10:00 +00:00
|
|
|
aceObj.focus();
|
2023-06-12 22:39:53 +00:00
|
|
|
const lis = dom.onElem(aceObj.textInput.getElement(), 'blur', e => { lis.dispose(); aceObj.focus(); });
|
|
|
|
// If no blur right away, clear the listener, to avoid unexpected interference.
|
|
|
|
setTimeout(() => lis.dispose(), 0);
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// returns whether the column in that line is inside or adjacent to an identifier
|
|
|
|
// if yes, returns {start, end, ident}, else null
|
|
|
|
function _isInIdentifier(line: string, column: number) {
|
|
|
|
// If cursor is in or after an identifier, scoot back to the start of it
|
|
|
|
const prefix = line.slice(0, column);
|
|
|
|
let startOfIdent = prefix.search(/[$A-Za-z0-9_]+$/);
|
|
|
|
if (startOfIdent < 0) { startOfIdent = column; } // if no match, maybe we're right before it
|
|
|
|
|
|
|
|
// We're either before an ident or nowhere near one. Try to match to its end
|
|
|
|
const match = line.slice(startOfIdent).match(/^[$a-zA-Z0-9_]+/);
|
|
|
|
if (match) {
|
|
|
|
const ident = match[0];
|
|
|
|
return { ident, start: startOfIdent, end: startOfIdent + ident.length};
|
|
|
|
} else {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-03-22 13:41:11 +00:00
|
|
|
/**
|
|
|
|
* Open a formula editor. Returns a Disposable that owns the editor.
|
|
|
|
*/
|
|
|
|
export function openFormulaEditor(options: {
|
|
|
|
gristDoc: GristDoc,
|
2022-04-28 15:43:31 +00:00
|
|
|
// Associated formula from a different column (for example style rule).
|
2022-03-22 13:41:11 +00:00
|
|
|
column?: ColumnRec,
|
2023-06-02 11:25:14 +00:00
|
|
|
// Associated formula from a view field. If provided together with column, this field is used
|
2022-08-08 13:32:50 +00:00
|
|
|
field?: ViewFieldRec,
|
|
|
|
editingFormula?: ko.Computed<boolean>,
|
2022-03-22 13:41:11 +00:00
|
|
|
// Needed to get exception value, if any.
|
|
|
|
editRow?: DataRowModel,
|
|
|
|
// Element over which to position the editor.
|
|
|
|
refElem: Element,
|
|
|
|
editValue?: string,
|
|
|
|
onSave?: (column: ColumnRec, formula: string) => Promise<void>,
|
|
|
|
onCancel?: () => void,
|
2023-06-02 11:25:14 +00:00
|
|
|
canDetach?: boolean,
|
2022-03-22 13:41:11 +00:00
|
|
|
// Called after editor is created to set up editor cleanup (e.g. saving on click-away).
|
|
|
|
setupCleanup: (
|
2023-06-02 11:25:14 +00:00
|
|
|
owner: Disposable,
|
2022-03-22 13:41:11 +00:00
|
|
|
doc: GristDoc,
|
2022-08-08 13:32:50 +00:00
|
|
|
editingFormula: ko.Computed<boolean>,
|
2022-03-22 13:41:11 +00:00
|
|
|
save: () => Promise<void>
|
|
|
|
) => void,
|
2023-06-02 11:25:14 +00:00
|
|
|
}): FormulaEditor {
|
2022-08-08 13:32:50 +00:00
|
|
|
const {gristDoc, editRow, refElem, setupCleanup} = options;
|
2023-06-02 11:25:14 +00:00
|
|
|
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.
|
2022-08-08 13:32:50 +00:00
|
|
|
const column = options.column ?? options.field?.column();
|
|
|
|
|
|
|
|
if (!column) {
|
2023-06-02 11:25:14 +00:00
|
|
|
throw new Error('Column or field is required');
|
2022-08-08 13:32:50 +00:00
|
|
|
}
|
2022-03-22 13:41:11 +00:00
|
|
|
|
|
|
|
// AsyncOnce ensures it's called once even if triggered multiple times.
|
|
|
|
const saveEdit = asyncOnce(async () => {
|
2023-06-02 11:25:14 +00:00
|
|
|
const detached = editor.isDetached.get();
|
|
|
|
if (detached) {
|
|
|
|
editor.dispose();
|
|
|
|
return;
|
|
|
|
}
|
2022-10-17 09:47:16 +00:00
|
|
|
const formula = String(editor.getCellValue());
|
2022-07-25 09:49:35 +00:00
|
|
|
if (formula !== column.formula.peek()) {
|
|
|
|
if (options.onSave) {
|
2022-10-17 09:47:16 +00:00
|
|
|
await options.onSave(column, formula);
|
2022-07-25 09:49:35 +00:00
|
|
|
} else {
|
|
|
|
await column.updateColValues({formula});
|
|
|
|
}
|
2023-06-02 11:25:14 +00:00
|
|
|
editor.dispose();
|
2022-07-25 09:49:35 +00:00
|
|
|
} else {
|
2023-06-02 11:25:14 +00:00
|
|
|
editor.dispose();
|
2022-07-25 09:49:35 +00:00
|
|
|
options.onCancel?.();
|
2022-03-22 13:41:11 +00:00
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
// These are the commands for while the editor is active.
|
|
|
|
const editCommands = {
|
|
|
|
fieldEditSave: () => { saveEdit().catch(reportError); },
|
|
|
|
fieldEditSaveHere: () => { saveEdit().catch(reportError); },
|
2023-06-02 11:25:14 +00:00
|
|
|
fieldEditCancel: () => { editor.dispose(); options.onCancel?.(); },
|
2022-03-22 13:41:11 +00:00
|
|
|
};
|
|
|
|
|
2023-06-02 11:25:14 +00:00
|
|
|
const formulaError = editRow ? getFormulaError(attachedHolder, {
|
2022-03-22 13:41:11 +00:00
|
|
|
gristDoc,
|
2023-06-02 11:25:14 +00:00
|
|
|
editRow,
|
2022-08-08 13:32:50 +00:00
|
|
|
column,
|
2023-06-02 11:25:14 +00:00
|
|
|
field: options.field,
|
|
|
|
}) : undefined;
|
|
|
|
const editor = FormulaEditor.create(null, {
|
|
|
|
gristDoc,
|
|
|
|
column,
|
|
|
|
field: options.field,
|
2022-08-08 13:32:50 +00:00
|
|
|
editingFormula: options.editingFormula,
|
2022-07-06 22:36:09 +00:00
|
|
|
rowId: editRow ? editRow.id() : 0,
|
2022-03-22 13:41:11 +00:00
|
|
|
cellValue: column.formula(),
|
2023-06-02 11:25:14 +00:00
|
|
|
formulaError,
|
2022-03-22 13:41:11 +00:00
|
|
|
editValue: options.editValue,
|
|
|
|
cursorPos: Number.POSITIVE_INFINITY, // Position of the caret within the editor.
|
|
|
|
commands: editCommands,
|
|
|
|
cssClass: 'formula_editor_sidepane',
|
2023-06-02 11:25:14 +00:00
|
|
|
readonly : false,
|
|
|
|
canDetach: options.canDetach
|
|
|
|
} as IFormulaEditorOptions) as FormulaEditor;
|
|
|
|
editor.autoDispose(attachedHolder);
|
2022-03-22 13:41:11 +00:00
|
|
|
editor.attach(refElem);
|
|
|
|
|
2022-08-08 13:32:50 +00:00
|
|
|
const editingFormula = options.editingFormula ?? options?.field?.editingFormula;
|
|
|
|
|
|
|
|
if (!editingFormula) {
|
2023-01-11 17:57:42 +00:00
|
|
|
throw new Error(t('editingFormula is required'));
|
2022-08-08 13:32:50 +00:00
|
|
|
}
|
|
|
|
|
2022-03-22 13:41:11 +00:00
|
|
|
// When formula is empty enter formula-editing mode (highlight formula icons; click on a column inserts its ID).
|
|
|
|
// This function is used for primarily for switching between different column behaviors, so we want to enter full
|
|
|
|
// edit mode right away.
|
|
|
|
// TODO: consider converting it to parameter, when this will be used in different scenarios.
|
|
|
|
if (!column.formula()) {
|
2022-08-08 13:32:50 +00:00
|
|
|
editingFormula(true);
|
2022-03-22 13:41:11 +00:00
|
|
|
}
|
2023-06-02 11:25:14 +00:00
|
|
|
setupCleanup(editor, gristDoc, editingFormula, saveEdit);
|
|
|
|
return editor;
|
2022-03-22 13:41:11 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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.
|
|
|
|
*/
|
2023-06-02 11:25:14 +00:00
|
|
|
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());
|
2022-03-22 13:41:11 +00:00
|
|
|
return formulaError;
|
2023-06-02 11:25:14 +00:00
|
|
|
} 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;
|
|
|
|
});
|
2022-03-22 13:41:11 +00:00
|
|
|
}
|
2023-06-02 11:25:14 +00:00
|
|
|
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);
|
|
|
|
}
|
|
|
|
};
|
2022-03-22 13:41:11 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Create and return an observable for the count of errors in a column, which gets updated in
|
|
|
|
* response to changes in origColumn and in user data.
|
|
|
|
*/
|
|
|
|
export function createFormulaErrorObs(owner: MultiHolder, gristDoc: GristDoc, origColumn: ColumnRec) {
|
|
|
|
const errorMessage = Observable.create(owner, '');
|
|
|
|
|
|
|
|
// Count errors in origColumn when it's a formula column. Counts get cached by the
|
|
|
|
// tableData.countErrors() method, and invalidated on relevant data changes.
|
|
|
|
function countErrors() {
|
|
|
|
if (owner.isDisposed()) { return; }
|
|
|
|
const tableData = gristDoc.docData.getTable(origColumn.table.peek().tableId.peek());
|
|
|
|
const isFormula = origColumn.isRealFormula.peek() || origColumn.hasTriggerFormula.peek();
|
|
|
|
if (tableData && isFormula) {
|
|
|
|
const colId = origColumn.colId.peek();
|
|
|
|
const numCells = tableData.getColValues(colId)?.length || 0;
|
|
|
|
const numErrors = tableData.countErrors(colId) || 0;
|
|
|
|
errorMessage.set(
|
|
|
|
(numErrors === 0) ? '' :
|
2023-01-11 17:57:42 +00:00
|
|
|
(numCells === 1) ? t(`Error in the cell`) :
|
|
|
|
(numErrors === numCells) ? t(`Errors in all {{numErrors}} cells`, {numErrors}) :
|
|
|
|
t(`Errors in {{numErrors}} of {{numCells}} cells`, {numErrors, numCells})
|
2022-03-22 13:41:11 +00:00
|
|
|
);
|
|
|
|
} else {
|
|
|
|
errorMessage.set('');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Debounce the count calculation to defer it to the end of a bundle of actions.
|
|
|
|
const debouncedCountErrors = debounce(countErrors, 0);
|
|
|
|
|
|
|
|
// If there is an update to the data in the table, count errors again. Since the same UI is
|
|
|
|
// reused when different page widgets are selected, we need to re-create this subscription
|
|
|
|
// whenever the selected table changes. We use a Computed to both react to changes and dispose
|
|
|
|
// the previous subscription when it changes.
|
|
|
|
Computed.create(owner, (use) => {
|
|
|
|
const tableData = gristDoc.docData.getTable(use(use(origColumn.table).tableId));
|
|
|
|
return tableData ? use.owner.autoDispose(tableData.tableActionEmitter.addListener(debouncedCountErrors)) : null;
|
|
|
|
});
|
|
|
|
|
|
|
|
// The counts depend on the origColumn and its isRealFormula status, but with the debounced
|
|
|
|
// callback and subscription to data, subscribe to relevant changes manually (rather than using
|
|
|
|
// a Computed).
|
|
|
|
owner.autoDispose(subscribe(use => { use(origColumn.id); use(origColumn.isRealFormula); debouncedCountErrors(); }));
|
|
|
|
return errorMessage;
|
|
|
|
}
|
|
|
|
|
2024-04-26 20:34:16 +00:00
|
|
|
export function initializeAceOptions(aceObj: any) {
|
|
|
|
aceObj.setFontSize(11);
|
|
|
|
aceObj.setHighlightActiveLine(false);
|
|
|
|
aceObj.getSession().setUseWrapMode(false);
|
|
|
|
aceObj.renderer.setPadding(0);
|
|
|
|
}
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
const cssCollapseIcon = styled(icon, `
|
|
|
|
margin: -3px 4px 0 4px;
|
|
|
|
--icon-color: ${colors.slate};
|
2023-06-02 11:25:14 +00:00
|
|
|
cursor: pointer;
|
2023-07-13 14:00:56 +00:00
|
|
|
position: sticky;
|
|
|
|
top: 0px;
|
|
|
|
flex-shrink: 0;
|
2020-10-02 15:10:00 +00:00
|
|
|
`);
|
2022-03-22 13:41:11 +00:00
|
|
|
|
|
|
|
export const cssError = styled('div', `
|
2022-09-06 01:51:57 +00:00
|
|
|
color: ${theme.errorText};
|
2022-03-22 13:41:11 +00:00
|
|
|
`);
|
2023-06-02 11:25:14 +00:00
|
|
|
|
|
|
|
const cssFormulaEditor = styled('div.default_editor.formula_editor_wrapper', `
|
|
|
|
&-detached {
|
|
|
|
height: 100%;
|
|
|
|
position: relative;
|
|
|
|
box-shadow: none;
|
|
|
|
}
|
|
|
|
&-detached .formula_editor {
|
|
|
|
flex-grow: 1;
|
2023-07-13 14:00:56 +00:00
|
|
|
min-height: 100px;
|
2023-06-02 11:25:14 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
&-detached .error_msg, &-detached .error_details {
|
2023-07-13 14:00:56 +00:00
|
|
|
max-height: 100px;
|
|
|
|
flex-shrink: 0;
|
|
|
|
}
|
|
|
|
|
2023-06-02 11:25:14 +00:00
|
|
|
&-detached .code_editor_container {
|
|
|
|
height: 100%;
|
|
|
|
width: 100%;
|
|
|
|
}
|
|
|
|
|
|
|
|
&-detached .ace_editor {
|
|
|
|
height: 100% !important;
|
|
|
|
width: 100% !important;
|
|
|
|
}
|
2023-07-13 14:00:56 +00:00
|
|
|
`);
|
2023-06-02 11:25:14 +00:00
|
|
|
|
2023-07-13 14:00:56 +00:00
|
|
|
const cssFormulaPlaceholder = styled('div', `
|
|
|
|
color: ${theme.lightText};
|
|
|
|
font-style: italic;
|
|
|
|
white-space: nowrap;
|
|
|
|
`);
|
2023-06-02 11:25:14 +00:00
|
|
|
|
2023-07-13 14:00:56 +00:00
|
|
|
const cssUseAssistantButton = styled(textButton, `
|
|
|
|
font-size: ${vars.smallFontSize};
|
2023-06-02 11:25:14 +00:00
|
|
|
`);
|