mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +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 {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);
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
@ -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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
@ -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'));
|
||||||
|
@ -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(
|
||||||
|
@ -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',
|
||||||
|
@ -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,
|
||||||
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
@ -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) {
|
||||||
|
@ -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');
|
||||||
|
@ -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);
|
||||||
|
@ -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 &&
|
||||||
|
@ -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
@ -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};
|
||||||
`);
|
`);
|
||||||
|
@ -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 {
|
||||||
|
@ -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 = {
|
||||||
|
@ -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>;
|
||||||
|
@ -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',
|
||||||
};
|
};
|
||||||
|
@ -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',
|
||||||
};
|
};
|
||||||
|
@ -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",
|
||||||
|
10
yarn.lock
10
yarn.lock
@ -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"
|
||||||
|
Loading…
Reference in New Issue
Block a user