(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
This commit is contained in:
George Gevoian 2023-07-13 10:00:56 -04:00
parent 8581492912
commit ea8a59c5e9
23 changed files with 983 additions and 641 deletions

View File

@ -1,8 +1,8 @@
import ace, {Ace} from 'ace-builds';
import {setupAceEditorCompletions} from 'app/client/components/AceEditorCompletions'; import {setupAceEditorCompletions} from 'app/client/components/AceEditorCompletions';
import {theme} from 'app/client/ui2018/cssVars'; import {theme} from 'app/client/ui2018/cssVars';
import {Theme} from 'app/common/ThemePrefs'; import {Theme} from 'app/common/ThemePrefs';
import {getGristConfig} from 'app/common/urlUtils'; import {getGristConfig} from 'app/common/urlUtils';
import * as ace from 'brace';
import {Computed, dom, DomArg, Listener, Observable, styled} from 'grainjs'; import {Computed, dom, DomArg, Listener, Observable, styled} from 'grainjs';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
@ -13,13 +13,13 @@ export interface ACLFormulaOptions {
placeholder: DomArg; placeholder: DomArg;
setValue: (value: string) => void; setValue: (value: string) => void;
getSuggestions: (prefix: string) => string[]; getSuggestions: (prefix: string) => string[];
customiseEditor?: (editor: ace.Editor) => void; customiseEditor?: (editor: Ace.Editor) => void;
} }
export function aclFormulaEditor(options: ACLFormulaOptions) { export function aclFormulaEditor(options: ACLFormulaOptions) {
// Create an element and an editor within it. // Create an element and an editor within it.
const editorElem = dom('div'); const editorElem = dom('div');
const editor: ace.Editor = ace.edit(editorElem); const editor: Ace.Editor = ace.edit(editorElem);
// Set various editor options. // Set various editor options.
function setAceTheme(gristTheme: Theme) { function setAceTheme(gristTheme: Theme) {
@ -40,7 +40,7 @@ export function aclFormulaEditor(options: ACLFormulaOptions) {
editor.renderer.setShowGutter(false); // Default line numbers to hidden editor.renderer.setShowGutter(false); // Default line numbers to hidden
editor.renderer.setPadding(5); editor.renderer.setPadding(5);
editor.renderer.setScrollMargin(4, 4, 0, 0); editor.renderer.setScrollMargin(4, 4, 0, 0);
editor.$blockScrolling = Infinity; (editor as any).$blockScrolling = Infinity;
editor.setReadOnly(options.readOnly); editor.setReadOnly(options.readOnly);
editor.setFontSize('12'); editor.setFontSize('12');
editor.setHighlightActiveLine(false); editor.setHighlightActiveLine(false);

View File

@ -1,3 +1,7 @@
.ace_editor {
background-color: var(--grist-theme-ace-editor-bg, white);
}
.ace_grist_link_hidden { .ace_grist_link_hidden {
display: none; display: none;
} }
@ -14,6 +18,7 @@
.ace_editor.ace_autocomplete .ace_completion-highlight { .ace_editor.ace_autocomplete .ace_completion-highlight {
color: var(--grist-theme-ace-autocomplete-highlighted-fg, #000) !important; 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 { .ace_editor.ace_autocomplete .ace_completion-highlight.ace_grist_link {
@ -41,3 +46,8 @@
.ace_editor.ace_autocomplete .ace_marker-layer .ace_active-line { .ace_editor.ace_autocomplete .ace_marker-layer .ace_active-line {
background-color: var(--grist-theme-ace-autocomplete-active-line-bg, #CAD6FA) !important; 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;
}

View File

@ -1,10 +1,11 @@
var ace = require('brace'); var ace = require('ace-builds');
var _ = require('underscore'); var _ = require('underscore');
// Used to load python language settings and color themes // ace-builds also has a minified build (src-min-noconflict), but we don't
require('brace/mode/python'); // use it since webpack already handles minification.
require('brace/theme/chrome'); require('ace-builds/src-noconflict/mode-python');
require('brace/theme/dracula'); require('ace-builds/src-noconflict/theme-chrome');
require('brace/ext/language_tools'); require('ace-builds/src-noconflict/theme-dracula');
require('ace-builds/src-noconflict/ext-language_tools');
var {setupAceEditorCompletions} = require('./AceEditorCompletions'); var {setupAceEditorCompletions} = require('./AceEditorCompletions');
var {getGristConfig} = require('../../common/urlUtils'); var {getGristConfig} = require('../../common/urlUtils');
var dom = require('../lib/dom'); var dom = require('../lib/dom');
@ -291,7 +292,7 @@ AceEditor.prototype._setAceTheme = function(gristTheme) {
let _RangeConstructor = null; //singleton, load it lazily let _RangeConstructor = null; //singleton, load it lazily
AceEditor.makeRange = function(a, b, c, d) { 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); return new _RangeConstructor(a, b, c, d);
}; };

View File

@ -1,14 +1,14 @@
import ace, {Ace} from 'ace-builds';
import {ISuggestionWithValue} from 'app/common/ActiveDocAPI'; import {ISuggestionWithValue} from 'app/common/ActiveDocAPI';
import {commonUrls} from 'app/common/gristUrls'; import {commonUrls} from 'app/common/gristUrls';
import * as ace from 'brace';
export interface ICompletionOptions { export interface ICompletionOptions {
getSuggestions(prefix: string): Promise<ISuggestionWithValue[]>; getSuggestions(prefix: string): Promise<ISuggestionWithValue[]>;
} }
const completionOptions = new WeakMap<ace.Editor, ICompletionOptions>(); const completionOptions = new WeakMap<Ace.Editor, ICompletionOptions>();
export function setupAceEditorCompletions(editor: ace.Editor, options: ICompletionOptions) { export function setupAceEditorCompletions(editor: Ace.Editor, options: ICompletionOptions) {
initCustomCompleter(); initCustomCompleter();
completionOptions.set(editor, options); 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 // 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 // use a suggestion, a change of behavior that doesn't seem particularly desirable and
// which also breaks several existing tests. // 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(); const completer = new Autocomplete();
completer.autoSelect = false; 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 // 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 // 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.) // expect that a single AutoCompleter would be used for all editor instances.)
editor.on('destroy', () => { editor.on('destroy' as any, () => {
if (completer.editor) { if (completer.editor) {
completer.detach(); completer.detach();
} }
@ -91,10 +91,10 @@ function initCustomCompleter() {
const prefixMatchRegex = /\w+\.(?:lookupRecords|lookupOne)\([\w.$\u00A2-\uFFFF]*$|[\w.$\u00A2-\uFFFF]+$/; const prefixMatchRegex = /\w+\.(?:lookupRecords|lookupOne)\([\w.$\u00A2-\uFFFF]*$|[\w.$\u00A2-\uFFFF]+$/;
// Monkey-patch getCompletionPrefix. This is based on the source code in // 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 // node_modules/ace-builds/src-noconflict/ext-language_tools.js, simplified to do the one thing
// the original method's generality doesn't help us here). // 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 const util = ace.require('ace/autocomplete/util');
util.getCompletionPrefix = function getCompletionPrefix(this: any, editor: ace.Editor) { util.getCompletionPrefix = function getCompletionPrefix(this: any, editor: Ace.Editor) {
const pos = editor.getCursorPosition(); const pos = editor.getCursorPosition();
const line = editor.session.getLine(pos.row); const line = editor.session.getLine(pos.row);
const match = line.slice(0, pos.column).match(prefixMatchRegex); const match = line.slice(0, pos.column).match(prefixMatchRegex);
@ -102,14 +102,14 @@ function initCustomCompleter() {
}; };
// Add some autocompletion with partial access to document // 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.setCompleters([]);
aceLanguageTools.addCompleter({ aceLanguageTools.addCompleter({
// For autocompletion we ship text to the sandbox and run standard completion there. // For autocompletion we ship text to the sandbox and run standard completion there.
async getCompletions( async getCompletions(
editor: ace.Editor, editor: Ace.Editor,
session: ace.IEditSession, session: Ace.EditSession,
pos: ace.Position, pos: Ace.Position,
prefix: string, prefix: string,
callback: any callback: any
) { ) {
@ -120,12 +120,13 @@ function initCustomCompleter() {
// in the case where one function is being switched with another. Since we normally // 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 // 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 // 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, // current token is a function/identifier, and the next token is a lparen; if both are
// we skip appending a "(" to each suggestion. // true, we skip appending a "(" to each suggestion.
const wordRange = session.getWordRange(pos.row, pos.column); const wordRange = session.getWordRange(pos.row, pos.column);
const token = session.getTokenAt(pos.row, wordRange.end.column) as TokenInfo; const token = session.getTokenAt(pos.row, wordRange.end.column) as Ace.Token;
const nextToken = session.getTokenAt(pos.row, wordRange.end.column + 1) as TokenInfo|null; const nextToken = session.getTokenAt(pos.row, wordRange.end.column + 1);
const isRenamingFunc = token.type === 'identifier' && nextToken?.type === 'paren.lparen'; const isRenamingFunc = ['function.support', 'identifier'].includes(token.type)
&& nextToken?.type === 'paren.lparen';
const suggestions = await options.getSuggestions(prefix); const suggestions = await options.getSuggestions(prefix);
// ACE autocompletions are very poorly documented. This is somewhat helpful: // 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. * 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 * 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) { function aceCompleterAddHelpLinks(completer: any) {
// Replace the $init function in order to intercept the creation of the autocomplete popup. // 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 { function retokenizeAceCompleterRow(rowData: AceSuggestion, tokens: Ace.Token[]): Ace.Token[] {
type: string;
}
function retokenizeAceCompleterRow(rowData: AceSuggestion, tokens: TokenInfo[]): TokenInfo[] {
if (!(rowData.funcname || rowData.example)) { if (!(rowData.funcname || rowData.example)) {
// Not a special completion, pass through the result of ACE's original tokenizing. // Not a special completion, pass through the result of ACE's original tokenizing.
return tokens; return tokens;

View File

@ -29,6 +29,7 @@ export function documentCursor(type: 'ns-resize' | 'grabbing'): IDisposable {
export function movable<T>(options: { export function movable<T>(options: {
onMove: (dx: number, dy: number, state: T) => void, onMove: (dx: number, dy: number, state: T) => void,
onStart: () => T, onStart: () => T,
onEnd?: () => void,
}) { }) {
return (el: HTMLElement) => { return (el: HTMLElement) => {
// Remember the initial position of the mouse. // Remember the initial position of the mouse.
@ -53,6 +54,7 @@ export function movable<T>(options: {
options.onMove(dx, dy, state); options.onMove(dx, dy, state);
})); }));
owner.autoDispose(dom.onElem(document, 'mouseup', () => { owner.autoDispose(dom.onElem(document, 'mouseup', () => {
options.onEnd?.();
holder.clear(); holder.clear();
})); }));
owner.autoDispose(documentCursor('ns-resize')); owner.autoDispose(documentCursor('ns-resize'));

View File

@ -1,14 +1,15 @@
import * as ace from 'ace-builds';
import {theme, vars} from 'app/client/ui2018/cssVars'; import {theme, vars} from 'app/client/ui2018/cssVars';
import {Theme} from 'app/common/ThemePrefs'; import {Theme} from 'app/common/ThemePrefs';
import {getGristConfig} from 'app/common/urlUtils'; import {getGristConfig} from 'app/common/urlUtils';
import * as ace from 'brace';
import {BindableValue, Computed, dom, DomElementArg, Observable, styled, subscribeElem} from 'grainjs'; import {BindableValue, Computed, dom, DomElementArg, Observable, styled, subscribeElem} from 'grainjs';
// tslint:disable:no-var-requires // ace-builds also has a minified build (src-min-noconflict), but we don't
require('brace/ext/static_highlight'); // use it since webpack already handles minification.
require("brace/mode/python"); require('ace-builds/src-noconflict/ext-static_highlight');
require("brace/theme/chrome"); require('ace-builds/src-noconflict/mode-python');
require('brace/theme/dracula'); require('ace-builds/src-noconflict/theme-chrome');
require('ace-builds/src-noconflict/theme-dracula');
export interface ICodeOptions { export interface ICodeOptions {
gristTheme: Computed<Theme>; gristTheme: Computed<Theme>;
@ -22,10 +23,11 @@ export function buildHighlightedCode(
const {gristTheme, placeholder, maxLines} = options; const {gristTheme, placeholder, maxLines} = options;
const {enableCustomCss} = getGristConfig(); const {enableCustomCss} = getGristConfig();
const highlighter = ace.acequire('ace/ext/static_highlight'); const highlighter = ace.require('ace/ext/static_highlight');
const PythonMode = ace.acequire('ace/mode/python').Mode; const PythonMode = ace.require('ace/mode/python').Mode;
const chrome = ace.acequire('ace/theme/chrome'); const aceDom = ace.require('ace/lib/dom');
const dracula = ace.acequire('ace/theme/dracula'); const chrome = ace.require('ace/theme/chrome');
const dracula = ace.require('ace/theme/dracula');
const mode = new PythonMode(); const mode = new PythonMode();
const codeText = Observable.create(null, ''); const codeText = Observable.create(null, '');
@ -33,7 +35,11 @@ export function buildHighlightedCode(
function updateHighlightedCode(elem: HTMLElement) { function updateHighlightedCode(elem: HTMLElement) {
let text = codeText.get(); let text = codeText.get();
if (text) { if (!text) {
elem.textContent = placeholder || '';
return;
}
if (maxLines) { if (maxLines) {
// If requested, trim to maxLines, and add an ellipsis at the end. // If requested, trim to maxLines, and add an ellipsis at the end.
// (Long lines are also truncated with an ellpsis via text-overflow style.) // (Long lines are also truncated with an ellpsis via text-overflow style.)
@ -43,11 +49,23 @@ export function buildHighlightedCode(
} }
} }
const aceTheme = codeTheme.get().appearance === 'dark' && !enableCustomCss ? dracula : chrome; let aceThemeName: 'chrome' | 'dracula';
elem.innerHTML = highlighter.render(text, mode, aceTheme, 1, true).html; let aceTheme: any;
if (codeTheme.get().appearance === 'dark' && !enableCustomCss) {
aceThemeName = 'dracula';
aceTheme = dracula;
} else { } else {
elem.textContent = placeholder || ''; 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( return cssHighlightedCode(

View File

@ -2,7 +2,7 @@ import {GristDoc} from 'app/client/components/GristDoc';
import {logTelemetryEvent} from 'app/client/lib/telemetry'; import {logTelemetryEvent} from 'app/client/lib/telemetry';
import {getWelcomeHomeUrl, urlState} from 'app/client/models/gristUrlState'; import {getWelcomeHomeUrl, urlState} from 'app/client/models/gristUrlState';
import {renderer} from 'app/client/ui/DocTutorialRenderer'; 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 {sanitizeHTML} from 'app/client/ui/sanitizeHTML';
import {hoverTooltip, setHoverTooltip} from 'app/client/ui/tooltips'; import {hoverTooltip, setHoverTooltip} from 'app/client/ui/tooltips';
import {basicButton, primaryButton} from 'app/client/ui2018/buttons'; import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
@ -26,8 +26,6 @@ interface DocTutorialSlide {
const testId = makeTestId('test-doc-tutorial-'); const testId = makeTestId('test-doc-tutorial-');
const TOOLTIP_KEY = 'docTutorialTooltip';
export class DocTutorial extends FloatingPopup { export class DocTutorial extends FloatingPopup {
private _appModel = this._gristDoc.docPageModel.appModel; private _appModel = this._gristDoc.docPageModel.appModel;
private _currentDoc = this._gristDoc.docPageModel.currentDoc.get(); private _currentDoc = this._gristDoc.docPageModel.currentDoc.get();
@ -47,7 +45,10 @@ export class DocTutorial extends FloatingPopup {
}); });
constructor(private _gristDoc: GristDoc) { constructor(private _gristDoc: GristDoc) {
super({stopClickPropagationOnMove: true}); super({
minimizable: true,
stopClickPropagationOnMove: true,
});
} }
public async start() { public async start() {
@ -102,7 +103,7 @@ export class DocTutorial extends FloatingPopup {
return [ return [
cssFooterButtonsLeft( cssFooterButtonsLeft(
cssPopupFooterButton(icon('Undo'), cssPopupFooterButton(icon('Undo'),
hoverTooltip('Restart Tutorial', {key: TOOLTIP_KEY}), hoverTooltip('Restart Tutorial', {key: FLOATING_POPUP_TOOLTIP_KEY}),
dom.on('click', () => this._restartTutorial()), dom.on('click', () => this._restartTutorial()),
testId('popup-restart'), testId('popup-restart'),
), ),
@ -111,7 +112,7 @@ export class DocTutorial extends FloatingPopup {
range(slides.length).map((i) => cssProgressBarDot( range(slides.length).map((i) => cssProgressBarDot(
hoverTooltip(slides[i].slideTitle, { hoverTooltip(slides[i].slideTitle, {
closeOnClick: false, closeOnClick: false,
key: TOOLTIP_KEY, key: FLOATING_POPUP_TOOLTIP_KEY,
}), }),
cssProgressBarDot.cls('-current', i === slideIndex), cssProgressBarDot.cls('-current', i === slideIndex),
i === slideIndex ? null : dom.on('click', () => this._changeSlide(i)), i === slideIndex ? null : dom.on('click', () => this._changeSlide(i)),
@ -315,7 +316,7 @@ export class DocTutorial extends FloatingPopup {
img.src = img.src; img.src = img.src;
setHoverTooltip(img, 'Click to expand', { setHoverTooltip(img, 'Click to expand', {
key: TOOLTIP_KEY, key: FLOATING_POPUP_TOOLTIP_KEY,
modifiers: { modifiers: {
flip: { flip: {
boundariesElement: 'scrollParent', boundariesElement: 'scrollParent',

View File

@ -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<void>; type SaveHandler = (column: ColumnRec, formula: string) => Promise<void>;
type BuildEditor = (
cellElem: Element, type BuildEditor = (options: BuildEditorOptions) => void;
editValue?: string,
onSave?: SaveHandler,
onCancel?: () => void) => void;
export function buildFormulaConfig( export function buildFormulaConfig(
owner: MultiHolder, origColumn: ColumnRec, gristDoc: GristDoc, buildEditor: BuildEditor owner: MultiHolder, origColumn: ColumnRec, gristDoc: GristDoc, buildEditor: BuildEditor
@ -315,14 +325,14 @@ export function buildFormulaConfig(
const errorMessage = createFormulaErrorObs(owner, gristDoc, origColumn); const errorMessage = createFormulaErrorObs(owner, gristDoc, origColumn);
// Helper that will create different flavors for formula builder. // Helper that will create different flavors for formula builder.
const formulaBuilder = (onSave: SaveHandler) => [ const formulaBuilder = (onSave: SaveHandler, canDetach?: boolean) => [
cssRow(formulaField = buildFormula( cssRow(formulaField = buildFormula(
origColumn, origColumn,
buildEditor, buildEditor,
{ {
gristTheme: gristDoc.currentTheme, gristTheme: gristDoc.currentTheme,
placeholder: t("Enter formula"),
disabled: disableOtherActions, disabled: disableOtherActions,
canDetach,
onSave, onSave,
onCancel: clearState, onCancel: clearState,
})), })),
@ -386,7 +396,7 @@ export function buildFormulaConfig(
// If data column is or wants to be a trigger formula: // If data column is or wants to be a trigger formula:
dom.maybe((use) => use(maybeTrigger) || use(origColumn.hasTriggerFormula), () => [ dom.maybe((use) => use(maybeTrigger) || use(origColumn.hasTriggerFormula), () => [
cssLabel(t("TRIGGER FORMULA")), cssLabel(t("TRIGGER FORMULA")),
formulaBuilder(onSaveConvertToTrigger), formulaBuilder(onSaveConvertToTrigger, false),
dom.create(buildFormulaTriggers, origColumn, { dom.create(buildFormulaTriggers, origColumn, {
disabled: disableOtherActions, disabled: disableOtherActions,
notTrigger: maybeTrigger, notTrigger: maybeTrigger,
@ -411,8 +421,8 @@ export function buildFormulaConfig(
interface BuildFormulaOptions { interface BuildFormulaOptions {
gristTheme: Computed<Theme>; gristTheme: Computed<Theme>;
placeholder: string;
disabled: Observable<boolean>; disabled: Observable<boolean>;
canDetach?: boolean;
onSave?: SaveHandler; onSave?: SaveHandler;
onCancel?: () => void; onCancel?: () => void;
} }
@ -422,8 +432,8 @@ function buildFormula(
buildEditor: BuildEditor, buildEditor: BuildEditor,
options: BuildFormulaOptions options: BuildFormulaOptions
) { ) {
const {gristTheme, placeholder, disabled, onSave, onCancel} = options; const {gristTheme, disabled, canDetach = true, onSave, onCancel} = options;
return cssFieldFormula(column.formula, {gristTheme, placeholder, maxLines: 2}, return cssFieldFormula(column.formula, {gristTheme, maxLines: 2},
dom.cls('formula_field_sidepane'), dom.cls('formula_field_sidepane'),
cssFieldFormula.cls('-disabled', disabled), cssFieldFormula.cls('-disabled', disabled),
cssFieldFormula.cls('-disabled-icon', use => !use(column.formula)), cssFieldFormula.cls('-disabled-icon', use => !use(column.formula)),
@ -431,7 +441,13 @@ function buildFormula(
{tabIndex: '-1'}, {tabIndex: '-1'},
// Focus event use used by a user to edit an existing formula. // Focus event use used by a user to edit an existing formula.
// It can also be triggered manually to open up the editor. // 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,
})),
); );
} }

View File

@ -1,31 +1,45 @@
import {makeT} from 'app/client/lib/localization';
import {documentCursor} from 'app/client/lib/popupUtils'; import {documentCursor} from 'app/client/lib/popupUtils';
import {hoverTooltip} from 'app/client/ui/tooltips'; import {hoverTooltip} from 'app/client/ui/tooltips';
import {isNarrowScreen, isNarrowScreenObs, theme, vars} from 'app/client/ui2018/cssVars'; 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 {icon} from 'app/client/ui2018/icons';
import {Disposable, dom, DomContents, DomElementArg, import {Disposable, dom, DomContents, DomElementArg,
IDisposable, makeTestId, Observable, styled} from 'grainjs'; IDisposable, makeTestId, Observable, styled} from 'grainjs';
const POPUP_INITIAL_PADDING_PX = 16; const POPUP_INITIAL_PADDING_PX = 16;
const POPUP_MIN_HEIGHT = 300; const POPUP_DEFAULT_MIN_HEIGHT = 300;
const POPUP_MAX_HEIGHT = 711; const POPUP_MAX_HEIGHT = 711;
const POPUP_HEADER_HEIGHT = 30; const POPUP_HEADER_HEIGHT = 30;
const t = makeT('FloatingPopup');
const testId = makeTestId('test-floating-popup-'); const testId = makeTestId('test-floating-popup-');
export const FLOATING_POPUP_TOOLTIP_KEY = 'floatingPopupTooltip';
export interface PopupOptions { export interface PopupOptions {
title?: () => DomContents; title?: () => DomContents;
content?: () => DomContents; content?: () => DomContents;
onClose?: () => void; onClose?: () => void;
closeButton?: boolean; closeButton?: boolean;
closeButtonIcon?: IconName;
closeButtonHover?: () => DomContents; closeButtonHover?: () => DomContents;
minimizable?: boolean;
autoHeight?: boolean; autoHeight?: boolean;
/** Minimum height in pixels. */
minHeight?: number;
/** Defaults to false. */ /** Defaults to false. */
stopClickPropagationOnMove?: boolean; stopClickPropagationOnMove?: boolean;
initialPosition?: [left: number, top: number];
args?: DomElementArg[]; args?: DomElementArg[];
} }
export class FloatingPopup extends Disposable { export class FloatingPopup extends Disposable {
protected _isMinimized = Observable.create(this, false); 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 _isFinishingMove = false;
private _popupElement: HTMLElement | null = null; private _popupElement: HTMLElement | null = null;
private _popupMinimizeButtonElement: HTMLElement | null = null; private _popupMinimizeButtonElement: HTMLElement | null = null;
@ -71,7 +85,7 @@ export class FloatingPopup extends Disposable {
this.autoDispose(isNarrowScreenObs().addListener(() => this._repositionPopup())); this.autoDispose(isNarrowScreenObs().addListener(() => this._repositionPopup()));
this.onDispose(() => { this.onDispose(() => {
this._closePopup(); this._disposePopup();
this._cursorGrab?.dispose(); this._cursorGrab?.dispose();
}); });
} }
@ -79,18 +93,22 @@ export class FloatingPopup extends Disposable {
public showPopup() { public showPopup() {
this._popupElement = this._buildPopup(); this._popupElement = this._buildPopup();
document.body.appendChild(this._popupElement); document.body.appendChild(this._popupElement);
const topPaddingPx = getTopPopupPaddingPx();
const initialLeft = document.body.offsetWidth - this._popupElement.offsetWidth - POPUP_INITIAL_PADDING_PX; const {initialPosition} = this._options;
const initialTop = document.body.offsetHeight - this._popupElement.offsetHeight - topPaddingPx; if (initialPosition) {
this._popupElement.style.left = `${initialLeft}px`; this._setPosition(initialPosition);
this._popupElement.style.top = `${initialTop}px`; 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() { protected _closePopup() {
if (!this._popupElement) { return; } if (!this._closable) { return; }
document.body.removeChild(this._popupElement);
dom.domDispose(this._popupElement); this._disposePopup();
this._popupElement = null;
} }
protected _buildTitle(): DomContents { protected _buildTitle(): DomContents {
@ -105,6 +123,21 @@ export class FloatingPopup extends Disposable {
return this._options.args ?? []; 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() { private _rememberPosition() {
this._initialLeft = this._popupElement!.offsetLeft; this._initialLeft = this._popupElement!.offsetLeft;
this._initialTop = this._popupElement!.offsetTop; this._initialTop = this._popupElement!.offsetTop;
@ -151,7 +184,7 @@ export class FloatingPopup extends Disposable {
// First just how much we can resize the popup. // First just how much we can resize the popup.
let minTop = this._initialBottom - POPUP_MAX_HEIGHT; 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). // Now how far we can move top (leave at least some padding for mobile).
minTop = Math.max(minTop, getTopPopupPaddingPx()); minTop = Math.max(minTop, getTopPopupPaddingPx());
@ -250,6 +283,8 @@ export class FloatingPopup extends Disposable {
} }
private _minimizeOrMaximize() { private _minimizeOrMaximize() {
if (!this._minimizable) { return; }
this._isMinimized.set(!this._isMinimized.get()); this._isMinimized.set(!this._isMinimized.get());
this._repositionPopup(); this._repositionPopup();
} }
@ -258,6 +293,7 @@ export class FloatingPopup extends Disposable {
const body = cssPopup( const body = cssPopup(
{tabIndex: '-1'}, {tabIndex: '-1'},
cssPopup.cls('-auto', this._options.autoHeight ?? false), cssPopup.cls('-auto', this._options.autoHeight ?? false),
dom.style('min-height', `${this._minHeight}px`),
cssPopupHeader( cssPopupHeader(
cssBottomHandle(testId('move-handle')), cssBottomHandle(testId('move-handle')),
dom.maybe(use => !use(this._isMinimized), () => { dom.maybe(use => !use(this._isMinimized), () => {
@ -277,10 +313,12 @@ export class FloatingPopup extends Disposable {
// center the title. // center the title.
cssPopupButtons( cssPopupButtons(
cssPopupHeaderButton( cssPopupHeaderButton(
icon('Maximize') icon('Maximize'),
dom.show(this._minimizable),
), ),
!this._options.closeButton ? null : cssPopupHeaderButton( cssPopupHeaderButton(
icon('CrossBig'), icon('CrossBig'),
dom.show(this._closable),
), ),
dom.style('visibility', 'hidden'), dom.style('visibility', 'hidden'),
), ),
@ -291,17 +329,23 @@ export class FloatingPopup extends Disposable {
cssPopupButtons( cssPopupButtons(
this._popupMinimizeButtonElement = cssPopupHeaderButton( this._popupMinimizeButtonElement = cssPopupHeaderButton(
isMinimized ? icon('Maximize'): icon('Minimize'), 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.on('click', () => this._minimizeOrMaximize()),
dom.show(this._minimizable),
testId('minimize-maximize'), testId('minimize-maximize'),
), ),
!this._options.closeButton ? null : cssPopupHeaderButton( cssPopupHeaderButton(
icon('CrossBig'), icon(this._options.closeButtonIcon ?? 'CrossBig'),
this._options.closeButtonHover && hoverTooltip(this._options.closeButtonHover(), {
key: FLOATING_POPUP_TOOLTIP_KEY,
}),
dom.on('click', () => { dom.on('click', () => {
this._options.onClose?.() ?? this._closePopup(); this._options.onClose?.() ?? this._closePopup();
}), }),
dom.show(this._closable),
testId('close'), testId('close'),
this._options.closeButtonHover && hoverTooltip(this._options.closeButtonHover())
), ),
// Disable dragging when a button in the header is clicked. // Disable dragging when a button in the header is clicked.
dom.on('mousedown', ev => ev.stopPropagation()), 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 = `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_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', ` const cssPopup = styled('div.floating-popup', `
position: fixed; position: fixed;
@ -374,7 +420,6 @@ const cssPopup = styled('div.floating-popup', `
--height: ${POPUP_MAX_HEIGHT}px; --height: ${POPUP_MAX_HEIGHT}px;
height: ${POPUP_HEIGHT}; height: ${POPUP_HEIGHT};
width: ${POPUP_WIDTH}; width: ${POPUP_WIDTH};
min-height: ${POPUP_MIN_HEIGHT}px;
background-color: ${theme.popupBg}; background-color: ${theme.popupBg};
box-shadow: 0 2px 18px 0 ${theme.popupInnerShadow}, 0 0 1px 0 ${theme.popupOuterShadow}; box-shadow: 0 2px 18px 0 ${theme.popupInnerShadow}, 0 0 1px 0 ${theme.popupOuterShadow};
outline: unset; outline: unset;

View File

@ -23,12 +23,13 @@ import * as imports from 'app/client/lib/imports';
import {makeT} from 'app/client/lib/localization'; import {makeT} from 'app/client/lib/localization';
import {createSessionObs} from 'app/client/lib/sessionObs'; import {createSessionObs} from 'app/client/lib/sessionObs';
import {reportError} from 'app/client/models/AppModel'; 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 {GridOptions} from 'app/client/ui/GridOptions';
import {attachPageWidgetPicker, IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker'; import {attachPageWidgetPicker, IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
import {linkId, selectBy} from 'app/client/ui/selectBy'; import {linkId, selectBy} from 'app/client/ui/selectBy';
import {CustomSectionConfig} from 'app/client/ui/CustomSectionConfig'; import {CustomSectionConfig} from 'app/client/ui/CustomSectionConfig';
import {buildDescriptionConfig} from 'app/client/ui/DescriptionConfig'; import {buildDescriptionConfig} from 'app/client/ui/DescriptionConfig';
import {BuildEditorOptions} from 'app/client/ui/FieldConfig';
import {cssLabel} from 'app/client/ui/RightPanelStyles'; import {cssLabel} from 'app/client/ui/RightPanelStyles';
import {VisibleFieldsConfig} from 'app/client/ui/VisibleFieldsConfig'; import {VisibleFieldsConfig} from 'app/client/ui/VisibleFieldsConfig';
import {IWidgetType, widgetTypes} from 'app/client/ui/widgetTypes'; 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. // Helper to activate the side-pane formula editor over the given HTML element.
private _activateFormulaEditor( private _activateFormulaEditor(options: BuildEditorOptions) {
// 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<void>,
// Custom cancel handler.
onCancel?: () => void) {
const vsi = this._gristDoc.viewModel.activeSection().viewInstance(); const vsi = this._gristDoc.viewModel.activeSection().viewInstance();
if (!vsi) { return; } 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) { private _buildPageWidgetContent(_owner: MultiHolder) {

View File

@ -143,6 +143,7 @@ export const vars = {
floatingPopupZIndex: new CustomProp('floating-popup-z-index', '1002'), floatingPopupZIndex: new CustomProp('floating-popup-z-index', '1002'),
tutorialModalZIndex: new CustomProp('tutorial-modal-z-index', '1003'), tutorialModalZIndex: new CustomProp('tutorial-modal-z-index', '1003'),
pricingModalZIndex: new CustomProp('pricing-modal-z-index', '1004'), pricingModalZIndex: new CustomProp('pricing-modal-z-index', '1004'),
floatingPopupMenuZIndex: new CustomProp('floating-popup-menu-z-index', '1004'),
notificationZIndex: new CustomProp('notification-z-index', '1100'), notificationZIndex: new CustomProp('notification-z-index', '1100'),
browserCheckZIndex: new CustomProp('browser-check-z-index', '5000'), browserCheckZIndex: new CustomProp('browser-check-z-index', '5000'),
tooltipZIndex: new CustomProp('tooltip-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'), cellBg: new CustomProp('theme-cell-bg', undefined, '#FFFFFF00'),
cellZebraBg: new CustomProp('theme-cell-zebra-bg', undefined, '#F8F8F8'), cellZebraBg: new CustomProp('theme-cell-zebra-bg', undefined, '#F8F8F8'),
/* Formula Editor */
formulaEditorBg: new CustomProp('theme-formula-editor-bg', undefined, 'white'),
/* Charts */ /* Charts */
chartFg: new CustomProp('theme-chart-fg', undefined, '#444'), chartFg: new CustomProp('theme-chart-fg', undefined, '#444'),
chartBg: new CustomProp('theme-chart-bg', undefined, '#fff'), chartBg: new CustomProp('theme-chart-bg', undefined, '#fff'),
@ -736,7 +734,8 @@ export const theme = {
colors.lightGreen), colors.lightGreen),
tutorialsPopupBoxBg: new CustomProp('theme-tutorials-popup-box-bg', undefined, '#F5F5F5'), 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'), aceAutocompletePrimaryFg: new CustomProp('theme-ace-autocomplete-primary-fg', undefined, '#444'),
aceAutocompleteSecondaryFg: new CustomProp('theme-ace-autocomplete-secondary-fg', undefined, aceAutocompleteSecondaryFg: new CustomProp('theme-ace-autocomplete-secondary-fg', undefined,
'#8f8f8f'), '#8f8f8f'),
@ -797,6 +796,14 @@ export const theme = {
loginPageBg: new CustomProp('theme-login-page-bg', undefined, 'white'), loginPageBg: new CustomProp('theme-login-page-bg', undefined, 'white'),
loginPageBackdrop: new CustomProp('theme-login-page-backdrop', undefined, '#F5F8FA'), loginPageBackdrop: new CustomProp('theme-login-page-backdrop', undefined, '#F5F8FA'),
loginPageLine: new CustomProp('theme-login-page-line', undefined, colors.lightGrey), 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'); const cssColors = values(colors).map(v => v.decl()).join('\n');

View File

@ -775,12 +775,16 @@ export class FieldBuilder extends Disposable {
/** /**
* Open the formula editor in the side pane. It will be positioned over refElem. * Open the formula editor in the side pane. It will be positioned over refElem.
*/ */
public openSideFormulaEditor( public openSideFormulaEditor(options: {
editRow: DataRowModel, editRow: DataRowModel,
refElem: Element, refElem: Element,
canDetach: boolean,
editValue?: string, editValue?: string,
onSave?: (column: ColumnRec, formula: string) => Promise<void>, onSave?: (column: ColumnRec, formula: string) => Promise<void>,
onCancel?: () => void) { onCancel?: () => void
}) {
const {editRow, refElem, canDetach, editValue, onSave, onCancel} = options;
// Remember position when the popup was opened. // Remember position when the popup was opened.
const position = this.gristDoc.cursorPosition.get(); const position = this.gristDoc.cursorPosition.get();
@ -838,14 +842,18 @@ export class FieldBuilder extends Disposable {
editRow, editRow,
refElem, refElem,
editValue, editValue,
canDetach: true, canDetach,
onSave, onSave,
onCancel onCancel
}); });
// And now create the floating editor itself. It is just a floating wrapper that will grab the dom // 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. // 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. // Add editor to document holder - this will prevent multiple formula editor instances.
this.gristDoc.fieldEditorHolder.autoDispose(formulaEditor); this.gristDoc.fieldEditorHolder.autoDispose(formulaEditor);

View File

@ -159,7 +159,11 @@ export class FieldEditor extends Disposable {
this.rebuildEditor(editValue, Number.POSITIVE_INFINITY, options.state); this.rebuildEditor(editValue, Number.POSITIVE_INFINITY, options.state);
// Create a floating editor, which will be used to display the editor in a popup. // 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) { if (offerToMakeFormula) {
this._offerToMakeFormula(); this._offerToMakeFormula();
@ -318,6 +322,10 @@ export class FieldEditor extends Disposable {
private _unmakeFormula() { private _unmakeFormula() {
const editor = this._editorHolder.get(); 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 // 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"). // data, use column menu option, or delete the formula first (which makes the column "empty").
if (editor && this._field.editingFormula.peek() && editor.getCursorPos() === 0 && if (editor && this._field.editingFormula.peek() && editor.getCursorPos() === 0 &&

View File

@ -2,24 +2,57 @@ import * as commands from 'app/client/components/commands';
import {GristDoc} from 'app/client/components/GristDoc'; import {GristDoc} from 'app/client/components/GristDoc';
import {detachNode} from 'app/client/lib/dom'; import {detachNode} from 'app/client/lib/dom';
import {FocusLayer} from 'app/client/lib/FocusLayer'; 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 {theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons'; import {icon} from 'app/client/ui2018/icons';
import {Disposable, dom, Holder, IDisposableOwner, IDomArgs, import {Disposable, dom, Holder, IDisposableOwner, IDomArgs,
makeTestId, MultiHolder, Observable, styled} from 'grainjs'; makeTestId, MultiHolder, Observable, styled} from 'grainjs';
const t = makeT('FloatingEditor');
const testId = makeTestId('test-floating-editor-');
export interface IFloatingOwner extends IDisposableOwner { export interface IFloatingOwner extends IDisposableOwner {
detach(): HTMLElement; detach(): HTMLElement;
attach(content: HTMLElement): Promise<void>|void; attach(content: HTMLElement): Promise<void>|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 { export class FloatingEditor extends Disposable {
public active = Observable.create<boolean>(this, false); public active = Observable.create<boolean>(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(); super();
this.autoDispose(commands.createGroup({ this.autoDispose(commands.createGroup({
detachEditor: this.createPopup.bind(this), detachEditor: this.createPopup.bind(this),
@ -52,7 +85,8 @@ export class FloatingEditor extends Disposable {
// detach it on close. // detach it on close.
title: () => title, // We are not reactive yet title: () => title, // We are not reactive yet
closeButton: true, // Show the close button with a hover closeButton: true, // Show the close button with a hover
closeButtonHover: () => 'Return to cell', closeButtonIcon: 'Minimize',
closeButtonHover: () => t('Collapse Editor'),
onClose: async () => { onClose: async () => {
const layer = FocusLayer.create(null, { defaultFocusElem: document.activeElement as any}); const layer = FocusLayer.create(null, { defaultFocusElem: document.activeElement as any});
try { try {
@ -63,6 +97,8 @@ export class FloatingEditor extends Disposable {
layer.dispose(); layer.dispose();
} }
}, },
minHeight: 550,
initialPosition: this._getInitialPosition(),
args: [testId('popup')] args: [testId('popup')]
}); });
// Set a public flag that we are active. // Set a public flag that we are active.
@ -78,6 +114,38 @@ export class FloatingEditor extends Disposable {
tempOwner.dispose(); 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<HTMLDivElement>) { export function createDetachedIcon(...args: IDomArgs<HTMLDivElement>) {

File diff suppressed because it is too large Load Diff

View File

@ -8,12 +8,14 @@ import {ColumnRec} from 'app/client/models/DocModel';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec'; import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {reportError} from 'app/client/models/errors'; import {reportError} from 'app/client/models/errors';
import {GRIST_FORMULA_ASSISTANT} from 'app/client/models/features'; 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 {icon} from 'app/client/ui2018/icons';
import {createMobileButtons, getButtonMargins} from 'app/client/widgets/EditorButtons'; import {createMobileButtons, getButtonMargins} from 'app/client/widgets/EditorButtons';
import {EditorPlacement, ISize} from 'app/client/widgets/EditorPlacement'; import {EditorPlacement, ISize} from 'app/client/widgets/EditorPlacement';
import {createDetachedIcon} from 'app/client/widgets/FloatingEditor'; 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 {NewBaseEditor, Options} from 'app/client/widgets/NewBaseEditor';
import {asyncOnce} from 'app/common/AsyncCreate'; import {asyncOnce} from 'app/common/AsyncCreate';
import {CellValue} from 'app/common/DocActions'; import {CellValue} from 'app/common/DocActions';
@ -55,6 +57,8 @@ export class FormulaEditor extends NewBaseEditor {
private _dom: HTMLElement; private _dom: HTMLElement;
private _editorPlacement!: EditorPlacement; private _editorPlacement!: EditorPlacement;
private _placementHolder = Holder.create(this); private _placementHolder = Holder.create(this);
private _canDetach: boolean;
private _isEmpty: Computed<boolean>;
constructor(options: IFormulaEditorOptions) { constructor(options: IFormulaEditorOptions) {
super(options); super(options);
@ -65,6 +69,8 @@ export class FormulaEditor extends NewBaseEditor {
// create editor state observable (used by draft and latest position memory) // create editor state observable (used by draft and latest position memory)
this.editorState = Observable.create(this, initialValue); this.editorState = Observable.create(this, initialValue);
this._isEmpty = Computed.create(this, this.editorState, (_use, state) => state === '');
this._formulaEditor = AceEditor.create({ this._formulaEditor = AceEditor.create({
// A bit awkward, but we need to assume calcSize is not used until attach() has been called // A bit awkward, but we need to assume calcSize is not used until attach() has been called
// and _editorPlacement created. // and _editorPlacement created.
@ -101,8 +107,7 @@ export class FormulaEditor extends NewBaseEditor {
return true; return true;
} }
// Else invoke regular command. // Else invoke regular command.
commands.allCommands[name]?.run(); return commands.allCommands[name]?.run() ?? false;
return false;
}; };
const detachedCommands = this.autoDispose(commands.createGroup({ const detachedCommands = this.autoDispose(commands.createGroup({
nextField: passThrough('nextField'), nextField: passThrough('nextField'),
@ -140,11 +145,17 @@ export class FormulaEditor extends NewBaseEditor {
// the DOM to update before resizing. // the DOM to update before resizing.
this.autoDispose(errorDetails.addListener(() => setTimeout(this.resize.bind(this), 0))); 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); 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( this._dom = cssFormulaEditor(
buildRobotIcon(),
// switch border shadow // switch border shadow
dom.cls("readonly_editor", options.readonly), dom.cls("readonly_editor", options.readonly),
createMobileButtons(options.commands), createMobileButtons(options.commands),
@ -173,7 +184,10 @@ export class FormulaEditor extends NewBaseEditor {
ev.preventDefault(); ev.preventDefault();
this.focus(); 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), cssFormulaEditor.cls('-detached', this.isDetached),
dom('div.formula_editor.formula_field_edit', testId('formula-editor'), dom('div.formula_editor.formula_field_edit', testId('formula-editor'),
this._formulaEditor.buildDom((aceObj: any) => { this._formulaEditor.buildDom((aceObj: any) => {
@ -198,6 +212,11 @@ export class FormulaEditor extends NewBaseEditor {
aceObj.once("change", () => { aceObj.once("change", () => {
editingFormula?.(true); editingFormula?.(true);
}); });
if (val === '') {
// Show placeholder text if the formula is blank.
this._updateEditorPlaceholder();
}
}) })
), ),
dom.maybe(options.formulaError, () => [ dom.maybe(options.formulaError, () => [
@ -305,6 +324,40 @@ export class FormulaEditor extends NewBaseEditor {
return this._dom; 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) { private _calcSize(elem: HTMLElement, desiredElemSize: ISize) {
if (this.isDetached.get()) { if (this.isDetached.get()) {
// If we are detached, we will stop autosizing. // If we are detached, we will stop autosizing.
@ -313,6 +366,16 @@ export class FormulaEditor extends NewBaseEditor {
width: 0 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 errorBox: HTMLElement|null = this._dom.querySelector('.error_details');
const errorBoxStartHeight = errorBox?.getBoundingClientRect().height || 0; const errorBoxStartHeight = errorBox?.getBoundingClientRect().height || 0;
const errorBoxDesiredHeight = errorBox?.scrollHeight || 0; const errorBoxDesiredHeight = errorBox?.scrollHeight || 0;
@ -652,6 +715,9 @@ const cssCollapseIcon = styled(icon, `
margin: -3px 4px 0 4px; margin: -3px 4px 0 4px;
--icon-color: ${colors.slate}; --icon-color: ${colors.slate};
cursor: pointer; cursor: pointer;
position: sticky;
top: 0px;
flex-shrink: 0;
`); `);
export const cssError = styled('div', ` export const cssError = styled('div', `
@ -666,11 +732,15 @@ const cssFormulaEditor = styled('div.default_editor.formula_editor_wrapper', `
} }
&-detached .formula_editor { &-detached .formula_editor {
flex-grow: 1; flex-grow: 1;
min-height: 100px;
} }
&-detached .error_msg, &-detached .error_details { &-detached .error_msg, &-detached .error_details {
flex-grow: 0; max-height: 100px;
flex-shrink: 1; flex-shrink: 0;
}
&-detached .error_msg {
cursor: default; cursor: default;
} }
@ -683,12 +753,14 @@ const cssFormulaEditor = styled('div.default_editor.formula_editor_wrapper', `
height: 100% !important; height: 100% !important;
width: 100% !important; width: 100% !important;
} }
`);
.floating-popup .formula_editor {
min-height: 100px; const cssFormulaPlaceholder = styled('div', `
} color: ${theme.lightText};
font-style: italic;
.floating-popup .error_details { white-space: nowrap;
min-height: 100px; `);
}
const cssUseAssistantButton = styled(textButton, `
font-size: ${vars.smallFontSize};
`); `);

View File

@ -42,8 +42,8 @@
/* Make overflow hidden, since editor might be 1 pixel bigger due to fix for devices /* Make overflow hidden, since editor might be 1 pixel bigger due to fix for devices
* with different pixel ratio */ * with different pixel ratio */
.formula_editor { .formula_editor {
background-color: var(--grist-theme-formula-editor-bg, white); background-color: var(--grist-theme-ace-editor-bg, white);
padding: 4px 0 2px 21px; padding: 4px 4px 2px 21px;
z-index: 10; z-index: 10;
overflow: hidden; overflow: hidden;
flex: none; flex: none;
@ -109,12 +109,14 @@
} }
.error_msg { .error_msg {
display: flex;
background-color: #ffb6c1; background-color: #ffb6c1;
padding: 4px; padding: 4px;
color: black; color: black;
cursor: pointer; cursor: pointer;
white-space: pre-wrap; white-space: pre-wrap;
flex: none; flex: none;
overflow: auto;
} }
.error_details { .error_details {

View File

@ -333,7 +333,6 @@ export const ThemeColors = t.iface([], {
"cell-fg": "string", "cell-fg": "string",
"cell-bg": "string", "cell-bg": "string",
"cell-zebra-bg": "string", "cell-zebra-bg": "string",
"formula-editor-bg": "string",
"chart-fg": "string", "chart-fg": "string",
"chart-bg": "string", "chart-bg": "string",
"chart-legend-bg": "string", "chart-legend-bg": "string",
@ -359,6 +358,7 @@ export const ThemeColors = t.iface([], {
"tutorials-popup-border": "string", "tutorials-popup-border": "string",
"tutorials-popup-header-fg": "string", "tutorials-popup-header-fg": "string",
"tutorials-popup-box-bg": "string", "tutorials-popup-box-bg": "string",
"ace-editor-bg": "string",
"ace-autocomplete-primary-fg": "string", "ace-autocomplete-primary-fg": "string",
"ace-autocomplete-secondary-fg": "string", "ace-autocomplete-secondary-fg": "string",
"ace-autocomplete-highlighted-fg": "string", "ace-autocomplete-highlighted-fg": "string",
@ -391,6 +391,9 @@ export const ThemeColors = t.iface([], {
"login-page-bg": "string", "login-page-bg": "string",
"login-page-backdrop": "string", "login-page-backdrop": "string",
"login-page-line": "string", "login-page-line": "string",
"formula-assistant-header-bg": "string",
"formula-assistant-border": "string",
"formula-assistant-preformatted-text-bg": "string",
}); });
const exportedTypeSuite: t.ITypeSuite = { const exportedTypeSuite: t.ITypeSuite = {

View File

@ -436,9 +436,6 @@ export interface ThemeColors {
'cell-bg': string; 'cell-bg': string;
'cell-zebra-bg': string; 'cell-zebra-bg': string;
/* Formula Editor */
'formula-editor-bg': string;
/* Charts */ /* Charts */
'chart-fg': string; 'chart-fg': string;
'chart-bg': string; 'chart-bg': string;
@ -472,7 +469,8 @@ export interface ThemeColors {
'tutorials-popup-header-fg': string; 'tutorials-popup-header-fg': string;
'tutorials-popup-box-bg': string; 'tutorials-popup-box-bg': string;
/* Ace Autocomplete */ /* Ace */
'ace-editor-bg': string;
'ace-autocomplete-primary-fg': string; 'ace-autocomplete-primary-fg': string;
'ace-autocomplete-secondary-fg': string; 'ace-autocomplete-secondary-fg': string;
'ace-autocomplete-highlighted-fg': string; 'ace-autocomplete-highlighted-fg': string;
@ -511,6 +509,11 @@ export interface ThemeColors {
'login-page-bg': string; 'login-page-bg': string;
'login-page-backdrop': string; 'login-page-backdrop': string;
'login-page-line': 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<ThemePrefs>; export const ThemePrefsChecker = createCheckers(ThemePrefsTI).ThemePrefs as CheckerT<ThemePrefs>;

View File

@ -415,9 +415,6 @@ export const GristDark: ThemeColors = {
'cell-bg': '#32323F', 'cell-bg': '#32323F',
'cell-zebra-bg': '#262633', 'cell-zebra-bg': '#262633',
/* Formula Editor */
'formula-editor-bg': '#282A36',
/* Charts */ /* Charts */
'chart-fg': '#A4A4A4', 'chart-fg': '#A4A4A4',
'chart-bg': '#32323F', 'chart-bg': '#32323F',
@ -451,7 +448,8 @@ export const GristDark: ThemeColors = {
'tutorials-popup-header-fg': '#FFFFFF', 'tutorials-popup-header-fg': '#FFFFFF',
'tutorials-popup-box-bg': '#57575F', 'tutorials-popup-box-bg': '#57575F',
/* Ace Autocomplete */ /* Ace */
'ace-editor-bg': '#32323F',
'ace-autocomplete-primary-fg': '#EFEFEF', 'ace-autocomplete-primary-fg': '#EFEFEF',
'ace-autocomplete-secondary-fg': '#A4A4A4', 'ace-autocomplete-secondary-fg': '#A4A4A4',
'ace-autocomplete-highlighted-fg': '#FFFFFF', 'ace-autocomplete-highlighted-fg': '#FFFFFF',
@ -490,4 +488,9 @@ export const GristDark: ThemeColors = {
'login-page-bg': '#32323F', 'login-page-bg': '#32323F',
'login-page-backdrop': '#404150', 'login-page-backdrop': '#404150',
'login-page-line': '#57575F', 'login-page-line': '#57575F',
/* Formula Assistant */
'formula-assistant-header-bg': '#262633',
'formula-assistant-border': '#69697D',
'formula-assistant-preformatted-text-bg': '#262633',
}; };

View File

@ -415,9 +415,6 @@ export const GristLight: ThemeColors = {
'cell-bg': 'white', 'cell-bg': 'white',
'cell-zebra-bg': '#F8F8F8', 'cell-zebra-bg': '#F8F8F8',
/* Formula Editor */
'formula-editor-bg': 'white',
/* Charts */ /* Charts */
'chart-fg': '#444', 'chart-fg': '#444',
'chart-bg': '#fff', 'chart-bg': '#fff',
@ -451,7 +448,8 @@ export const GristLight: ThemeColors = {
'tutorials-popup-header-fg': '#FFFFFF', 'tutorials-popup-header-fg': '#FFFFFF',
'tutorials-popup-box-bg': '#F5F5F5', 'tutorials-popup-box-bg': '#F5F5F5',
/* Ace Autocomplete */ /* Ace */
'ace-editor-bg': 'white',
'ace-autocomplete-primary-fg': '#444', 'ace-autocomplete-primary-fg': '#444',
'ace-autocomplete-secondary-fg': '#8F8F8F', 'ace-autocomplete-secondary-fg': '#8F8F8F',
'ace-autocomplete-highlighted-fg': '#000', 'ace-autocomplete-highlighted-fg': '#000',
@ -490,4 +488,9 @@ export const GristLight: ThemeColors = {
'login-page-bg': 'white', 'login-page-bg': 'white',
'login-page-backdrop': '#F5F8FA', 'login-page-backdrop': '#F5F8FA',
'login-page-line': '#F7F7F7', 'login-page-line': '#F7F7F7',
/* Formula Assistant */
'formula-assistant-header-bg': '#F7F7F7',
'formula-assistant-border': '#D9D9D9',
'formula-assistant-preformatted-text-bg': '#F7F7F7',
}; };

View File

@ -116,13 +116,13 @@
"@gristlabs/sqlite3": "5.1.4-grist.8", "@gristlabs/sqlite3": "5.1.4-grist.8",
"@popperjs/core": "2.3.3", "@popperjs/core": "2.3.3",
"accept-language-parser": "1.5.0", "accept-language-parser": "1.5.0",
"ace-builds": "1.23.3",
"async-mutex": "0.2.4", "async-mutex": "0.2.4",
"axios": "0.21.2", "axios": "0.21.2",
"backbone": "1.3.3", "backbone": "1.3.3",
"bootstrap": "3.3.5", "bootstrap": "3.3.5",
"bootstrap-datepicker": "1.9.0", "bootstrap-datepicker": "1.9.0",
"bowser": "2.7.0", "bowser": "2.7.0",
"brace": "0.11.1",
"collect-js-deps": "^0.1.1", "collect-js-deps": "^0.1.1",
"color-convert": "2.0.1", "color-convert": "2.0.1",
"commander": "9.3.0", "commander": "9.3.0",

View File

@ -1262,6 +1262,11 @@ accepts@~1.3.5:
mime-types "~2.1.24" mime-types "~2.1.24"
negotiator "0.6.2" 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: acorn-class-fields@^0.3.7:
version "0.3.7" version "0.3.7"
resolved "https://registry.yarnpkg.com/acorn-class-fields/-/acorn-class-fields-0.3.7.tgz#a35122f3cc6ad2bb33b1857e79215677fcfdd720" 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: dependencies:
balanced-match "^1.0.0" 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: braces@^3.0.2, braces@~3.0.2:
version "3.0.2" version "3.0.2"
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"