mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +00:00 
			
		
		
		
	(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:
		
							parent
							
								
									8581492912
								
							
						
					
					
						commit
						ea8a59c5e9
					
				| @ -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); | ||||
|  | ||||
| @ -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; | ||||
| } | ||||
|  | ||||
| @ -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); | ||||
| }; | ||||
| 
 | ||||
|  | ||||
| @ -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<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(); | ||||
|   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; | ||||
|  | ||||
| @ -29,6 +29,7 @@ export function documentCursor(type: 'ns-resize' | 'grabbing'): IDisposable { | ||||
| export function movable<T>(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<T>(options: { | ||||
|         options.onMove(dx, dy, state); | ||||
|       })); | ||||
|       owner.autoDispose(dom.onElem(document, 'mouseup', () => { | ||||
|         options.onEnd?.(); | ||||
|         holder.clear(); | ||||
|       })); | ||||
|       owner.autoDispose(documentCursor('ns-resize')); | ||||
|  | ||||
| @ -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<Theme>; | ||||
| @ -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( | ||||
|  | ||||
| @ -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', | ||||
|  | ||||
| @ -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 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<Theme>; | ||||
|   placeholder: string; | ||||
|   disabled: Observable<boolean>; | ||||
|   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, | ||||
|     })), | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
|  | ||||
| @ -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; | ||||
|  | ||||
| @ -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<void>, | ||||
|     // 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) { | ||||
|  | ||||
| @ -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'); | ||||
|  | ||||
| @ -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<void>, | ||||
|     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); | ||||
|  | ||||
| @ -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 && | ||||
|  | ||||
| @ -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>|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<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(); | ||||
|     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<HTMLDivElement>) { | ||||
|  | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @ -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<boolean>; | ||||
| 
 | ||||
|   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}; | ||||
| `);
 | ||||
|  | ||||
| @ -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 { | ||||
|  | ||||
| @ -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 = { | ||||
|  | ||||
| @ -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<ThemePrefs>; | ||||
|  | ||||
| @ -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', | ||||
| }; | ||||
|  | ||||
| @ -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', | ||||
| }; | ||||
|  | ||||
| @ -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", | ||||
|  | ||||
							
								
								
									
										10
									
								
								yarn.lock
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								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" | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user