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 {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