From ea8a59c5e95078c1150262f1779fc917f8c597f7 Mon Sep 17 00:00:00 2001 From: George Gevoian Date: Thu, 13 Jul 2023 10:00:56 -0400 Subject: [PATCH] (core) Implement AI Assistant UI V2 Summary: Implements the latest design of the Formula AI Assistant. Also switches out brace to the latest build of ace. Test Plan: Browser tests. Reviewers: jarek Reviewed By: jarek Subscribers: jarek Differential Revision: https://phab.getgrist.com/D3949 --- app/client/aclui/ACLFormulaEditor.ts | 8 +- app/client/components/AceEditor.css | 10 + app/client/components/AceEditor.js | 15 +- app/client/components/AceEditorCompletions.ts | 46 +- app/client/lib/popupUtils.ts | 2 + app/client/ui/CodeHighlight.ts | 64 +- app/client/ui/DocTutorial.ts | 15 +- app/client/ui/FieldConfig.ts | 40 +- app/client/ui/FloatingPopup.ts | 85 +- app/client/ui/RightPanel.ts | 26 +- app/client/ui2018/cssVars.ts | 15 +- app/client/widgets/FieldBuilder.ts | 16 +- app/client/widgets/FieldEditor.ts | 10 +- app/client/widgets/FloatingEditor.ts | 76 +- app/client/widgets/FormulaAssistant.ts | 1034 +++++++++-------- app/client/widgets/FormulaEditor.ts | 106 +- app/client/widgets/TextEditor.css | 6 +- app/common/ThemePrefs-ti.ts | 5 +- app/common/ThemePrefs.ts | 11 +- app/common/themes/GristDark.ts | 11 +- app/common/themes/GristLight.ts | 11 +- package.json | 2 +- yarn.lock | 10 +- 23 files changed, 983 insertions(+), 641 deletions(-) diff --git a/app/client/aclui/ACLFormulaEditor.ts b/app/client/aclui/ACLFormulaEditor.ts index f9c5e799..d72ba19f 100644 --- a/app/client/aclui/ACLFormulaEditor.ts +++ b/app/client/aclui/ACLFormulaEditor.ts @@ -1,8 +1,8 @@ +import ace, {Ace} from 'ace-builds'; import {setupAceEditorCompletions} from 'app/client/components/AceEditorCompletions'; import {theme} from 'app/client/ui2018/cssVars'; import {Theme} from 'app/common/ThemePrefs'; import {getGristConfig} from 'app/common/urlUtils'; -import * as ace from 'brace'; import {Computed, dom, DomArg, Listener, Observable, styled} from 'grainjs'; import debounce from 'lodash/debounce'; @@ -13,13 +13,13 @@ export interface ACLFormulaOptions { placeholder: DomArg; setValue: (value: string) => void; getSuggestions: (prefix: string) => string[]; - customiseEditor?: (editor: ace.Editor) => void; + customiseEditor?: (editor: Ace.Editor) => void; } export function aclFormulaEditor(options: ACLFormulaOptions) { // Create an element and an editor within it. const editorElem = dom('div'); - const editor: ace.Editor = ace.edit(editorElem); + const editor: Ace.Editor = ace.edit(editorElem); // Set various editor options. function setAceTheme(gristTheme: Theme) { @@ -40,7 +40,7 @@ export function aclFormulaEditor(options: ACLFormulaOptions) { editor.renderer.setShowGutter(false); // Default line numbers to hidden editor.renderer.setPadding(5); editor.renderer.setScrollMargin(4, 4, 0, 0); - editor.$blockScrolling = Infinity; + (editor as any).$blockScrolling = Infinity; editor.setReadOnly(options.readOnly); editor.setFontSize('12'); editor.setHighlightActiveLine(false); diff --git a/app/client/components/AceEditor.css b/app/client/components/AceEditor.css index 2aa78c1b..4daec6c4 100644 --- a/app/client/components/AceEditor.css +++ b/app/client/components/AceEditor.css @@ -1,3 +1,7 @@ +.ace_editor { + background-color: var(--grist-theme-ace-editor-bg, white); +} + .ace_grist_link_hidden { display: none; } @@ -14,6 +18,7 @@ .ace_editor.ace_autocomplete .ace_completion-highlight { color: var(--grist-theme-ace-autocomplete-highlighted-fg, #000) !important; + text-shadow: 0 0 0.01em; } .ace_editor.ace_autocomplete .ace_completion-highlight.ace_grist_link { @@ -41,3 +46,8 @@ .ace_editor.ace_autocomplete .ace_marker-layer .ace_active-line { background-color: var(--grist-theme-ace-autocomplete-active-line-bg, #CAD6FA) !important; } + +.ace_autocomplete .ace_line .ace_ { + /* Ace collapses whitespace by default, which breaks alignment changes made in AceEditorCompletions.ts. */ + white-space: pre !important; +} diff --git a/app/client/components/AceEditor.js b/app/client/components/AceEditor.js index f6ee833c..afac581b 100644 --- a/app/client/components/AceEditor.js +++ b/app/client/components/AceEditor.js @@ -1,10 +1,11 @@ -var ace = require('brace'); +var ace = require('ace-builds'); var _ = require('underscore'); -// Used to load python language settings and color themes -require('brace/mode/python'); -require('brace/theme/chrome'); -require('brace/theme/dracula'); -require('brace/ext/language_tools'); +// ace-builds also has a minified build (src-min-noconflict), but we don't +// use it since webpack already handles minification. +require('ace-builds/src-noconflict/mode-python'); +require('ace-builds/src-noconflict/theme-chrome'); +require('ace-builds/src-noconflict/theme-dracula'); +require('ace-builds/src-noconflict/ext-language_tools'); var {setupAceEditorCompletions} = require('./AceEditorCompletions'); var {getGristConfig} = require('../../common/urlUtils'); var dom = require('../lib/dom'); @@ -291,7 +292,7 @@ AceEditor.prototype._setAceTheme = function(gristTheme) { let _RangeConstructor = null; //singleton, load it lazily AceEditor.makeRange = function(a, b, c, d) { - _RangeConstructor = _RangeConstructor || ace.acequire('ace/range').Range; + _RangeConstructor = _RangeConstructor || ace.require('ace/range').Range; return new _RangeConstructor(a, b, c, d); }; diff --git a/app/client/components/AceEditorCompletions.ts b/app/client/components/AceEditorCompletions.ts index 4bb3b575..1438a110 100644 --- a/app/client/components/AceEditorCompletions.ts +++ b/app/client/components/AceEditorCompletions.ts @@ -1,14 +1,14 @@ +import ace, {Ace} from 'ace-builds'; import {ISuggestionWithValue} from 'app/common/ActiveDocAPI'; import {commonUrls} from 'app/common/gristUrls'; -import * as ace from 'brace'; export interface ICompletionOptions { getSuggestions(prefix: string): Promise; } -const completionOptions = new WeakMap(); +const completionOptions = new WeakMap(); -export function setupAceEditorCompletions(editor: ace.Editor, options: ICompletionOptions) { +export function setupAceEditorCompletions(editor: Ace.Editor, options: ICompletionOptions) { initCustomCompleter(); completionOptions.set(editor, options); @@ -17,7 +17,7 @@ export function setupAceEditorCompletions(editor: ace.Editor, options: ICompleti // It is important for autoSelect to be off so that hitting enter doesn't automatically // use a suggestion, a change of behavior that doesn't seem particularly desirable and // which also breaks several existing tests. - const {Autocomplete} = ace.acequire('ace/autocomplete'); // lives in brace/ext/language_tools + const {Autocomplete} = ace.require('ace/autocomplete'); const completer = new Autocomplete(); completer.autoSelect = false; @@ -69,7 +69,7 @@ export function setupAceEditorCompletions(editor: ace.Editor, options: ICompleti // it adds to body even when it detaches itself. Ace's AutoCompleter doesn't expose any // interface for this, so this takes some hacking. (One reason for this is that Ace seems to // expect that a single AutoCompleter would be used for all editor instances.) - editor.on('destroy', () => { + editor.on('destroy' as any, () => { if (completer.editor) { completer.detach(); } @@ -91,10 +91,10 @@ function initCustomCompleter() { const prefixMatchRegex = /\w+\.(?:lookupRecords|lookupOne)\([\w.$\u00A2-\uFFFF]*$|[\w.$\u00A2-\uFFFF]+$/; // Monkey-patch getCompletionPrefix. This is based on the source code in - // node_modules/brace/ext/language_tools.js, simplified to do the one thing we want here (since - // the original method's generality doesn't help us here). - const util = ace.acequire('ace/autocomplete/util'); // lives in brace/ext/language_tools - util.getCompletionPrefix = function getCompletionPrefix(this: any, editor: ace.Editor) { + // node_modules/ace-builds/src-noconflict/ext-language_tools.js, simplified to do the one thing + // we want here (since the original method's generality doesn't help us here). + const util = ace.require('ace/autocomplete/util'); + util.getCompletionPrefix = function getCompletionPrefix(this: any, editor: Ace.Editor) { const pos = editor.getCursorPosition(); const line = editor.session.getLine(pos.row); const match = line.slice(0, pos.column).match(prefixMatchRegex); @@ -102,14 +102,14 @@ function initCustomCompleter() { }; // Add some autocompletion with partial access to document - const aceLanguageTools = ace.acequire('ace/ext/language_tools'); + const aceLanguageTools = ace.require('ace/ext/language_tools'); aceLanguageTools.setCompleters([]); aceLanguageTools.addCompleter({ // For autocompletion we ship text to the sandbox and run standard completion there. async getCompletions( - editor: ace.Editor, - session: ace.IEditSession, - pos: ace.Position, + editor: Ace.Editor, + session: Ace.EditSession, + pos: Ace.Position, prefix: string, callback: any ) { @@ -120,12 +120,13 @@ function initCustomCompleter() { // in the case where one function is being switched with another. Since we normally // append a "(" when completing such suggestions, we need to be careful not to do // so if a "(" is already present. One way to do this in ACE is to check if the - // current token is an identifier, and the next token is a lparen; if both are true, - // we skip appending a "(" to each suggestion. + // current token is a function/identifier, and the next token is a lparen; if both are + // true, we skip appending a "(" to each suggestion. const wordRange = session.getWordRange(pos.row, pos.column); - const token = session.getTokenAt(pos.row, wordRange.end.column) as TokenInfo; - const nextToken = session.getTokenAt(pos.row, wordRange.end.column + 1) as TokenInfo|null; - const isRenamingFunc = token.type === 'identifier' && nextToken?.type === 'paren.lparen'; + const token = session.getTokenAt(pos.row, wordRange.end.column) as Ace.Token; + const nextToken = session.getTokenAt(pos.row, wordRange.end.column + 1); + const isRenamingFunc = ['function.support', 'identifier'].includes(token.type) + && nextToken?.type === 'paren.lparen'; const suggestions = await options.getSuggestions(prefix); // ACE autocompletions are very poorly documented. This is somewhat helpful: @@ -209,7 +210,8 @@ interface AceSuggestion { * them to look like links, and handle clicks to open the destination URL. * * This implementation relies a lot on the details of the implementation in - * node_modules/brace/ext/language_tools.js. Updates to brace module may easily break it. + * node_modules/ace-builds/src-noconflict/ext-language_tools.js. Updates to ace-builds module may + * easily break it. */ function aceCompleterAddHelpLinks(completer: any) { // Replace the $init function in order to intercept the creation of the autocomplete popup. @@ -239,11 +241,7 @@ function customizeAceCompleterPopup(completer: any, popup: any) { }); } -interface TokenInfo extends ace.TokenInfo { - type: string; -} - -function retokenizeAceCompleterRow(rowData: AceSuggestion, tokens: TokenInfo[]): TokenInfo[] { +function retokenizeAceCompleterRow(rowData: AceSuggestion, tokens: Ace.Token[]): Ace.Token[] { if (!(rowData.funcname || rowData.example)) { // Not a special completion, pass through the result of ACE's original tokenizing. return tokens; diff --git a/app/client/lib/popupUtils.ts b/app/client/lib/popupUtils.ts index 42c32cb4..d3eb616b 100644 --- a/app/client/lib/popupUtils.ts +++ b/app/client/lib/popupUtils.ts @@ -29,6 +29,7 @@ export function documentCursor(type: 'ns-resize' | 'grabbing'): IDisposable { export function movable(options: { onMove: (dx: number, dy: number, state: T) => void, onStart: () => T, + onEnd?: () => void, }) { return (el: HTMLElement) => { // Remember the initial position of the mouse. @@ -53,6 +54,7 @@ export function movable(options: { options.onMove(dx, dy, state); })); owner.autoDispose(dom.onElem(document, 'mouseup', () => { + options.onEnd?.(); holder.clear(); })); owner.autoDispose(documentCursor('ns-resize')); diff --git a/app/client/ui/CodeHighlight.ts b/app/client/ui/CodeHighlight.ts index 4dad60ab..f80a4907 100644 --- a/app/client/ui/CodeHighlight.ts +++ b/app/client/ui/CodeHighlight.ts @@ -1,14 +1,15 @@ +import * as ace from 'ace-builds'; import {theme, vars} from 'app/client/ui2018/cssVars'; import {Theme} from 'app/common/ThemePrefs'; import {getGristConfig} from 'app/common/urlUtils'; -import * as ace from 'brace'; import {BindableValue, Computed, dom, DomElementArg, Observable, styled, subscribeElem} from 'grainjs'; -// tslint:disable:no-var-requires -require('brace/ext/static_highlight'); -require("brace/mode/python"); -require("brace/theme/chrome"); -require('brace/theme/dracula'); +// ace-builds also has a minified build (src-min-noconflict), but we don't +// use it since webpack already handles minification. +require('ace-builds/src-noconflict/ext-static_highlight'); +require('ace-builds/src-noconflict/mode-python'); +require('ace-builds/src-noconflict/theme-chrome'); +require('ace-builds/src-noconflict/theme-dracula'); export interface ICodeOptions { gristTheme: Computed; @@ -22,10 +23,11 @@ export function buildHighlightedCode( const {gristTheme, placeholder, maxLines} = options; const {enableCustomCss} = getGristConfig(); - const highlighter = ace.acequire('ace/ext/static_highlight'); - const PythonMode = ace.acequire('ace/mode/python').Mode; - const chrome = ace.acequire('ace/theme/chrome'); - const dracula = ace.acequire('ace/theme/dracula'); + const highlighter = ace.require('ace/ext/static_highlight'); + const PythonMode = ace.require('ace/mode/python').Mode; + const aceDom = ace.require('ace/lib/dom'); + const chrome = ace.require('ace/theme/chrome'); + const dracula = ace.require('ace/theme/dracula'); const mode = new PythonMode(); const codeText = Observable.create(null, ''); @@ -33,21 +35,37 @@ export function buildHighlightedCode( function updateHighlightedCode(elem: HTMLElement) { let text = codeText.get(); - if (text) { - if (maxLines) { - // If requested, trim to maxLines, and add an ellipsis at the end. - // (Long lines are also truncated with an ellpsis via text-overflow style.) - const lines = text.split(/\n/); - if (lines.length > maxLines) { - text = lines.slice(0, maxLines).join("\n") + " \u2026"; // Ellipsis - } - } - - const aceTheme = codeTheme.get().appearance === 'dark' && !enableCustomCss ? dracula : chrome; - elem.innerHTML = highlighter.render(text, mode, aceTheme, 1, true).html; - } else { + if (!text) { elem.textContent = placeholder || ''; + return; } + + if (maxLines) { + // If requested, trim to maxLines, and add an ellipsis at the end. + // (Long lines are also truncated with an ellpsis via text-overflow style.) + const lines = text.split(/\n/); + if (lines.length > maxLines) { + text = lines.slice(0, maxLines).join("\n") + " \u2026"; // Ellipsis + } + } + + let aceThemeName: 'chrome' | 'dracula'; + let aceTheme: any; + if (codeTheme.get().appearance === 'dark' && !enableCustomCss) { + aceThemeName = 'dracula'; + aceTheme = dracula; + } else { + aceThemeName = 'chrome'; + aceTheme = chrome; + } + + // Rendering highlighted code gives you back the HTML to insert into the DOM, as well + // as the CSS styles needed to apply the theme. The latter typically isn't included in + // the document until an Ace editor is opened, so we explicitly import it here to avoid + // leaving highlighted code blocks without a theme applied. + const {html, css} = highlighter.render(text, mode, aceTheme, 1, true); + elem.innerHTML = html; + aceDom.importCssString(css, `${aceThemeName}-highlighted-code`); } return cssHighlightedCode( diff --git a/app/client/ui/DocTutorial.ts b/app/client/ui/DocTutorial.ts index 67905417..15b7368e 100644 --- a/app/client/ui/DocTutorial.ts +++ b/app/client/ui/DocTutorial.ts @@ -2,7 +2,7 @@ import {GristDoc} from 'app/client/components/GristDoc'; import {logTelemetryEvent} from 'app/client/lib/telemetry'; import {getWelcomeHomeUrl, urlState} from 'app/client/models/gristUrlState'; import {renderer} from 'app/client/ui/DocTutorialRenderer'; -import {cssPopupBody, FloatingPopup} from 'app/client/ui/FloatingPopup'; +import {cssPopupBody, FLOATING_POPUP_TOOLTIP_KEY, FloatingPopup} from 'app/client/ui/FloatingPopup'; import {sanitizeHTML} from 'app/client/ui/sanitizeHTML'; import {hoverTooltip, setHoverTooltip} from 'app/client/ui/tooltips'; import {basicButton, primaryButton} from 'app/client/ui2018/buttons'; @@ -26,8 +26,6 @@ interface DocTutorialSlide { const testId = makeTestId('test-doc-tutorial-'); -const TOOLTIP_KEY = 'docTutorialTooltip'; - export class DocTutorial extends FloatingPopup { private _appModel = this._gristDoc.docPageModel.appModel; private _currentDoc = this._gristDoc.docPageModel.currentDoc.get(); @@ -47,7 +45,10 @@ export class DocTutorial extends FloatingPopup { }); constructor(private _gristDoc: GristDoc) { - super({stopClickPropagationOnMove: true}); + super({ + minimizable: true, + stopClickPropagationOnMove: true, + }); } public async start() { @@ -102,7 +103,7 @@ export class DocTutorial extends FloatingPopup { return [ cssFooterButtonsLeft( cssPopupFooterButton(icon('Undo'), - hoverTooltip('Restart Tutorial', {key: TOOLTIP_KEY}), + hoverTooltip('Restart Tutorial', {key: FLOATING_POPUP_TOOLTIP_KEY}), dom.on('click', () => this._restartTutorial()), testId('popup-restart'), ), @@ -111,7 +112,7 @@ export class DocTutorial extends FloatingPopup { range(slides.length).map((i) => cssProgressBarDot( hoverTooltip(slides[i].slideTitle, { closeOnClick: false, - key: TOOLTIP_KEY, + key: FLOATING_POPUP_TOOLTIP_KEY, }), cssProgressBarDot.cls('-current', i === slideIndex), i === slideIndex ? null : dom.on('click', () => this._changeSlide(i)), @@ -315,7 +316,7 @@ export class DocTutorial extends FloatingPopup { img.src = img.src; setHoverTooltip(img, 'Click to expand', { - key: TOOLTIP_KEY, + key: FLOATING_POPUP_TOOLTIP_KEY, modifiers: { flip: { boundariesElement: 'scrollParent', diff --git a/app/client/ui/FieldConfig.ts b/app/client/ui/FieldConfig.ts index 680a05e2..b100807a 100644 --- a/app/client/ui/FieldConfig.ts +++ b/app/client/ui/FieldConfig.ts @@ -89,12 +89,22 @@ export function buildNameConfig( ]; } +export interface BuildEditorOptions { + // Element to attach to. + refElem: Element; + // Should the detach button be shown? + canDetach: boolean; + // Simulate user typing on the cell - open editor with an initial value. + editValue?: string; + // Custom save handler. + onSave?: SaveHandler; + // Custom cancel handler. + onCancel?: () => void; +} + type SaveHandler = (column: ColumnRec, formula: string) => Promise; -type BuildEditor = ( - cellElem: Element, - editValue?: string, - onSave?: SaveHandler, - onCancel?: () => void) => void; + +type BuildEditor = (options: BuildEditorOptions) => void; export function buildFormulaConfig( owner: MultiHolder, origColumn: ColumnRec, gristDoc: GristDoc, buildEditor: BuildEditor @@ -315,14 +325,14 @@ export function buildFormulaConfig( const errorMessage = createFormulaErrorObs(owner, gristDoc, origColumn); // Helper that will create different flavors for formula builder. - const formulaBuilder = (onSave: SaveHandler) => [ + const formulaBuilder = (onSave: SaveHandler, canDetach?: boolean) => [ cssRow(formulaField = buildFormula( origColumn, buildEditor, { gristTheme: gristDoc.currentTheme, - placeholder: t("Enter formula"), disabled: disableOtherActions, + canDetach, onSave, onCancel: clearState, })), @@ -386,7 +396,7 @@ export function buildFormulaConfig( // If data column is or wants to be a trigger formula: dom.maybe((use) => use(maybeTrigger) || use(origColumn.hasTriggerFormula), () => [ cssLabel(t("TRIGGER FORMULA")), - formulaBuilder(onSaveConvertToTrigger), + formulaBuilder(onSaveConvertToTrigger, false), dom.create(buildFormulaTriggers, origColumn, { disabled: disableOtherActions, notTrigger: maybeTrigger, @@ -411,8 +421,8 @@ export function buildFormulaConfig( interface BuildFormulaOptions { gristTheme: Computed; - placeholder: string; disabled: Observable; + canDetach?: boolean; onSave?: SaveHandler; onCancel?: () => void; } @@ -422,8 +432,8 @@ function buildFormula( buildEditor: BuildEditor, options: BuildFormulaOptions ) { - const {gristTheme, placeholder, disabled, onSave, onCancel} = options; - return cssFieldFormula(column.formula, {gristTheme, placeholder, maxLines: 2}, + const {gristTheme, disabled, canDetach = true, onSave, onCancel} = options; + return cssFieldFormula(column.formula, {gristTheme, maxLines: 2}, dom.cls('formula_field_sidepane'), cssFieldFormula.cls('-disabled', disabled), cssFieldFormula.cls('-disabled-icon', use => !use(column.formula)), @@ -431,7 +441,13 @@ function buildFormula( {tabIndex: '-1'}, // Focus event use used by a user to edit an existing formula. // It can also be triggered manually to open up the editor. - dom.on('focus', (_, elem) => buildEditor(elem, undefined, onSave, onCancel)), + dom.on('focus', (_, refElem) => buildEditor({ + refElem, + editValue: undefined, + canDetach, + onSave, + onCancel, + })), ); } diff --git a/app/client/ui/FloatingPopup.ts b/app/client/ui/FloatingPopup.ts index 6c011013..b593b1b7 100644 --- a/app/client/ui/FloatingPopup.ts +++ b/app/client/ui/FloatingPopup.ts @@ -1,31 +1,45 @@ +import {makeT} from 'app/client/lib/localization'; import {documentCursor} from 'app/client/lib/popupUtils'; import {hoverTooltip} from 'app/client/ui/tooltips'; import {isNarrowScreen, isNarrowScreenObs, theme, vars} from 'app/client/ui2018/cssVars'; +import {IconName} from 'app/client/ui2018/IconList'; import {icon} from 'app/client/ui2018/icons'; import {Disposable, dom, DomContents, DomElementArg, IDisposable, makeTestId, Observable, styled} from 'grainjs'; const POPUP_INITIAL_PADDING_PX = 16; -const POPUP_MIN_HEIGHT = 300; +const POPUP_DEFAULT_MIN_HEIGHT = 300; const POPUP_MAX_HEIGHT = 711; const POPUP_HEADER_HEIGHT = 30; +const t = makeT('FloatingPopup'); + const testId = makeTestId('test-floating-popup-'); +export const FLOATING_POPUP_TOOLTIP_KEY = 'floatingPopupTooltip'; + export interface PopupOptions { title?: () => DomContents; content?: () => DomContents; onClose?: () => void; closeButton?: boolean; + closeButtonIcon?: IconName; closeButtonHover?: () => DomContents; + minimizable?: boolean; autoHeight?: boolean; + /** Minimum height in pixels. */ + minHeight?: number; /** Defaults to false. */ stopClickPropagationOnMove?: boolean; + initialPosition?: [left: number, top: number]; args?: DomElementArg[]; } export class FloatingPopup extends Disposable { protected _isMinimized = Observable.create(this, false); + private _closable = this._options.closeButton ?? false; + private _minimizable = this._options.minimizable ?? false; + private _minHeight = this._options.minHeight ?? POPUP_DEFAULT_MIN_HEIGHT; private _isFinishingMove = false; private _popupElement: HTMLElement | null = null; private _popupMinimizeButtonElement: HTMLElement | null = null; @@ -71,7 +85,7 @@ export class FloatingPopup extends Disposable { this.autoDispose(isNarrowScreenObs().addListener(() => this._repositionPopup())); this.onDispose(() => { - this._closePopup(); + this._disposePopup(); this._cursorGrab?.dispose(); }); } @@ -79,18 +93,22 @@ export class FloatingPopup extends Disposable { public showPopup() { this._popupElement = this._buildPopup(); document.body.appendChild(this._popupElement); - const topPaddingPx = getTopPopupPaddingPx(); - const initialLeft = document.body.offsetWidth - this._popupElement.offsetWidth - POPUP_INITIAL_PADDING_PX; - const initialTop = document.body.offsetHeight - this._popupElement.offsetHeight - topPaddingPx; - this._popupElement.style.left = `${initialLeft}px`; - this._popupElement.style.top = `${initialTop}px`; + + const {initialPosition} = this._options; + if (initialPosition) { + this._setPosition(initialPosition); + this._repositionPopup(); + } else { + const left = document.body.offsetWidth - this._popupElement.offsetWidth - POPUP_INITIAL_PADDING_PX; + const top = document.body.offsetHeight - this._popupElement.offsetHeight - getTopPopupPaddingPx(); + this._setPosition([left, top]); + } } protected _closePopup() { - if (!this._popupElement) { return; } - document.body.removeChild(this._popupElement); - dom.domDispose(this._popupElement); - this._popupElement = null; + if (!this._closable) { return; } + + this._disposePopup(); } protected _buildTitle(): DomContents { @@ -105,6 +123,21 @@ export class FloatingPopup extends Disposable { return this._options.args ?? []; } + private _disposePopup() { + if (!this._popupElement) { return; } + + document.body.removeChild(this._popupElement); + dom.domDispose(this._popupElement); + this._popupElement = null; + } + + private _setPosition([left, top]: [left: number, top: number]) { + if (!this._popupElement) { return; } + + this._popupElement.style.left = `${left}px`; + this._popupElement.style.top = `${top}px`; + } + private _rememberPosition() { this._initialLeft = this._popupElement!.offsetLeft; this._initialTop = this._popupElement!.offsetTop; @@ -151,7 +184,7 @@ export class FloatingPopup extends Disposable { // First just how much we can resize the popup. let minTop = this._initialBottom - POPUP_MAX_HEIGHT; - let maxTop = this._initialBottom - POPUP_MIN_HEIGHT; + let maxTop = this._initialBottom - this._minHeight; // Now how far we can move top (leave at least some padding for mobile). minTop = Math.max(minTop, getTopPopupPaddingPx()); @@ -250,6 +283,8 @@ export class FloatingPopup extends Disposable { } private _minimizeOrMaximize() { + if (!this._minimizable) { return; } + this._isMinimized.set(!this._isMinimized.get()); this._repositionPopup(); } @@ -258,6 +293,7 @@ export class FloatingPopup extends Disposable { const body = cssPopup( {tabIndex: '-1'}, cssPopup.cls('-auto', this._options.autoHeight ?? false), + dom.style('min-height', `${this._minHeight}px`), cssPopupHeader( cssBottomHandle(testId('move-handle')), dom.maybe(use => !use(this._isMinimized), () => { @@ -277,10 +313,12 @@ export class FloatingPopup extends Disposable { // center the title. cssPopupButtons( cssPopupHeaderButton( - icon('Maximize') + icon('Maximize'), + dom.show(this._minimizable), ), - !this._options.closeButton ? null : cssPopupHeaderButton( + cssPopupHeaderButton( icon('CrossBig'), + dom.show(this._closable), ), dom.style('visibility', 'hidden'), ), @@ -291,17 +329,23 @@ export class FloatingPopup extends Disposable { cssPopupButtons( this._popupMinimizeButtonElement = cssPopupHeaderButton( isMinimized ? icon('Maximize'): icon('Minimize'), - hoverTooltip(isMinimized ? 'Maximize' : 'Minimize', {key: 'docTutorialTooltip'}), + hoverTooltip(isMinimized ? t('Maximize') : t('Minimize'), { + key: FLOATING_POPUP_TOOLTIP_KEY, + }), dom.on('click', () => this._minimizeOrMaximize()), + dom.show(this._minimizable), testId('minimize-maximize'), ), - !this._options.closeButton ? null : cssPopupHeaderButton( - icon('CrossBig'), + cssPopupHeaderButton( + icon(this._options.closeButtonIcon ?? 'CrossBig'), + this._options.closeButtonHover && hoverTooltip(this._options.closeButtonHover(), { + key: FLOATING_POPUP_TOOLTIP_KEY, + }), dom.on('click', () => { this._options.onClose?.() ?? this._closePopup(); }), + dom.show(this._closable), testId('close'), - this._options.closeButtonHover && hoverTooltip(this._options.closeButtonHover()) ), // Disable dragging when a button in the header is clicked. dom.on('mousedown', ev => ev.stopPropagation()), @@ -362,7 +406,9 @@ function getTopPopupPaddingPx(): number { const POPUP_HEIGHT = `min(var(--height), calc(100% - (2 * ${POPUP_INITIAL_PADDING_PX}px)))`; const POPUP_HEIGHT_MOBILE = `min(var(--height), calc(100% - (2 * ${POPUP_INITIAL_PADDING_PX}px) - (2 * 50px)))`; -const POPUP_WIDTH = `min(436px, calc(100% - (2 * ${POPUP_INITIAL_PADDING_PX}px)))`; + +export const FLOATING_POPUP_MAX_WIDTH_PX = 436; +const POPUP_WIDTH = `min(${FLOATING_POPUP_MAX_WIDTH_PX}px, calc(100% - (2 * ${POPUP_INITIAL_PADDING_PX}px)))`; const cssPopup = styled('div.floating-popup', ` position: fixed; @@ -374,7 +420,6 @@ const cssPopup = styled('div.floating-popup', ` --height: ${POPUP_MAX_HEIGHT}px; height: ${POPUP_HEIGHT}; width: ${POPUP_WIDTH}; - min-height: ${POPUP_MIN_HEIGHT}px; background-color: ${theme.popupBg}; box-shadow: 0 2px 18px 0 ${theme.popupInnerShadow}, 0 0 1px 0 ${theme.popupOuterShadow}; outline: unset; diff --git a/app/client/ui/RightPanel.ts b/app/client/ui/RightPanel.ts index 48695f2d..2cbbc2f7 100644 --- a/app/client/ui/RightPanel.ts +++ b/app/client/ui/RightPanel.ts @@ -23,12 +23,13 @@ import * as imports from 'app/client/lib/imports'; import {makeT} from 'app/client/lib/localization'; import {createSessionObs} from 'app/client/lib/sessionObs'; import {reportError} from 'app/client/models/AppModel'; -import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel'; +import {ViewSectionRec} from 'app/client/models/DocModel'; import {GridOptions} from 'app/client/ui/GridOptions'; import {attachPageWidgetPicker, IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker'; import {linkId, selectBy} from 'app/client/ui/selectBy'; import {CustomSectionConfig} from 'app/client/ui/CustomSectionConfig'; import {buildDescriptionConfig} from 'app/client/ui/DescriptionConfig'; +import {BuildEditorOptions} from 'app/client/ui/FieldConfig'; import {cssLabel} from 'app/client/ui/RightPanelStyles'; import {VisibleFieldsConfig} from 'app/client/ui/VisibleFieldsConfig'; import {IWidgetType, widgetTypes} from 'app/client/ui/widgetTypes'; @@ -295,19 +296,20 @@ export class RightPanel extends Disposable { } // Helper to activate the side-pane formula editor over the given HTML element. - private _activateFormulaEditor( - // Element to attach to. - refElem: Element, - // Simulate user typing on the cell - open editor with an initial value. - editValue?: string, - // Custom save handler. - onSave?: (column: ColumnRec, formula: string) => Promise, - // Custom cancel handler. - onCancel?: () => void) { + private _activateFormulaEditor(options: BuildEditorOptions) { const vsi = this._gristDoc.viewModel.activeSection().viewInstance(); if (!vsi) { return; } - const editRowModel = vsi.moveEditRowToCursor(); - return vsi.activeFieldBuilder.peek().openSideFormulaEditor(editRowModel, refElem, editValue, onSave, onCancel); + + const {refElem, editValue, canDetach, onSave, onCancel} = options; + const editRow = vsi.moveEditRowToCursor(); + return vsi.activeFieldBuilder.peek().openSideFormulaEditor({ + editRow, + refElem, + canDetach, + editValue, + onSave, + onCancel, + }); } private _buildPageWidgetContent(_owner: MultiHolder) { diff --git a/app/client/ui2018/cssVars.ts b/app/client/ui2018/cssVars.ts index fd9ef496..a23c6770 100644 --- a/app/client/ui2018/cssVars.ts +++ b/app/client/ui2018/cssVars.ts @@ -143,6 +143,7 @@ export const vars = { floatingPopupZIndex: new CustomProp('floating-popup-z-index', '1002'), tutorialModalZIndex: new CustomProp('tutorial-modal-z-index', '1003'), pricingModalZIndex: new CustomProp('pricing-modal-z-index', '1004'), + floatingPopupMenuZIndex: new CustomProp('floating-popup-menu-z-index', '1004'), notificationZIndex: new CustomProp('notification-z-index', '1100'), browserCheckZIndex: new CustomProp('browser-check-z-index', '5000'), tooltipZIndex: new CustomProp('tooltip-z-index', '5000'), @@ -686,9 +687,6 @@ export const theme = { cellBg: new CustomProp('theme-cell-bg', undefined, '#FFFFFF00'), cellZebraBg: new CustomProp('theme-cell-zebra-bg', undefined, '#F8F8F8'), - /* Formula Editor */ - formulaEditorBg: new CustomProp('theme-formula-editor-bg', undefined, 'white'), - /* Charts */ chartFg: new CustomProp('theme-chart-fg', undefined, '#444'), chartBg: new CustomProp('theme-chart-bg', undefined, '#fff'), @@ -736,7 +734,8 @@ export const theme = { colors.lightGreen), tutorialsPopupBoxBg: new CustomProp('theme-tutorials-popup-box-bg', undefined, '#F5F5F5'), - /* Ace Autocomplete */ + /* Ace */ + aceEditorBg: new CustomProp('theme-ace-editor-bg', undefined, 'white'), aceAutocompletePrimaryFg: new CustomProp('theme-ace-autocomplete-primary-fg', undefined, '#444'), aceAutocompleteSecondaryFg: new CustomProp('theme-ace-autocomplete-secondary-fg', undefined, '#8f8f8f'), @@ -797,6 +796,14 @@ export const theme = { loginPageBg: new CustomProp('theme-login-page-bg', undefined, 'white'), loginPageBackdrop: new CustomProp('theme-login-page-backdrop', undefined, '#F5F8FA'), loginPageLine: new CustomProp('theme-login-page-line', undefined, colors.lightGrey), + + /* Formula Assistant */ + formulaAssistantHeaderBg: new CustomProp( + 'theme-formula-assistant-header-bg', undefined, colors.lightGrey), + formulaAssistantBorder: new CustomProp( + 'theme-formula-assistant-border', undefined, colors.darkGrey), + formulaAssistantPreformattedTextBg: new CustomProp( + 'theme-formula-assistant-preformatted-text-bg', undefined, colors.lightGrey), }; const cssColors = values(colors).map(v => v.decl()).join('\n'); diff --git a/app/client/widgets/FieldBuilder.ts b/app/client/widgets/FieldBuilder.ts index 657bedd0..0b458d94 100644 --- a/app/client/widgets/FieldBuilder.ts +++ b/app/client/widgets/FieldBuilder.ts @@ -775,12 +775,16 @@ export class FieldBuilder extends Disposable { /** * Open the formula editor in the side pane. It will be positioned over refElem. */ - public openSideFormulaEditor( + public openSideFormulaEditor(options: { editRow: DataRowModel, refElem: Element, + canDetach: boolean, editValue?: string, onSave?: (column: ColumnRec, formula: string) => Promise, - onCancel?: () => void) { + onCancel?: () => void + }) { + const {editRow, refElem, canDetach, editValue, onSave, onCancel} = options; + // Remember position when the popup was opened. const position = this.gristDoc.cursorPosition.get(); @@ -838,14 +842,18 @@ export class FieldBuilder extends Disposable { editRow, refElem, editValue, - canDetach: true, + canDetach, 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); + const floatingExtension = FloatingEditor.create(formulaEditor, floatController, { + gristDoc: this.gristDoc, + refElem, + placement: 'overlapping', + }); // Add editor to document holder - this will prevent multiple formula editor instances. this.gristDoc.fieldEditorHolder.autoDispose(formulaEditor); diff --git a/app/client/widgets/FieldEditor.ts b/app/client/widgets/FieldEditor.ts index 32d28902..973fb16f 100644 --- a/app/client/widgets/FieldEditor.ts +++ b/app/client/widgets/FieldEditor.ts @@ -159,7 +159,11 @@ 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); + this.floatingEditor = FloatingEditor.create(this, this, { + gristDoc: this._gristDoc, + refElem: this._cellElem, + placement: 'adjacent', + }); if (offerToMakeFormula) { this._offerToMakeFormula(); @@ -318,6 +322,10 @@ export class FieldEditor extends Disposable { private _unmakeFormula() { const editor = this._editorHolder.get(); + if (editor instanceof FormulaEditor && editor.isDetached.get()) { + return true; + } + // Only convert to data if we are undoing a to-formula conversion. To convert formula to // data, use column menu option, or delete the formula first (which makes the column "empty"). if (editor && this._field.editingFormula.peek() && editor.getCursorPos() === 0 && diff --git a/app/client/widgets/FloatingEditor.ts b/app/client/widgets/FloatingEditor.ts index 96a99a3a..079d2865 100644 --- a/app/client/widgets/FloatingEditor.ts +++ b/app/client/widgets/FloatingEditor.ts @@ -2,24 +2,57 @@ 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 {makeT} from 'app/client/lib/localization'; +import {FLOATING_POPUP_MAX_WIDTH_PX, 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'; +const t = makeT('FloatingEditor'); + +const testId = makeTestId('test-floating-editor-'); + export interface IFloatingOwner extends IDisposableOwner { detach(): HTMLElement; attach(content: HTMLElement): Promise|void; } -const testId = makeTestId('test-floating-editor-'); +export interface FloatingEditorOptions { + gristDoc: GristDoc; + /** + * The element that `placement` should be relative to. + */ + refElem?: Element; + /** + * How to position the editor. + * + * If "overlapping", the editor will be positioned on top of `refElem`, anchored + * to its top-left corner. + * + * If "adjacent", the editor will be positioned to the left or right of `refElem`, + * depending on available space. + * + * If "fixed", the editor will be positioned in the bottom-right corner of the + * viewport. + * + * Defaults to "fixed". + */ + placement?: 'overlapping' | 'adjacent' | 'fixed'; +} export class FloatingEditor extends Disposable { public active = Observable.create(this, false); - constructor(private _fieldEditor: IFloatingOwner, private _gristDoc: GristDoc) { + private _gristDoc = this._options.gristDoc; + private _placement = this._options.placement ?? 'fixed'; + private _refElem = this._options.refElem; + + constructor( + private _fieldEditor: IFloatingOwner, + private _options: FloatingEditorOptions + ) { super(); this.autoDispose(commands.createGroup({ detachEditor: this.createPopup.bind(this), @@ -52,7 +85,8 @@ export class FloatingEditor extends Disposable { // detach it on close. title: () => title, // We are not reactive yet closeButton: true, // Show the close button with a hover - closeButtonHover: () => 'Return to cell', + closeButtonIcon: 'Minimize', + closeButtonHover: () => t('Collapse Editor'), onClose: async () => { const layer = FocusLayer.create(null, { defaultFocusElem: document.activeElement as any}); try { @@ -63,6 +97,8 @@ export class FloatingEditor extends Disposable { layer.dispose(); } }, + minHeight: 550, + initialPosition: this._getInitialPosition(), args: [testId('popup')] }); // Set a public flag that we are active. @@ -78,6 +114,38 @@ export class FloatingEditor extends Disposable { tempOwner.dispose(); } } + + private _getInitialPosition(): [number, number] | undefined { + if (!this._refElem || this._placement === 'fixed') { + return undefined; + } + + const refElem = this._refElem as HTMLElement; + const refElemBoundingRect = refElem.getBoundingClientRect(); + if (this._placement === 'overlapping') { + // Anchor the floating editor to the top-left corner of the refElement. + return [ + refElemBoundingRect.left, + refElemBoundingRect.top, + ]; + } else { + if (window.innerWidth - refElemBoundingRect.right >= FLOATING_POPUP_MAX_WIDTH_PX) { + // If there's enough space to the right of refElement, position the + // floating editor there. + return [ + refElemBoundingRect.right, + refElemBoundingRect.top, + ]; + } else { + // Otherwise position it to the left of refElement; note that it may still + // overlap if there isn't enough space on this side either. + return [ + refElemBoundingRect.left - FLOATING_POPUP_MAX_WIDTH_PX, + refElemBoundingRect.top, + ]; + } + } + } } export function createDetachedIcon(...args: IDomArgs) { diff --git a/app/client/widgets/FormulaAssistant.ts b/app/client/widgets/FormulaAssistant.ts index 3b3e22d6..644f0394 100644 --- a/app/client/widgets/FormulaAssistant.ts +++ b/app/client/widgets/FormulaAssistant.ts @@ -1,29 +1,29 @@ import * as commands from 'app/client/components/commands'; import {GristDoc} from 'app/client/components/GristDoc'; import {makeT} from 'app/client/lib/localization'; +import {localStorageBoolObs} from 'app/client/lib/localStorageObs'; import {ColumnRec, ViewFieldRec} from 'app/client/models/DocModel'; import {ChatMessage} from 'app/client/models/entities/ColumnRec'; import {GRIST_FORMULA_ASSISTANT} from 'app/client/models/features'; +import {getLoginOrSignupUrl, urlState} from 'app/client/models/gristUrlState'; import {buildHighlightedCode} from 'app/client/ui/CodeHighlight'; import {sanitizeHTML} from 'app/client/ui/sanitizeHTML'; import {createUserImage} from 'app/client/ui/UserImage'; -import {loadingDots} from 'app/client/ui2018/loaders'; import {FormulaEditor} from 'app/client/widgets/FormulaEditor'; import {AssistanceResponse, AssistanceState} from 'app/common/AssistancePrompts'; -import {commonUrls} from 'app/common/gristUrls'; -import {basicButton, primaryButton, textButton} from 'app/client/ui2018/buttons'; -import {theme} from 'app/client/ui2018/cssVars'; +import {basicButton, bigPrimaryButtonLink, primaryButton} from 'app/client/ui2018/buttons'; +import {theme, vars} from 'app/client/ui2018/cssVars'; import {autoGrow} from 'app/client/ui/forms'; -import {IconName} from 'app/client/ui2018/IconList'; import {icon} from 'app/client/ui2018/icons'; import {cssLink} from 'app/client/ui2018/links'; +import {commonUrls} from 'app/common/gristUrls'; import {movable} from 'app/client/lib/popupUtils'; - -import debounce from 'lodash/debounce'; -import {Computed, Disposable, dom, DomContents, DomElementArg, keyframes, - makeTestId, +import {loadingDots} from 'app/client/ui2018/loaders'; +import {menu, menuCssClass, menuItem} from "app/client/ui2018/menus"; +import {Computed, Disposable, dom, DomElementArg, makeTestId, MutableObsArray, obsArray, Observable, styled} from 'grainjs'; - import noop from 'lodash/noop'; +import debounce from 'lodash/debounce'; +import noop from 'lodash/noop'; import {marked} from 'marked'; const t = makeT('FormulaEditor'); @@ -34,29 +34,21 @@ const testId = makeTestId('test-formula-editor-'); * It renders itself in the detached FormulaEditor and adds some extra UI elements. * - Save button: a subscription for the Enter key that saves the formula and closes the assistant. * - Preview button: a new functionality that allows to preview the formula in a temporary column. - * - Two info cards: that describes what this is and how to use it. + * - Cancel button: a subscription for the Escape key that discards all changes and closes the assistant. * - A chat component: that allows to communicate with the assistant. */ export class FormulaAssistant extends Disposable { + private _gristDoc = this._options.gristDoc; /** Chat component */ private _chat: ChatHistory; /** State of the user input */ private _userInput = Observable.create(this, ''); - /** Is formula description card dismissed */ - private _isFormulaInfoClosed: Observable; - /** Is Ai card dismissed */ - private _isAssistantInfoClosed: Observable; - /** Are any cards dismissed */ - private _cardsVisible: Observable; /** Dom element that holds the user input */ // TODO: move it to a separate component private _input: HTMLTextAreaElement; - /** Do we need to show an intro, we show it when history is empty */ - private _introVisible: Observable; - /** Do we need to show a robot icon, we show it when history is empty and assistant is disabled */ - private _robotIconVisible: Observable; - /** Is chat active, we show it when history is not empty */ - private _chatActive = Observable.create(this, false); + /** Is the formula assistant expanded */ + private _assistantExpanded = this.autoDispose(localStorageBoolObs( + `u:${this._options.gristDoc.appModel.currentUser?.id ?? 0};formulaAssistantExpanded`, true)); /** Is the request pending */ private _waiting = Observable.create(this, false); /** Is this feature enabled at all */ @@ -71,6 +63,19 @@ export class FormulaAssistant extends Disposable { private _domElement: HTMLElement; // Input wrapper element (used for resizing). private _inputWrapper: HTMLElement; + /** Chat panel body element. */ + private _chatPanelBody: HTMLElement; + /** Client height of the chat panel body element. */ + private _chatPanelBodyClientHeight = Observable.create(this, 0); + /** + * Last known height of the chat panel. + * + * This is like `_chatPanelBodyClientHeight`, but updated only for the purposes of + * being able to collapse and expand the panel to a known height. + */ + private _lastChatPanelHeight: number|undefined; + /** True if the chat panel is being resized via dragging. */ + private _isResizing = Observable.create(this, false); /** * Debounced version of the method that will force parent editor to resize, we call it often * as we have an ability to resize the chat window. @@ -90,9 +95,7 @@ export class FormulaAssistant extends Disposable { super(); this._assistantEnabled = Computed.create(this, use => { - const enabledByFlag = use(GRIST_FORMULA_ASSISTANT()); - const notAnonymous = Boolean(this._options.gristDoc.appModel.currentValidUser); - return enabledByFlag && notAnonymous; + return use(GRIST_FORMULA_ASSISTANT()); }); if (!this._options.field) { @@ -104,65 +107,18 @@ export class FormulaAssistant extends Disposable { this._chat = ChatHistory.create(this, { ...this._options, - copyClicked: this._copyClicked.bind(this), + apply: this._apply.bind(this), }); - const hasHistory = Computed.create(this, use => use(this._chat.length) > 0); - if (hasHistory.get()) { - this._chatActive.set(true); - } - this.autoDispose(commands.createGroup({ activateAssistant: () => { - this._robotIconClicked(); + this._expandChatPanel(); setTimeout(() => { - this._input.focus(); + this._focusChatInput(); }, 0); } }, this, true)); - // Calculate some flags what to show when. - this._isFormulaInfoClosed = this.autoDispose(_options.gristDoc.appModel.dismissedPopup('formulaHelpInfo')); - this._isAssistantInfoClosed = this.autoDispose(_options.gristDoc.appModel.dismissedPopup('formulaAssistantInfo')); - this._cardsVisible = Computed.create(this, use => { - const seenInfo = use(this._isFormulaInfoClosed); - const seenAi = use(this._isAssistantInfoClosed); - const aiEnable = use(this._assistantEnabled); - const nothingToShow = seenInfo && (seenAi || !aiEnable); - if (nothingToShow) { - return false; - } - if (use(hasHistory)) { - return false; - } - if (use(this._chatActive)) { - return false; - } - return true; - }); - this._introVisible = Computed.create(this, use => { - if (use(hasHistory)) { - return false; - } - if (use(this._chatActive)) { - return true; - } - return false; - }); - this._robotIconVisible = Computed.create(this, use => { - if (!use(this._assistantEnabled)) { return false; } - if (use(hasHistory)) { - return false; - } - if (use(this._introVisible)) { - return false; - } - if (use(this._chatActive)) { - return false; - } - return true; - }); - // Unfortunately we need to observe the size of the formula editor dom and resize it accordingly. const observer = new ResizeObserver(this._resizeEditor); observer.observe(this._options.editor.getDom()); @@ -200,85 +156,116 @@ export class FormulaAssistant extends Disposable { (el) => observer.observe(el), dom.onDispose(() => observer.disconnect()), cssButtons( - primaryButton(t('Save'), dom.on('click', () => { - this.saveOrClose(); - }), testId('save-button')), + basicButton(t('Cancel'), dom.on('click', () => { + this._cancel(); + }), testId('cancel-button')), basicButton(t('Preview'), dom.on('click', async () => { - await this.preview(); + await this._preview(); }), testId('preview-button')), - this.buildInlineRobotButton(), + primaryButton(t('Save'), dom.on('click', () => { + this._saveOrClose(); + }), testId('save-button')), ), - this.buildInfoCards(), - this.buildChat(), + this._buildChatPanel(), ); + + if (!this._assistantExpanded.get()) { + this._chatPanelBody.style.setProperty('height', '0px'); + } else { + // The actual height doesn't matter too much here, so we just pick + // a value that guarantees the assistant will fill as much of the + // available space as possible. + this._chatPanelBody.style.setProperty('height', '999px'); + } + return this._domElement; } - public buildInfoCards() { - return cssCardList( - dom.show(this._cardsVisible), - dom.maybe(use => use(this._assistantEnabled) && !use(this._isAssistantInfoClosed), () => - buildCard({ - close: () => this._isAssistantInfoClosed.set(true), - icon: "Robot", - title: t("Grist's AI Formula Assistance. "), - content: dom('span', - t('Need help? Our AI assistant can help.'), ' ', - textButton(t('Ask the bot.'), dom.on('click', this._robotIconClicked.bind(this)),), - ), - args: [testId('ai-well')] - }), - ), - dom.maybe(use => !use(this._isFormulaInfoClosed), () => - buildCard({ - close: () => this._isFormulaInfoClosed.set(true), - icon: 'Help', - title: t("Formula Help. "), - content: dom('span', - t('See our {{helpFunction}} and {{formulaCheat}}, or visit our {{community}} for more help.', { - helpFunction: cssLink(t('Function List'), {href: commonUrls.functions, target: '_blank'}), - formulaCheat: cssLink(t('Formula Cheat Sheet'), {href: commonUrls.formulaSheet, target: '_blank'}), - community: cssLink(t('Community'), {href: commonUrls.community, target: '_blank'}), - })), - args: [testId('formula-well')] - }) - ), - ); - } - - public buildChat() { + private _buildChatPanel() { return dom.maybe(this._assistantEnabled, () => { - setTimeout(() => { - if (!this.isDisposed()) { - // Scroll to the bottom of the chat right after it is rendered without the animation. - this._chat.scrollDown(false); - } - this._options.editor.resize(); - }, 0); - return cssChat( - testId('chat'), - dom.maybe(this._chatActive, () => [ - cssTopGreenBorder( - movable({ - onStart: this._onResizeStart.bind(this), - onMove: this._onResizeMove.bind(this), - }) - ), - ]), - this._buildIntro(), - this._chat.buildDom(), - this._buildChatInput(), - // Stop propagation of mousedown events, as the formula editor will still focus. - dom.on('mousedown', (ev) => ev.stopPropagation()), + return cssChatPanel( + cssChatPanelHeaderResizer( + movable({ + onStart: this._onResizeStart.bind(this), + onMove: this._onResizeMove.bind(this), + onEnd: this._onResizeEnd.bind(this), + }), + cssChatPanelHeaderResizer.cls('-collapsed', use => !use(this._assistantExpanded)), + ), + this._buildChatPanelHeader(), + this._buildChatPanelBody(), ); }); } + private _buildChatPanelHeader() { + return cssChatPanelHeader( + cssChatPanelHeaderTitle( + icon('Robot'), + t('AI Assistant'), + ), + cssChatPanelHeaderButtons( + cssChatPanelHeaderButton( + dom.domComputed(this._assistantExpanded, isExpanded => isExpanded + ? icon('Dropdown') : icon('DropdownUp')), + dom.on('click', () => { + if (this._assistantExpanded.get()) { + this._collapseChatPanel(); + } else { + this._expandChatPanel(); + } + }), + testId('ai-assistant-expand-collapse'), + ), + cssChatPanelHeaderButton( + icon('Dots'), + menu(() => [ + menuItem( + () => this._clear(), + t('Clear Conversation'), + testId('ai-assistant-options-clear-conversation'), + ), + ], {menuCssClass: menuCssClass + ' ' + cssChatOptionsMenu.className}), + testId('ai-assistant-options'), + ), + ), + ); + } + + private _buildChatPanelBody() { + setTimeout(() => { + if (!this.isDisposed()) { + // Scroll to the bottom of the chat right after it is rendered without the animation. + this._chat.scrollDown(false); + } + this._options.editor.resize(); + }, 0); + + const observer = new ResizeObserver(() => { + // Keep track of changes to the chat panel body height; its children need to know it to adjust + // their max heights accordingly. + this._chatPanelBodyClientHeight.set(this._chatPanelBody.clientHeight); + }); + + this._chatPanelBody = cssChatPanelBody( + dom.onDispose(() => observer.disconnect()), + testId('ai-assistant-chat-panel'), + this._chat.buildDom(), + this._gristDoc.appModel.currentValidUser ? this._buildChatInput() : this._buildSignupNudge(), + cssChatPanelBody.cls('-resizing', this._isResizing), + // Stop propagation of mousedown events, as the formula editor will still focus. + dom.on('mousedown', (ev) => ev.stopPropagation()), + ); + + observer.observe(this._chatPanelBody); + + return this._chatPanelBody; + } /** * Save button handler. We just store the action and wait for the bundler to finalize. */ - public saveOrClose() { + private _saveOrClose() { this._action = 'save'; this._triggerFinalize(); } @@ -286,7 +273,7 @@ export class FormulaAssistant extends Disposable { /** * Cancel button handler. */ - public async cancel() { + private _cancel() { this._action = 'cancel'; this._triggerFinalize(); } @@ -294,9 +281,8 @@ export class FormulaAssistant extends Disposable { /** * Preview button handler. */ - public async preview() { + private async _preview() { const tableId = this._options.column.table.peek().tableId.peek(); - // const colId = this._options.column.colId.peek(); const formula = this._options.editor.getCellValue(); const isFormula = true; await this._options.gristDoc.docData.sendAction( @@ -307,15 +293,6 @@ export class FormulaAssistant extends Disposable { } } - public buildInlineRobotButton() { - return cssRobotButton( - icon('Robot'), - dom.show(this._robotIconVisible), - dom.on('click', this._robotIconClicked.bind(this)), - testId('robot-button'), - ); - } - private async _preparePreview() { const docData = this._options.gristDoc.docData; const tableId = this._options.column.table.peek().tableId.peek(); @@ -382,7 +359,44 @@ export class FormulaAssistant extends Disposable { } } + private _collapseChatPanel() { + this._assistantExpanded.set(false); + // The panel's height and client height may differ; to ensure the collapse transition + // appears linear, temporarily disable the transition and sync the height and client + // height. + this._chatPanelBody.style.setProperty('transition', 'none'); + this._chatPanelBody.style.setProperty('height', `${this._chatPanelBody.clientHeight}px`); + // eslint-disable-next-line no-unused-expressions + this._chatPanelBody.offsetHeight; // Flush CSS changes. + this._chatPanelBody.style.removeProperty('transition'); + this._chatPanelBody.style.setProperty('height', '0px'); + this._resizeEditor(); + } + + private _expandChatPanel() { + this._assistantExpanded.set(true); + const editor = this._options.editor.getDom(); + let availableSpace = editor.clientHeight - MIN_FORMULA_EDITOR_HEIGHT_PX + - FORMULA_EDITOR_BUTTONS_HEIGHT_PX - CHAT_PANEL_HEADER_HEIGHT_PX; + if (editor.querySelector('.error_msg')) { + availableSpace -= editor.querySelector('.error_msg')!.clientHeight; + } + if (editor.querySelector('.error_details')) { + availableSpace -= editor.querySelector('.error_details')!.clientHeight; + } + if (this._lastChatPanelHeight) { + const height = Math.min(Math.max(this._lastChatPanelHeight, 220), availableSpace); + this._chatPanelBody.style.setProperty('height', `${height}px`); + this._lastChatPanelHeight = height; + } else { + this._lastChatPanelHeight = availableSpace; + this._chatPanelBody.style.setProperty('height', `${this._lastChatPanelHeight}px`); + } + this._resizeEditor(); + } + private _onResizeStart() { + this._isResizing.set(true); const start = this._domElement?.clientHeight; const total = this._options.editor.getDom().clientHeight; return { @@ -393,17 +407,43 @@ export class FormulaAssistant extends Disposable { /** * Resize handler for the chat window. */ - private _onResizeMove(x: number, y: number, {start, total}: {start: number, total: number}) { - // We want to keep the formula well at least 100px tall. - const minFormulaHeight = 100; - // The total height of the tools, input and resize line. - const toolsHeight = 43 + this._inputWrapper.clientHeight + 7; - const desiredHeight = start - y; - // Calculate the correct height in the allowed range. - const calculatedHeight = Math.max(toolsHeight + 10, Math.min(total - minFormulaHeight, desiredHeight)); - this._domElement.style.height = `${calculatedHeight}px`; + private _onResizeMove(x: number, y: number, {start, total}: {start: number, total: number}): void { + // The y axis includes the panel header and formula editor buttons; excluded them from the + // new height of the panel body. + const newChatPanelBodyHeight = start - y - CHAT_PANEL_HEADER_HEIGHT_PX - FORMULA_EDITOR_BUTTONS_HEIGHT_PX; + + // Toggle `_isResizing` whenever the new panel body height crosses the threshold for the minimum + // height. As of now, the sole purpose of this observable is to control when the animation for + // expanding and collapsing is shown. + if (newChatPanelBodyHeight < MIN_CHAT_PANEL_BODY_HEIGHT_PX && this._isResizing.get()) { + this._isResizing.set(false); + } else if (newChatPanelBodyHeight >= MIN_CHAT_PANEL_BODY_HEIGHT_PX && !this._isResizing.get()) { + this._isResizing.set(true); + } + + const collapseThreshold = 78; + if (newChatPanelBodyHeight < collapseThreshold) { + if (this._assistantExpanded.get()) { + this._collapseChatPanel(); + } + } else { + if (!this._assistantExpanded.get()) { + this._expandChatPanel(); + } + const calculatedHeight = Math.max( + MIN_CHAT_PANEL_BODY_HEIGHT_PX, + Math.min(total - MIN_FORMULA_EDITOR_HEIGHT_PX, newChatPanelBodyHeight) + ); + this._chatPanelBody.style.height = `${calculatedHeight}px`; + } } + private _onResizeEnd() { + this._isResizing.set(false); + if (this._assistantExpanded.get()) { + this._lastChatPanelHeight = this._chatPanelBody.clientHeight; + } + } /** * Builds the chat input at the bottom of the chat. */ @@ -412,7 +452,7 @@ export class FormulaAssistant extends Disposable { if (this._input) { dom.domDispose(this._input); } - const ask = () => this._ask(); + // Input is created by hand, as we need a finer control of the user input than what is available // in generic textInput control. this._input = cssInput( @@ -420,107 +460,105 @@ export class FormulaAssistant extends Disposable { this._userInput.set((ev.target as HTMLInputElement).value); }), autoGrow(this._userInput), + dom.style('max-height', use => { + // Set an upper bound on the height the input can grow to, so that when the chat panel + // is resized, the input is automatically resized to fit and doesn't overflow. + const panelHeight = use(this._chatPanelBodyClientHeight); + // The available input height is computed by taking the the panel height, and subtracting + // the heights of all the other elements (except for the input). + const availableInputHeight = panelHeight - + ((this._inputWrapper?.clientHeight ?? 0) - (this._input?.clientHeight ?? 0)) - + MIN_CHAT_HISTORY_HEIGHT_PX; + return `${Math.max(availableInputHeight, MIN_CHAT_INPUT_HEIGHT_PX)}px`; + }), dom.onKeyDown({ - Enter$: (ev) => { - // If shift is pressed, we want to insert a new line. - if (!ev.shiftKey) { - ev.preventDefault(); - ask().catch(reportError); - } - }, - Escape: this.cancel.bind(this), + Enter$: (ev) => this._handleChatEnterKeyDown(ev), + Escape: () => this._cancel(), }), dom.autoDispose(this._userInput.addListener(value => this._input.value = value)), dom.prop('disabled', this._waiting), + dom.prop('placeholder', use => { + const lastFormula = use(this._chat.lastSuggestedFormula); + if (lastFormula) { + return t('Press Enter to apply suggested formula.'); + } else { + return t('What do you need help with?'); + } + }), dom.autoDispose(this._waiting.addListener(value => { if (!value) { - setTimeout(() => this._input.focus(), 0); + setTimeout(() => this._focusChatInput(), 0); } })), ); + return this._inputWrapper = cssHContainer( - testId('chat-input'), - dom.style('margin-top', 'auto'), + testId('ai-assistant-chat-input'), dom.cls(cssTopBorder.className), dom.cls(cssVSpace.className), - dom.on('click', () => this._input.focus()), - dom.show(this._chatActive), cssInputWrapper( dom.cls(cssTypography.className), this._input, - dom.domComputed(this._waiting, (waiting) => { - if (!waiting) { return cssClickableIcon('FieldAny', dom.on('click', ask)); } - else { return cssLoadingDots(); } - }) - ), - cssVContainer( - cssHBox( - cssPlainButton( - icon('Script'), - t('New Chat'), - dom.on('click', this._clear.bind(this)), - testId('chat-new') - ), - cssPlainButton(icon('Revert'), t('Regenerate'), - dom.on('click', this._regenerate.bind(this)), dom.style('margin-left', '8px'), - testId('chat-regenerate') + cssInputButtonsRow( + cssSendMessageButton( + icon('FieldAny'), + dom.on('click', this._handleSendMessageClick.bind(this)), + cssSendMessageButton.cls('-disabled', use => + use(this._waiting) || use(this._userInput).length === 0 + ), ), + dom.on('click', (ev) => { + ev.stopPropagation(); + this._focusChatInput(); + }), + cssInputButtonsRow.cls('-disabled', this._waiting), ), - dom.style('padding-bottom', '0'), - dom.style('padding-top', '12px'), - ) + cssInputWrapper.cls('-disabled', this._waiting), + ), ); } /** - * Builds the intro section of the chat panel. TODO the copy. + * Builds the signup nudge shown to anonymous users at the bottom of the chat. */ - private _buildIntro() { - return dom.maybe(this._introVisible, () => cssInfo( - testId('chat-intro'), - cssTopHeader(t("Grist's AI Assistance")), - cssHeader(t('Tips')), - cssCardList( - buildCard({ - title: 'Example prompt: ', - content: 'Some instructions for how to draft a prompt. A link to even more examples in support. ', - }), - buildCard({ - title: 'Example Values: ', - content: 'Some instructions for how to draft a prompt. A link to even more examples in support. ', - }), + private _buildSignupNudge() { + return cssSignupNudgeWrapper( + cssSignupNudgeParagraph( + t('Sign up for a free Grist account to start using the Formula AI Assistant.'), ), - cssHeader(t('Capabilities')), - cssCardList( - buildCard({ - title: 'Example prompt: ', - content: 'Some instructions for how to draft a prompt. A link to even more examples in support. ', - }), - buildCard({ - title: 'Example Values: ', - content: 'Some instructions for how to draft a prompt. A link to even more examples in support. ', - }), + cssSignupNudgeButtonsRow( + bigPrimaryButtonLink( + t('Sign Up for Free'), + {href: getLoginOrSignupUrl()}, + testId('ai-assistant-sign-up'), + ), ), - cssHeader(t('Data')), - cssCardList( - buildCard({ - title: 'Data usage. ', - content: 'Some instructions for how to draft a prompt. A link to even more examples in support. ', - }), - buildCard({ - title: 'Data sharing. ', - content: 'Some instructions for how to draft a prompt. A link to even more examples in support. ', - }), - ) - )); + ); } - private _robotIconClicked() { - this._chatActive.set(true); + private async _handleChatEnterKeyDown(ev: KeyboardEvent) { + // If shift is pressed, we want to insert a new line. + if (ev.shiftKey) { return; } + + ev.preventDefault(); + const lastFormula = this._chat.lastSuggestedFormula.get(); + if (this._input.value === '' && lastFormula) { + this._apply(lastFormula).catch(reportError); + } else { + this._ask().catch(reportError); + } } - private _copyClicked(entry: ChatMessage) { - this._options.editor.setFormula(entry.formula!); + private async _handleSendMessageClick(ev: MouseEvent) { + if (this._waiting.get() || this._input.value.length === 0) { return; } + + await this._ask(); + } + + private async _apply(formula: string) { + this._options.editor.setFormula(formula); + this._resizeEditor(); + await this._preview(); } private async _sendMessage(description: string, regenerate = false): Promise { @@ -554,28 +592,27 @@ export class FormulaAssistant extends Disposable { }; } + private _focusChatInput() { + if (!this._input) { return; } + + this._input.focus(); + if (this._input.value.length > 0) { + // Make sure focus moves to the last character. + this._input.selectionStart = this._input.value.length; + this._input.scrollTop = this._input.scrollHeight; + } + } + private _clear() { this._chat.clear(); this._userInput.set(''); } - private async _regenerate() { - if (this._waiting.get()) { - return; - } - this._chat.removeLastResponse(); - const last = this._chat.lastQuestion(); - if (!last) { - return; - } - await this._doAsk(last); - } - private async _ask() { if (this._waiting.get()) { return; } - const message= this._userInput.get(); + const message = this._userInput.get(); if (!message) { return; } this._chat.addQuestion(message); this._userInput.set(''); @@ -604,13 +641,14 @@ export class FormulaAssistant extends Disposable { class ChatHistory extends Disposable { public history: MutableObsArray; public length: Computed; + public lastSuggestedFormula: Computed; private _element: HTMLElement; constructor(private _options: { column: ColumnRec, gristDoc: GristDoc, - copyClicked: (entry: ChatMessage) => void, + apply: (formula: string) => void, }) { super(); const column = this._options.column; @@ -621,6 +659,9 @@ class ChatHistory extends Disposable { chatHistory.set({...chatHistory.get(), messages: [...cur]}); })); this.length = Computed.create(this, use => use(this.history).length); // ?? + this.lastSuggestedFormula = Computed.create(this, use => { + return [...use(this.history)].reverse().find(entry => entry.formula)?.formula ?? null; + }); } public thinking(on = true) { @@ -695,31 +736,87 @@ class ChatHistory extends Disposable { public buildDom() { return this._element = cssHistory( + this._buildIntroMessage(), dom.forEach(this.history, entry => { if (entry.sender === 'user') { return cssMessage( - cssAvatar(buildAvatar(this._options.gristDoc)), dom('span', dom.text(entry.message), - testId('user-message'), - testId('chat-message'), - ) + testId('ai-assistant-message-user'), + testId('ai-assistant-message'), + ), + cssAvatar(buildAvatar(this._options.gristDoc)), ); } else { - return cssAiMessage( - cssAvatar(cssAiImage()), - entry.message === '...' ? cssCursor() : - this._render(entry.message, - testId('assistant-message'), - testId('chat-message'), + return dom('div', + cssAiMessage( + cssAvatar(cssAiImage()), + entry.message === '...' ? cssLoadingDots() : + this._render(entry.message, + dom.cls('formula-assistant-message'), + testId('ai-assistant-message-ai'), + testId('ai-assistant-message'), + ), ), - cssCopyIconWrapper( + cssAiMessageButtonsRow( + cssAiMessageButtons( + primaryButton(t('Apply'), dom.on('click', () => { + this._options.apply(entry.formula!); + })), + ), dom.show(Boolean(entry.formula)), - icon('Copy', dom.on('click', () => this._options.copyClicked(entry))), - ) + ), ); } - }) + }), + ); + } + + private _buildIntroMessage() { + return cssAiIntroMessage( + cssAvatar(cssAiImage()), + dom('div', + cssAiMessageParagraph(t(`Hi, I'm the Grist Formula AI Assistant.`)), + cssAiMessageParagraph(t(`There are some things you should know when working with me:`)), + cssAiMessageParagraph( + cssAiMessageBullet( + cssTickIcon('Tick'), + t('I can only help with formulas. I cannot build tables, columns, and views, or write access rules.'), + ), + cssAiMessageBullet( + cssTickIcon('Tick'), + t( + 'Talk to me like a person. No need to specify tables and column names. For example, you can ask ' + + '"Please calculate the total invoice amount."' + ), + ), + cssAiMessageBullet( + cssTickIcon('Tick'), + dom('div', + t( + 'When you talk to me, your questions and your document structure (visible in {{codeView}}) ' + + 'are sent to OpenAI. {{learnMore}}.', + { + codeView: cssLink(t('Code View'), urlState().setLinkUrl({docPage: 'code'})), + learnMore: cssLink(t('Learn more'), {href: commonUrls.help, target: '_blank'}), + } + ), + ), + ), + ), + cssAiMessageParagraph( + t( + 'For more help with formulas, check out our {{functionList}} and {{formulaCheatSheet}}, ' + + 'or visit our {{community}} for more help.', + { + functionList: cssLink(t('Function List'), {href: commonUrls.functions, target: '_blank'}), + formulaCheatSheet: cssLink(t('Formula Cheat Sheet'), {href: commonUrls.formulaSheet, target: '_blank'}), + community: cssLink(t('Community'), {href: commonUrls.community, target: '_blank'}), + } + ), + ), + ), + testId('ai-assistant-message-intro'), ); } @@ -736,7 +833,7 @@ class ChatHistory extends Disposable { const codeBlock = buildHighlightedCode(code, { gristTheme: doc.currentTheme, maxLines: 60, - }, cssCodeStyles.cls('')); + }); return codeBlock.innerHTML; }, })); @@ -749,7 +846,7 @@ class ChatHistory extends Disposable { return buildHighlightedCode(message, { gristTheme: doc.currentTheme, maxLines: 100, - }, cssCodeStyles.cls('')); + }); } } } @@ -775,32 +872,6 @@ async function askAI(grist: GristDoc, options: { return result; } -/** - * Builds a card with the given title and content. - */ -function buildCard(options: { - icon?: IconName, - title: string, - content: DomContents, - close?: () => void, - args?: DomElementArg[] -}) { - return cssCard( - options.icon && dom('div', cssCard.cls(`-icon`), icon(options.icon)), - dom('div', cssCard.cls('-body'), dom('span', - dom('span', cssCard.cls('-title'), options.title), - dom('span', cssCard.cls('-content'), options.content), - )), - options.icon && dom('div', - dom.on('click', options.close ?? noop), - cssCard.cls('-close'), - icon('CrossSmall'), - testId('well-close'), - ), - ...(options.args ?? []) - ); -} - /** Builds avatar image for user or assistant. */ function buildAvatar(grist: GristDoc) { const user = grist.app.topAppModel.appObs.get()?.currentUser || null; @@ -812,135 +883,86 @@ function buildAvatar(grist: GristDoc) { } } -// TODO: for now this icon is hidden as more design is needed. It overlaps various elements. -const detachRobotVisible = false; -export function buildRobotIcon() { - if (!detachRobotVisible) { return null; } - return dom.maybe(GRIST_FORMULA_ASSISTANT(), () => - cssDetachedRobotIcon( - 'Robot', - dom.on('click', () => { - commands.allCommands.detachEditor.run(); - commands.allCommands.activateAssistant.run(); - }), - testId('detached-robot-icon'), - ) - ); -} +const MIN_FORMULA_EDITOR_HEIGHT_PX = 100; -const cssDetachedRobotIcon = styled(icon, ` - left: -25px; - --icon-color: ${theme.iconButtonPrimaryBg}; - position: absolute; +const FORMULA_EDITOR_BUTTONS_HEIGHT_PX = 42; + +const MIN_CHAT_HISTORY_HEIGHT_PX = 100; + +const MIN_CHAT_PANEL_BODY_HEIGHT_PX = 120; + +const CHAT_PANEL_HEADER_HEIGHT_PX = 30; + +const MIN_CHAT_INPUT_HEIGHT_PX = 42; + +const cssChatPanel = styled('div', ` + position: relative; + display: flex; + flex-direction: column; + overflow:hidden; + flex-grow: 1; +`); + +const cssChatPanelHeader = styled('div', ` + display: flex; + justify-content: space-between; + align-items: center; + flex-shrink: 0; + padding: 0px 8px 0px 8px; + background-color: ${theme.formulaAssistantHeaderBg}; + height: ${CHAT_PANEL_HEADER_HEIGHT_PX}px; + border-top: 1px solid ${theme.formulaAssistantBorder}; + border-bottom: 1px solid ${theme.formulaAssistantBorder}; +`); + +const cssChatPanelHeaderTitle = styled('div', ` + display: flex; + align-items: center; + color: ${theme.lightText}; + --icon-color: ${theme.accentIcon}; + column-gap: 8px; + user-select: none; +`); + +const cssChatPanelHeaderButtons = styled('div', ` + display: flex; + align-items: center; + column-gap: 8px; +`); + +const cssChatPanelHeaderButton = styled('div', ` + --icon-color: ${theme.controlSecondaryFg}; + border-radius: 3px; + padding: 3px; cursor: pointer; - &:hover { - --icon-color: ${theme.iconButtonPrimaryHoverBg}; + user-select: none; + &:hover, &.weasel-popup-open { + background-color: ${theme.hover}; } `); -const cssInfo = styled('div', ` - overflow: auto; - height: 100%; -`); - -const cssTopHeader = styled('div', ` - font-size: 20px; - padding-left: 16px; - padding-right: 16px; - margin-top: 20px; - color: ${theme.inputFg}; -`); - -const cssHeader = styled('div', ` - font-size: 16px; - padding-left: 16px; - padding-right: 16px; - margin: 10px 0px; - color: ${theme.inputFg}; -`); - - -const cssTopGreenBorder = styled('div', ` - background: ${theme.accentBorder}; +const cssChatPanelHeaderResizer = styled('div', ` + position: absolute; + top: -3px; height: 7px; - border-top: 3px solid ${theme.pageBg}; - border-bottom: 3px solid ${theme.pageBg}; + width: 100%; cursor: ns-resize; - flex: none; `); -const cssChat = styled('div', ` +const cssChatPanelBody = styled('div', ` overflow: hidden; display: flex; flex-direction: column; flex-grow: 1; -`); + transition: height 0.4s; - -const cssRobotButton = styled('div', ` - padding-left: 9px; - padding-right: 9px; - padding-top: 4px; - padding-bottom: 6px; - margin-left: -8px; - --icon-color: ${theme.controlPrimaryBg}; - cursor: pointer; - &:hover { - --icon-color: ${theme.controlPrimaryHoverBg}; - } -`); - -const cssCardList = styled('div', ` - display: flex; - flex-direction: column; - gap: 12px; - padding: 16px; - padding-top: 0px; - & a { - font-weight: bold; - } -`); - -const cssCard = styled('div', ` - position: relative; - display: flex; - column-gap: 8px; - padding: 8px; - padding-bottom: 12px; - color: ${theme.inputFg}; - border-radius: 4px; - background-color: ${theme.cardCompactWidgetBg}; - &-icon { - --icon-color: ${theme.accentText}; - } - &-title { - font-weight: 600; - } - &-close { - position: absolute; - top: 4px; - right: 4px; - height: 20px; - width: 20px; - cursor: pointer; - } - &-close:hover { - background-color: ${theme.pageHoverBg}; - --icon-color: ${theme.linkHover}; - border-radius: 4px; - } - &-body { - padding-top: 2px; - padding-right: 12px; - line-height: 1.6em; - } - & button { - font-weight: 600; + &-resizing { + transition: unset; } `); const cssTopBorder = styled('div', ` - border-top: 1px solid ${theme.inputBorder}; + border-top: 1px solid ${theme.formulaAssistantBorder}; `); const cssVSpace = styled('div', ` @@ -949,8 +971,10 @@ const cssVSpace = styled('div', ` `); const cssHContainer = styled('div', ` + margin-top: auto; padding-left: 18px; padding-right: 18px; + min-height: ${MIN_CHAT_PANEL_BODY_HEIGHT_PX}px; display: flex; flex-shrink: 0; flex-direction: column; @@ -960,18 +984,6 @@ const cssTypography = styled('div', ` color: ${theme.inputFg}; `); -const cssHBox = styled('div', ` - display: flex; -`); - -const cssVContainer = styled('div', ` - padding-top: 18px; - padding-bottom: 18px; - display: flex; - flex-direction: column; -`); - - const cssHistory = styled('div', ` overflow: auto; display: flex; @@ -979,30 +991,15 @@ const cssHistory = styled('div', ` color: ${theme.inputFg}; `); - -const cssPlainButton = styled(basicButton, ` - border-color: ${theme.inputBorder}; - color: ${theme.controlSecondaryFg}; - --icon-color: ${theme.controlSecondaryFg}; - display: inline-flex; - gap: 10px; - align-items: flex-end; - border-radius: 3px; - padding: 5px 7px; - padding-right: 13px; -`); - const cssInputWrapper = styled('div', ` display: flex; + flex-direction: column; border: 1px solid ${theme.inputBorder}; border-radius: 3px; - background-color: ${theme.mainPanelBg}; align-items: center; - gap: 8px; - padding-right: 8px !important; --icon-color: ${theme.controlSecondaryFg}; - &:hover, &:focus-within { - --icon-color: ${theme.accentIcon}; + &-disabled { + background-color: ${theme.inputDisabledBg}; } & > input { outline: none; @@ -1016,31 +1013,39 @@ const cssInputWrapper = styled('div', ` const cssMessage = styled('div', ` display: grid; - grid-template-columns: 60px 1fr; - padding-right: 54px; - padding-top: 12px; - padding-bottom: 12px; + grid-template-columns: 1fr 60px; + border-top: 1px solid ${theme.formulaAssistantBorder}; + padding: 20px 0px 20px 20px; `); const cssAiMessage = styled('div', ` + position: relative; display: grid; - grid-template-columns: 60px 1fr 54px; - padding-top: 20px; - padding-bottom: 20px; - background: #D9D9D94f; + grid-template-columns: 60px 1fr; + border-top: 1px solid ${theme.formulaAssistantBorder}; + padding: 20px 20px 20px 0px; + & pre { - background: ${theme.cellBg}; + border: none; + background: ${theme.formulaAssistantPreformattedTextBg}; font-size: 10px; } + + & pre .ace-chrome, & pre .ace-dracula { + background: ${theme.formulaAssistantPreformattedTextBg} !important; + } + + & p > code { + background: #FFFFFF; + border: 1px solid #E1E4E5; + color: #333333; + white-space: pre-wrap; + word-wrap: break-word; + } `); -const cssCodeStyles = styled('div', ` - background: #E3E3E3; - border: none; - & .ace-chrome { - background: #E3E3E3; - border: none; - } +const cssAiIntroMessage = styled(cssAiMessage, ` + border-top: unset; `); const cssAvatar = styled('div', ` @@ -1061,36 +1066,6 @@ const cssAiImage = styled('div', ` background-position: center; `); - -const cssCopyIconWrapper = styled('div', ` - display: none; - align-items: center; - justify-content: center; - flex: none; - cursor: pointer; - .${cssAiMessage.className}:hover & { - display: flex; - } -`); - -const blink = keyframes(` - 0% { opacity: 1; } - 50% { opacity: 0; } - 100% { opacity: 1; } -`); - -const cssCursor = styled('div', ` - height: 1rem; - width: 3px; - background-color: ${theme.darkText}; - animation: ${blink} 1s infinite; -`); - -const cssLoadingDots = styled(loadingDots, ` - --dot-size: 4px; -`); - - const cssButtons = styled('div', ` display: flex; justify-content: flex-end; @@ -1104,19 +1079,116 @@ const cssTools = styled('div._tools_container', ` overflow: hidden; `); -const cssClickableIcon = styled(icon, ` - cursor: pointer; +const cssInputButtonsRow = styled('div', ` + padding-top: 8px; + width: 100%; + justify-content: flex-end; + cursor: text; + display: flex; + + &-disabled { + cursor: default; + } `); +const cssSendMessageButton = styled('div', ` + padding: 3px; + border-radius: 4px; + align-self: flex-end; + margin-bottom: 6px; + margin-right: 6px; + + &-disabled { + --icon-color: ${theme.controlSecondaryFg}; + } + + &:not(&-disabled) { + cursor: pointer; + --icon-color: ${theme.controlPrimaryFg}; + color: ${theme.controlPrimaryFg}; + background-color: ${theme.controlPrimaryBg}; + } + + &:hover:not(&-disabled) { + background-color: ${theme.controlPrimaryHoverBg}; + } +`); const cssInput = styled('textarea', ` border: 0px; flex-grow: 1; outline: none; + width: 100%; padding: 4px 6px; padding-top: 6px; resize: none; - min-height: 28px; + min-height: ${MIN_CHAT_INPUT_HEIGHT_PX}px; background: transparent; -} + + &:disabled { + background-color: ${theme.inputDisabledBg}; + color: ${theme.inputDisabledFg}; + } + + &::placeholder { + color: ${theme.inputPlaceholderFg}; + } +`); + +const cssChatOptionsMenu = styled('div', ` + z-index: ${vars.floatingPopupMenuZIndex}; +`); + +const cssAiMessageButtonsRow = styled('div', ` + display: flex; + justify-content: flex-end; + padding: 8px; +`); + +const cssAiMessageButtons = styled('div', ` + display: flex; + column-gap: 8px; +`); + +const cssAiMessageParagraph = styled('div', ` + margin-bottom: 8px; +`); + +const cssAiMessageBullet = styled('div', ` + display: flex; + align-items: flex-start; + margin-bottom: 6px; +`); + +const cssTickIcon = styled(icon, ` + --icon-color: ${theme.accentIcon}; + margin-right: 8px; + flex-shrink: 0; +`); + +const cssLoadingDots = styled(loadingDots, ` + --dot-size: 5px; + align-items: center; +`); + +const cssSignupNudgeWrapper = styled('div', ` + border-top: 1px solid ${theme.formulaAssistantBorder}; + padding: 16px; + margin-top: auto; + min-height: ${MIN_CHAT_PANEL_BODY_HEIGHT_PX}px; + display: flex; + flex-shrink: 0; + flex-direction: column; +`); + +const cssSignupNudgeParagraph = styled('div', ` + font-size: ${vars.mediumFontSize}; + font-weight: 500; + margin-bottom: 12px; + text-align: center; +`); + +const cssSignupNudgeButtonsRow = styled('div', ` + display: flex; + justify-content: center; `); diff --git a/app/client/widgets/FormulaEditor.ts b/app/client/widgets/FormulaEditor.ts index 47864a39..7f5fa327 100644 --- a/app/client/widgets/FormulaEditor.ts +++ b/app/client/widgets/FormulaEditor.ts @@ -8,12 +8,14 @@ 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 {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 {buildRobotIcon, FormulaAssistant} from 'app/client/widgets/FormulaAssistant'; +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'; @@ -55,6 +57,8 @@ export class FormulaEditor extends NewBaseEditor { private _dom: HTMLElement; private _editorPlacement!: EditorPlacement; private _placementHolder = Holder.create(this); + private _canDetach: boolean; + private _isEmpty: Computed; constructor(options: IFormulaEditorOptions) { super(options); @@ -65,6 +69,8 @@ export class FormulaEditor extends NewBaseEditor { // 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._formulaEditor = AceEditor.create({ // A bit awkward, but we need to assume calcSize is not used until attach() has been called // and _editorPlacement created. @@ -101,8 +107,7 @@ export class FormulaEditor extends NewBaseEditor { return true; } // Else invoke regular command. - commands.allCommands[name]?.run(); - return false; + return commands.allCommands[name]?.run() ?? false; }; const detachedCommands = this.autoDispose(commands.createGroup({ nextField: passThrough('nextField'), @@ -140,11 +145,17 @@ export class FormulaEditor extends NewBaseEditor { // the DOM to update before resizing. this.autoDispose(errorDetails.addListener(() => setTimeout(this.resize.bind(this), 0))); - const canDetach = GRIST_FORMULA_ASSISTANT().get() && options.canDetach && !options.readonly; + this._canDetach = Boolean(GRIST_FORMULA_ASSISTANT().get() && options.canDetach && !options.readonly); this.autoDispose(this._formulaEditor); + + // Show placeholder text when the formula is blank. + this._isEmpty.addListener(() => this._updateEditorPlaceholder()); + + // Update the placeholder text when expanding or collapsing the editor. + this.isDetached.addListener(() => this._updateEditorPlaceholder()); + this._dom = cssFormulaEditor( - buildRobotIcon(), // switch border shadow dom.cls("readonly_editor", options.readonly), createMobileButtons(options.commands), @@ -173,7 +184,10 @@ export class FormulaEditor extends NewBaseEditor { ev.preventDefault(); this.focus(); }), - canDetach ? createDetachedIcon(dom.hide(this.isDetached)) : null, + !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._formulaEditor.buildDom((aceObj: any) => { @@ -198,6 +212,11 @@ export class FormulaEditor extends NewBaseEditor { aceObj.once("change", () => { editingFormula?.(true); }); + + if (val === '') { + // Show placeholder text if the formula is blank. + this._updateEditorPlaceholder(); + } }) ), dom.maybe(options.formulaError, () => [ @@ -305,6 +324,40 @@ export class FormulaEditor extends NewBaseEditor { return this._dom; } + private _updateEditorPlaceholder() { + const editor = this._formulaEditor.getEditor(); + const shouldShowPlaceholder = editor.session.getValue().length === 0; + const placeholderNode = editor.renderer.emptyMessageNode; + if (placeholderNode) { + // Remove the current placeholder if one is present. + editor.renderer.scroller.removeChild(placeholderNode); + } + if (!shouldShowPlaceholder) { + editor.renderer.emptyMessageNode = null; + } else { + editor.renderer.emptyMessageNode = cssFormulaPlaceholder( + !this._canDetach || this.isDetached.get() + ? 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); + } + this._formulaEditor.resize(); + } + + 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. @@ -313,6 +366,16 @@ export class FormulaEditor extends NewBaseEditor { width: 0 }; } + + const placeholder: HTMLElement | undefined = this._formulaEditor.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; @@ -652,6 +715,9 @@ 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', ` @@ -666,11 +732,15 @@ const cssFormulaEditor = styled('div.default_editor.formula_editor_wrapper', ` } &-detached .formula_editor { flex-grow: 1; + min-height: 100px; } &-detached .error_msg, &-detached .error_details { - flex-grow: 0; - flex-shrink: 1; + max-height: 100px; + flex-shrink: 0; + } + + &-detached .error_msg { cursor: default; } @@ -683,12 +753,14 @@ const cssFormulaEditor = styled('div.default_editor.formula_editor_wrapper', ` height: 100% !important; width: 100% !important; } - - .floating-popup .formula_editor { - min-height: 100px; - } - - .floating-popup .error_details { - min-height: 100px; - } +`); + +const cssFormulaPlaceholder = styled('div', ` + color: ${theme.lightText}; + font-style: italic; + white-space: nowrap; +`); + +const cssUseAssistantButton = styled(textButton, ` + font-size: ${vars.smallFontSize}; `); diff --git a/app/client/widgets/TextEditor.css b/app/client/widgets/TextEditor.css index f0603d4a..daa5b136 100644 --- a/app/client/widgets/TextEditor.css +++ b/app/client/widgets/TextEditor.css @@ -42,8 +42,8 @@ /* Make overflow hidden, since editor might be 1 pixel bigger due to fix for devices * with different pixel ratio */ .formula_editor { - background-color: var(--grist-theme-formula-editor-bg, white); - padding: 4px 0 2px 21px; + background-color: var(--grist-theme-ace-editor-bg, white); + padding: 4px 4px 2px 21px; z-index: 10; overflow: hidden; flex: none; @@ -109,12 +109,14 @@ } .error_msg { + display: flex; background-color: #ffb6c1; padding: 4px; color: black; cursor: pointer; white-space: pre-wrap; flex: none; + overflow: auto; } .error_details { diff --git a/app/common/ThemePrefs-ti.ts b/app/common/ThemePrefs-ti.ts index 78a60b3a..3d86dd87 100644 --- a/app/common/ThemePrefs-ti.ts +++ b/app/common/ThemePrefs-ti.ts @@ -333,7 +333,6 @@ export const ThemeColors = t.iface([], { "cell-fg": "string", "cell-bg": "string", "cell-zebra-bg": "string", - "formula-editor-bg": "string", "chart-fg": "string", "chart-bg": "string", "chart-legend-bg": "string", @@ -359,6 +358,7 @@ export const ThemeColors = t.iface([], { "tutorials-popup-border": "string", "tutorials-popup-header-fg": "string", "tutorials-popup-box-bg": "string", + "ace-editor-bg": "string", "ace-autocomplete-primary-fg": "string", "ace-autocomplete-secondary-fg": "string", "ace-autocomplete-highlighted-fg": "string", @@ -391,6 +391,9 @@ export const ThemeColors = t.iface([], { "login-page-bg": "string", "login-page-backdrop": "string", "login-page-line": "string", + "formula-assistant-header-bg": "string", + "formula-assistant-border": "string", + "formula-assistant-preformatted-text-bg": "string", }); const exportedTypeSuite: t.ITypeSuite = { diff --git a/app/common/ThemePrefs.ts b/app/common/ThemePrefs.ts index 39ae7193..0d084a08 100644 --- a/app/common/ThemePrefs.ts +++ b/app/common/ThemePrefs.ts @@ -436,9 +436,6 @@ export interface ThemeColors { 'cell-bg': string; 'cell-zebra-bg': string; - /* Formula Editor */ - 'formula-editor-bg': string; - /* Charts */ 'chart-fg': string; 'chart-bg': string; @@ -472,7 +469,8 @@ export interface ThemeColors { 'tutorials-popup-header-fg': string; 'tutorials-popup-box-bg': string; - /* Ace Autocomplete */ + /* Ace */ + 'ace-editor-bg': string; 'ace-autocomplete-primary-fg': string; 'ace-autocomplete-secondary-fg': string; 'ace-autocomplete-highlighted-fg': string; @@ -511,6 +509,11 @@ export interface ThemeColors { 'login-page-bg': string; 'login-page-backdrop': string; 'login-page-line': string; + + /* Formula Assistant */ + 'formula-assistant-header-bg': string; + 'formula-assistant-border': string; + 'formula-assistant-preformatted-text-bg': string; } export const ThemePrefsChecker = createCheckers(ThemePrefsTI).ThemePrefs as CheckerT; diff --git a/app/common/themes/GristDark.ts b/app/common/themes/GristDark.ts index 4d782a1d..d9013038 100644 --- a/app/common/themes/GristDark.ts +++ b/app/common/themes/GristDark.ts @@ -415,9 +415,6 @@ export const GristDark: ThemeColors = { 'cell-bg': '#32323F', 'cell-zebra-bg': '#262633', - /* Formula Editor */ - 'formula-editor-bg': '#282A36', - /* Charts */ 'chart-fg': '#A4A4A4', 'chart-bg': '#32323F', @@ -451,7 +448,8 @@ export const GristDark: ThemeColors = { 'tutorials-popup-header-fg': '#FFFFFF', 'tutorials-popup-box-bg': '#57575F', - /* Ace Autocomplete */ + /* Ace */ + 'ace-editor-bg': '#32323F', 'ace-autocomplete-primary-fg': '#EFEFEF', 'ace-autocomplete-secondary-fg': '#A4A4A4', 'ace-autocomplete-highlighted-fg': '#FFFFFF', @@ -490,4 +488,9 @@ export const GristDark: ThemeColors = { 'login-page-bg': '#32323F', 'login-page-backdrop': '#404150', 'login-page-line': '#57575F', + + /* Formula Assistant */ + 'formula-assistant-header-bg': '#262633', + 'formula-assistant-border': '#69697D', + 'formula-assistant-preformatted-text-bg': '#262633', }; diff --git a/app/common/themes/GristLight.ts b/app/common/themes/GristLight.ts index ecb969aa..28819ab6 100644 --- a/app/common/themes/GristLight.ts +++ b/app/common/themes/GristLight.ts @@ -415,9 +415,6 @@ export const GristLight: ThemeColors = { 'cell-bg': 'white', 'cell-zebra-bg': '#F8F8F8', - /* Formula Editor */ - 'formula-editor-bg': 'white', - /* Charts */ 'chart-fg': '#444', 'chart-bg': '#fff', @@ -451,7 +448,8 @@ export const GristLight: ThemeColors = { 'tutorials-popup-header-fg': '#FFFFFF', 'tutorials-popup-box-bg': '#F5F5F5', - /* Ace Autocomplete */ + /* Ace */ + 'ace-editor-bg': 'white', 'ace-autocomplete-primary-fg': '#444', 'ace-autocomplete-secondary-fg': '#8F8F8F', 'ace-autocomplete-highlighted-fg': '#000', @@ -490,4 +488,9 @@ export const GristLight: ThemeColors = { 'login-page-bg': 'white', 'login-page-backdrop': '#F5F8FA', 'login-page-line': '#F7F7F7', + + /* Formula Assistant */ + 'formula-assistant-header-bg': '#F7F7F7', + 'formula-assistant-border': '#D9D9D9', + 'formula-assistant-preformatted-text-bg': '#F7F7F7', }; diff --git a/package.json b/package.json index c104212d..149c5614 100644 --- a/package.json +++ b/package.json @@ -116,13 +116,13 @@ "@gristlabs/sqlite3": "5.1.4-grist.8", "@popperjs/core": "2.3.3", "accept-language-parser": "1.5.0", + "ace-builds": "1.23.3", "async-mutex": "0.2.4", "axios": "0.21.2", "backbone": "1.3.3", "bootstrap": "3.3.5", "bootstrap-datepicker": "1.9.0", "bowser": "2.7.0", - "brace": "0.11.1", "collect-js-deps": "^0.1.1", "color-convert": "2.0.1", "commander": "9.3.0", diff --git a/yarn.lock b/yarn.lock index 23b597a1..37d61912 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1262,6 +1262,11 @@ accepts@~1.3.5: mime-types "~2.1.24" negotiator "0.6.2" +ace-builds@1.23.3: + version "1.23.3" + resolved "https://registry.yarnpkg.com/ace-builds/-/ace-builds-1.23.3.tgz#0e9a18194b3e8a29a724ffa11900bf1fdc6fe8ef" + integrity sha512-TTWtmCQCaMZyTALMBDeQGu2VD9N6ijVZPYwz+CrpuS1XxYVdWPuvK+7b0ORVlIVXLJBnIf8D6dpP8iTO1elemA== + acorn-class-fields@^0.3.7: version "0.3.7" resolved "https://registry.yarnpkg.com/acorn-class-fields/-/acorn-class-fields-0.3.7.tgz#a35122f3cc6ad2bb33b1857e79215677fcfdd720" @@ -1871,11 +1876,6 @@ brace-expansion@^2.0.1: dependencies: balanced-match "^1.0.0" -brace@0.11.1: - version "0.11.1" - resolved "https://registry.npmjs.org/brace/-/brace-0.11.1.tgz" - integrity sha1-SJb8ydVE7vRfS7dmDbMg07N5/lg= - braces@^3.0.2, braces@~3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"