mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user