(core) Implement AI Assistant UI V2

Summary:
Implements the latest design of the Formula AI Assistant.

Also switches out brace to the latest build of ace.

Test Plan: Browser tests.

Reviewers: jarek

Reviewed By: jarek

Subscribers: jarek

Differential Revision: https://phab.getgrist.com/D3949
This commit is contained in:
George Gevoian
2023-07-13 10:00:56 -04:00
parent 8581492912
commit ea8a59c5e9
23 changed files with 983 additions and 641 deletions

View File

@@ -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);

View File

@@ -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 &&

View File

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

View File

@@ -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};
`);

View File

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