import * as AceEditor from 'app/client/components/AceEditor'; 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 {HAS_FORMULA_ASSISTANT} from 'app/client/models/features'; import {hoverTooltip} from 'app/client/ui/tooltips'; import {textButton} from 'app/client/ui2018/buttons'; import {colors, testId, theme, vars} 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 {FormulaAssistant} from 'app/client/widgets/FormulaAssistant'; import {NewBaseEditor, Options} from 'app/client/widgets/NewBaseEditor'; import {asyncOnce} from 'app/common/AsyncCreate'; 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. const minFormulaErrorWidth = 400; const t = makeT('FormulaEditor'); export interface IFormulaEditorOptions extends Options { cssClass?: string; editingFormula: ko.Computed; column: ColumnRec; field?: ViewFieldRec; canDetach?: boolean; } /** * 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 { public isDetached = Observable.create(this, false); protected options: IFormulaEditorOptions; private _aceEditor: any; private _dom: HTMLElement; private _editorPlacement!: EditorPlacement; private _placementHolder = Holder.create(this); private _canDetach: boolean; private _isEmpty: Computed; constructor(options: IFormulaEditorOptions) { super(options); const editingFormula = options.editingFormula; 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); this._isEmpty = Computed.create(this, this.editorState, (_use, state) => state === ''); this._aceEditor = AceEditor.create({ // A bit awkward, but we need to assume calcSize is not used until attach() has been called // and _editorPlacement created. column: options.column, calcSize: this._calcSize.bind(this), gristDoc: options.gristDoc, saveValueOnBlurEvent: !options.readonly, editorState : this.editorState, readonly: options.readonly }); // 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. return commands.allCommands[name]?.run() ?? 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 || !use(options.formulaError)) { return null; } const error = isRaisedException(use(options.formulaError)!) ? decodeObject(use(options.formulaError)!) as RaisedException: 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 ?? ""; }); // 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.resize.bind(this), 0))); this._canDetach = Boolean(options.canDetach && !options.readonly); this.autoDispose(this._aceEditor); // Show placeholder text when the formula is blank. this._isEmpty.addListener(() => this._updateEditorPlaceholder()); // Disable undo/redo while the editor is detached. 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(); }); this._dom = cssFormulaEditor( // switch border shadow dom.cls("readonly_editor", options.readonly), createMobileButtons(options.commands), options.cssClass ? dom.cls(options.cssClass) : null, // 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 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. 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.focus(); }), !this._canDetach ? null : createDetachedIcon( hoverTooltip(t('Expand Editor')), dom.hide(this.isDetached), ), cssFormulaEditor.cls('-detached', this.isDetached), dom('div.formula_editor.formula_field_edit', testId('formula-editor'), this._aceEditor.buildDom((aceObj: any) => { aceObj.setFontSize(11); aceObj.setHighlightActiveLine(false); aceObj.getSession().setUseWrapMode(false); aceObj.renderer.setPadding(0); const val = initialValue; const pos = Math.min(options.cursorPos, val.length); this._aceEditor.setValue(val, pos); this._aceEditor.attachCommandGroup(aceCommands); // enable formula editing if state was passed if (options.state || options.readonly) { editingFormula(true); } if (options.readonly) { this._aceEditor.enable(false); 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); }); if (val === '') { // Show placeholder text if the formula is blank. this._updateEditorPlaceholder(); } }) ), 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._aceEditor.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._aceEditor.resize(); } }) )) ), 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'), ) ) ]), 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.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._aceEditor.resize, this._aceEditor)); this._aceEditor.onAttach(); this._updateEditorPlaceholder(); this._aceEditor.resize(); this.focus(); } public getDom(): HTMLElement { return this._dom; } public setFormula(formula: string) { this._aceEditor.setValue(formula); } public getCellValue() { const value = this._aceEditor.getValue(); // 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; } public getTextValue() { return this._aceEditor.getValue(); } public getCursorPos() { const aceObj = this._aceEditor.getEditor(); return aceObj.getSession().getDocument().positionToIndex(aceObj.getCursorPosition()); } public focus() { if (this.isDisposed()) { return; } this._aceEditor.getEditor().focus(); } public resize() { if (this.isDisposed()) { return; } this._aceEditor.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); this._updateEditorPlaceholder(); // Set the focus in timeout, as the dom is added after this function. setTimeout(() => !this.isDisposed() && this._aceEditor.resize(), 0); // Return the dom, it will be moved to the floating editor. return this._dom; } private _updateEditorPlaceholder() { const editor = this._aceEditor.getEditor(); const shouldShowPlaceholder = editor.session.getValue().length === 0; if (editor.renderer.emptyMessageNode) { // Remove the current placeholder if one is present. editor.renderer.scroller.removeChild(editor.renderer.emptyMessageNode); } if (!shouldShowPlaceholder) { editor.renderer.emptyMessageNode = null; } else { const withAiButton = this._canDetach && !this.isDetached.get() && HAS_FORMULA_ASSISTANT(); editor.renderer.emptyMessageNode = cssFormulaPlaceholder( !withAiButton ? 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(); } private _calcSize(elem: HTMLElement, desiredElemSize: ISize) { if (this.isDetached.get()) { // If we are detached, we will stop autosizing. return { height: 0, width: 0 }; } const placeholder: HTMLElement | undefined = this._aceEditor.getEditor().renderer.emptyMessageNode; 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, }); } 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.get() ? minFormulaErrorWidth : 0)), // Ask for extra space for the error; we'll decide how to allocate it below. height: desiredElemSize.height + (errorBoxDesiredHeight - errorBoxStartHeight), }; 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; } // TODO: update regexes to unicode? 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(); if (col.tableId.peek() !== this.options.column.table.peek().tableId.peek()) { // Fall back to default behavior if cursor didn't move to a column in the same table. this.options.gristDoc.onSetCursorPos(row, col).catch(reportError); return; } const aceObj = this._aceEditor.getEditor(); 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('$' + 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, don't mangle it } // Resize editor in case it is needed. this._aceEditor.resize(); // 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. aceObj.focus(); 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); } } // 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; } } /** * Open a formula editor. Returns a Disposable that owns the editor. */ 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, // 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, onCancel?: () => void, canDetach?: boolean, // Called after editor is created to set up editor cleanup (e.g. saving on click-away). setupCleanup: ( owner: Disposable, doc: GristDoc, editingFormula: ko.Computed, save: () => Promise ) => void, }): FormulaEditor { const {gristDoc, editRow, refElem, setupCleanup} = options; 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('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) { await options.onSave(column, formula); } else { await column.updateColValues({formula}); } editor.dispose(); } else { editor.dispose(); options.onCancel?.(); } }); // These are the commands for while the editor is active. const editCommands = { fieldEditSave: () => { saveEdit().catch(reportError); }, fieldEditSaveHere: () => { saveEdit().catch(reportError); }, fieldEditCancel: () => { editor.dispose(); options.onCancel?.(); }, }; 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, editValue: options.editValue, cursorPos: Number.POSITIVE_INFINITY, // Position of the caret within the editor. commands: editCommands, cssClass: 'formula_editor_sidepane', readonly : false, canDetach: options.canDetach } as IFormulaEditorOptions) as FormulaEditor; editor.autoDispose(attachedHolder); editor.attach(refElem); const editingFormula = options.editingFormula ?? options?.field?.editingFormula; if (!editingFormula) { throw new Error(t('editingFormula is required')); } // 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()) { editingFormula(true); } 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(owner: Disposable, options: { gristDoc: GristDoc, editRow: DataRowModel, column?: ColumnRec, field?: ViewFieldRec, }): Observable { 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 formulaError; } function errorMonitor( gristDoc: GristDoc, column: ColumnRec, editRow: DataRowModel, holder: Disposable, formulaError: Observable ) { 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); } }; } /** * 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) ? '' : (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}) ); } 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; } const cssCollapseIcon = styled(icon, ` margin: -3px 4px 0 4px; --icon-color: ${colors.slate}; cursor: pointer; position: sticky; top: 0px; flex-shrink: 0; `); 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; min-height: 100px; } &-detached .error_msg, &-detached .error_details { max-height: 100px; flex-shrink: 0; } &-detached .error_msg { cursor: default; } &-detached .code_editor_container { height: 100%; width: 100%; } &-detached .ace_editor { height: 100% !important; width: 100% !important; } `); const cssFormulaPlaceholder = styled('div', ` color: ${theme.lightText}; font-style: italic; white-space: nowrap; `); const cssUseAssistantButton = styled(textButton, ` font-size: ${vars.smallFontSize}; `);