mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Floating formula editor
Summary: Adding a way to detach an editor. Initially only implemented for the formula editor, includes redesign for the AI part. - Initially, the detached editor is tight with the formula assistant and both are behind GRIST_FORMULA_ASSISTANT flag, but this can be relaxed later on, as the detached editor can be used on its own. - Detached editor is only supported in regular fields and on the creator panel. It is not supported yet for conditional styles, due to preview limitations. - Old code for the assistant was removed completely, as it was only a temporary solution, but the AI conversation part was copied to the new one. - Prompting was not modified in this diff, it will be included in the follow-up with more test cases. Test Plan: Added only new tests; existing tests should pass. Reviewers: JakubSerafin Reviewed By: JakubSerafin Differential Revision: https://phab.getgrist.com/D3863
This commit is contained in:
parent
e10067ff78
commit
da323fb741
@ -114,9 +114,9 @@ AceEditor.prototype.enable = function(bool) {
|
||||
* Note: Ace defers to standard behavior when false is returned.
|
||||
*/
|
||||
AceEditor.prototype.attachCommandGroup = function(commandGroup) {
|
||||
_.each(commandGroup.knownKeys, (command, key) => {
|
||||
_.each(commandGroup.knownKeys, (commandName, key) => {
|
||||
this.editor.commands.addCommand({
|
||||
name: command,
|
||||
name: commandName,
|
||||
// We are setting readonly as true to enable all commands
|
||||
// in a readonly mode.
|
||||
// Because FieldEditor in readonly mode will rewire all commands that
|
||||
@ -129,7 +129,7 @@ AceEditor.prototype.attachCommandGroup = function(commandGroup) {
|
||||
},
|
||||
// AceEditor wants a command to return true if it got handled, whereas our command returns
|
||||
// true to avoid stopPropagation/preventDefault, i.e. if it hasn't been handled.
|
||||
exec: () => !commandGroup.commands[command]()
|
||||
exec: () => !commandGroup.commands[commandName]()
|
||||
});
|
||||
});
|
||||
};
|
||||
@ -270,7 +270,7 @@ AceEditor.prototype.resize = function() {
|
||||
// This won't help for zooming (where the same problem occurs but in many more places), but will
|
||||
// help for Windows users who have different pixel ratio.
|
||||
this.editorDom.style.width = size.width ? Math.ceil(size.width) + 'px' : 'auto';
|
||||
this.editorDom.style.height = Math.ceil(size.height) + 'px';
|
||||
this.editorDom.style.height = size.height ? Math.ceil(size.height) + 'px' : 'auto';
|
||||
this.editor.resize();
|
||||
};
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
import {ISuggestionWithValue} from 'app/common/ActiveDocAPI';
|
||||
import {commonUrls} from 'app/common/gristUrls';
|
||||
import * as ace from 'brace';
|
||||
|
||||
export interface ICompletionOptions {
|
||||
@ -265,7 +266,7 @@ function retokenizeAceCompleterRow(rowData: AceSuggestion, tokens: TokenInfo[]):
|
||||
|
||||
// Include into new tokens a special token that will be hidden, but include the link URL. On
|
||||
// click, we find it to know what URL to open.
|
||||
const href = 'https://support.getgrist.com/functions/#' +
|
||||
const href = `${commonUrls.functions}/#` +
|
||||
rowData.funcname.slice(linkStart, linkEnd).toLowerCase();
|
||||
newTokens.push({value: href, type: 'grist_link_hidden'});
|
||||
|
||||
|
@ -49,6 +49,9 @@ const {parsePasteForView} = require("./BaseView2");
|
||||
const {NEW_FILTER_JSON} = require('app/client/models/ColumnFilter');
|
||||
const {CombinedStyle} = require("app/client/models/Styles");
|
||||
const {buildRenameColumn} = require('app/client/ui/ColumnTitle');
|
||||
const {makeT} = require('app/client/lib/localization');
|
||||
|
||||
const t = makeT('GridView');
|
||||
|
||||
// A threshold for interpreting a motionless click as a click rather than a drag.
|
||||
// Anything longer than this time (in milliseconds) should be interpreted as a drag
|
||||
@ -219,6 +222,14 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) {
|
||||
|
||||
// Holds column index that is hovered, works only in full-edit formula mode.
|
||||
this.hoverColumn = ko.observable(-1);
|
||||
|
||||
// Checks if there is active formula editor for a column in this table.
|
||||
this.editingFormula = ko.pureComputed(() => {
|
||||
const isEditing = this.gristDoc.docModel.editingFormula();
|
||||
if (!isEditing) { return false; }
|
||||
return this.viewSection.viewFields().all().some(field => field.editingFormula());
|
||||
});
|
||||
|
||||
// Debounced method to change current hover column, this is needed
|
||||
// as mouse when moved from field to field will switch the hover-column
|
||||
// observable from current index to -1 and then immediately back to current index.
|
||||
@ -226,7 +237,7 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) {
|
||||
// will be discarded.
|
||||
this.changeHover = debounce((index) => {
|
||||
if (this.isDisposed()) { return; }
|
||||
if (this.gristDoc.docModel.editingFormula()) {
|
||||
if (this.editingFormula()) {
|
||||
this.hoverColumn(index);
|
||||
}
|
||||
}, 0);
|
||||
@ -1054,8 +1065,9 @@ GridView.prototype.buildDom = function() {
|
||||
|
||||
let filterTriggerCtl;
|
||||
const isTooltip = ko.pureComputed(() =>
|
||||
self.gristDoc.docModel.editingFormula() &&
|
||||
ko.unwrap(self.hoverColumn) === field._index());
|
||||
self.editingFormula() &&
|
||||
ko.unwrap(self.hoverColumn) === field._index()
|
||||
);
|
||||
return dom(
|
||||
'div.column_name.field',
|
||||
kd.style('--frozen-position', () => ko.unwrap(this.frozenPositions.at(field._index()))),
|
||||
@ -1070,7 +1082,7 @@ GridView.prototype.buildDom = function() {
|
||||
dom.autoDispose(tooltip),
|
||||
dom.autoDispose(isTooltip.subscribe((show) => {
|
||||
if (show) {
|
||||
tooltip.show(`Click to insert $${field.colId.peek()}`);
|
||||
tooltip.show(t(`Click to insert`) + ` $${field.origCol.peek().colId.peek()}`);
|
||||
} else {
|
||||
tooltip.hide();
|
||||
}
|
||||
@ -1316,7 +1328,7 @@ GridView.prototype.buildDom = function() {
|
||||
dom.autoDispose(isSelected),
|
||||
dom.on("mouseenter", () => self.changeHover(field._index())),
|
||||
kd.toggleClass("hover-column", () =>
|
||||
self.gristDoc.docModel.editingFormula() &&
|
||||
self.editingFormula() &&
|
||||
ko.unwrap(self.hoverColumn) === (field._index())),
|
||||
kd.style('width', field.widthPx),
|
||||
//TODO: Ensure that fields in a row resize when
|
||||
@ -1624,7 +1636,7 @@ GridView.prototype.dropCols = function() {
|
||||
// column movement, propose renaming the column.
|
||||
if (Date.now() - this._colClickTime < SHORT_CLICK_IN_MS && oldIndices.length === 1 &&
|
||||
idx === oldIndices[0]) {
|
||||
this.currentEditingColumnIndex(idx);
|
||||
commands.allCommands.renameField.run();
|
||||
}
|
||||
this._colClickTime = 0;
|
||||
this.cellSelector.currentDragType(selector.NONE);
|
||||
|
@ -28,7 +28,7 @@ import {makeT} from 'app/client/lib/localization';
|
||||
import {createSessionObs} from 'app/client/lib/sessionObs';
|
||||
import {setTestState} from 'app/client/lib/testState';
|
||||
import {selectFiles} from 'app/client/lib/uploads';
|
||||
import {reportError} from 'app/client/models/AppModel';
|
||||
import {AppModel, reportError} from 'app/client/models/AppModel';
|
||||
import BaseRowModel from 'app/client/models/BaseRowModel';
|
||||
import DataTableModel from 'app/client/models/DataTableModel';
|
||||
import {DataTableModelWithDiff} from 'app/client/models/DataTableModelWithDiff';
|
||||
@ -205,6 +205,7 @@ export class GristDoc extends DisposableWithEvents {
|
||||
|
||||
constructor(
|
||||
public readonly app: App,
|
||||
public readonly appModel: AppModel,
|
||||
public readonly docComm: DocComm,
|
||||
public readonly docPageModel: DocPageModel,
|
||||
openDocResponse: OpenLocalDocResult,
|
||||
@ -440,13 +441,7 @@ export class GristDoc extends DisposableWithEvents {
|
||||
|
||||
// Command to be manually triggered on cell selection. Moves the cursor to the selected cell.
|
||||
// This is overridden by the formula editor to insert "$col" variables when clicking cells.
|
||||
setCursor(rowModel: BaseRowModel, fieldModel?: ViewFieldRec) {
|
||||
return this.setCursorPos({
|
||||
rowIndex: rowModel?._index() || 0,
|
||||
fieldIndex: fieldModel?._index() || 0,
|
||||
sectionId: fieldModel?.viewSection().getRowId(),
|
||||
});
|
||||
},
|
||||
setCursor: this.onSetCursorPos.bind(this),
|
||||
}, this, true));
|
||||
|
||||
this.listenTo(app.comm, 'docUserAction', this.onDocUserAction);
|
||||
@ -614,6 +609,14 @@ export class GristDoc extends DisposableWithEvents {
|
||||
return Object.assign(pos, viewInstance ? viewInstance.cursor.getCursorPos() : {});
|
||||
}
|
||||
|
||||
public async onSetCursorPos(rowModel: BaseRowModel|undefined, fieldModel?: ViewFieldRec) {
|
||||
return this.setCursorPos({
|
||||
rowIndex: rowModel?._index() || 0,
|
||||
fieldIndex: fieldModel?._index() || 0,
|
||||
sectionId: fieldModel?.viewSection().getRowId(),
|
||||
});
|
||||
}
|
||||
|
||||
public async setCursorPos(cursorPos: CursorPos) {
|
||||
if (cursorPos.sectionId && cursorPos.sectionId !== this.externalSectionId.get()) {
|
||||
const desiredSection: ViewSectionRec = this.docModel.viewSections.getRowModel(cursorPos.sectionId);
|
||||
@ -1149,7 +1152,7 @@ export class GristDoc extends DisposableWithEvents {
|
||||
* Opens up an editor at cursor position
|
||||
* @param input Optional. Cell's initial value
|
||||
*/
|
||||
public async activateEditorAtCursor(options: { init?: string, state?: any}) {
|
||||
public async activateEditorAtCursor(options?: { init?: string, state?: any}) {
|
||||
const view = await this._waitForView();
|
||||
view?.activateEditorAtCursor(options);
|
||||
}
|
||||
|
@ -31,8 +31,8 @@ import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
|
||||
import {byteString} from 'app/common/gutil';
|
||||
import {FetchUrlOptions, UploadResult} from 'app/common/uploads';
|
||||
import {ParseOptions, ParseOptionSchema} from 'app/plugin/FileParserAPI';
|
||||
import {Computed, dom, DomContents, fromKo, Holder, IDisposable, MultiHolder, MutableObsArray, obsArray, Observable,
|
||||
styled} from 'grainjs';
|
||||
import {Computed, Disposable, dom, DomContents, fromKo, Holder, IDisposable,
|
||||
MultiHolder, MutableObsArray, obsArray, Observable, styled} from 'grainjs';
|
||||
import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox';
|
||||
import {ACCESS_DENIED, AUTH_INTERRUPTED, canReadPrivateFiles, getGoogleCodeForReading} from 'app/client/ui/googleAuth';
|
||||
import debounce = require('lodash/debounce');
|
||||
@ -824,6 +824,7 @@ export class Importer extends DisposableWithEvents {
|
||||
editingFormula: field.editingFormula,
|
||||
refElem,
|
||||
editRow,
|
||||
canDetach: false,
|
||||
setupCleanup: this._setupFormulaEditorCleanup.bind(this),
|
||||
onSave: async (column, formula) => {
|
||||
if (formula === column.formula.peek()) { return; }
|
||||
@ -842,7 +843,7 @@ export class Importer extends DisposableWithEvents {
|
||||
* focus.
|
||||
*/
|
||||
private _setupFormulaEditorCleanup(
|
||||
owner: MultiHolder, _doc: GristDoc, editingFormula: ko.Computed<boolean>, _saveEdit: () => Promise<unknown>
|
||||
owner: Disposable, _doc: GristDoc, editingFormula: ko.Computed<boolean>, _saveEdit: () => Promise<unknown>
|
||||
) {
|
||||
const saveEdit = () => _saveEdit().catch(reportError);
|
||||
|
||||
|
@ -108,6 +108,8 @@ export type CommandName =
|
||||
| 'clearSectionLinks'
|
||||
| 'transformUpdate'
|
||||
| 'clearCopySelection'
|
||||
| 'detachEditor'
|
||||
| 'activateAssistant'
|
||||
;
|
||||
|
||||
|
||||
@ -259,6 +261,11 @@ export const groups: CommendGroupDef[] = [{
|
||||
keys: [],
|
||||
desc: 'Shortcut to open video tour from home left panel',
|
||||
},
|
||||
{
|
||||
name: 'activateAssistant',
|
||||
keys: [],
|
||||
desc: 'Activate assistant',
|
||||
},
|
||||
]
|
||||
}, {
|
||||
group: 'Navigation',
|
||||
@ -391,6 +398,10 @@ export const groups: CommendGroupDef[] = [{
|
||||
name: 'fieldEditSave',
|
||||
keys: ['Enter'],
|
||||
desc: 'Finish editing a cell, saving the value'
|
||||
}, {
|
||||
name: 'detachEditor',
|
||||
keys: [''],
|
||||
desc: 'Detach active editor'
|
||||
}, {
|
||||
name: 'fieldEditSaveHere',
|
||||
keys: [],
|
||||
|
@ -8,18 +8,30 @@
|
||||
*/
|
||||
|
||||
import * as Mousetrap from 'app/client/lib/Mousetrap';
|
||||
import { arrayRemove } from 'app/common/gutil';
|
||||
import {arrayRemove, unwrap} from 'app/common/gutil';
|
||||
import dom from 'app/client/lib/dom';
|
||||
import 'app/client/lib/koUtil'; // for subscribeInit
|
||||
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
|
||||
import {CommandDef, CommandName, CommendGroupDef, groups} from 'app/client/components/commandList';
|
||||
|
||||
import {Disposable} from 'grainjs';
|
||||
import {Disposable, Observable} from 'grainjs';
|
||||
import * as _ from 'underscore';
|
||||
import * as ko from 'knockout';
|
||||
|
||||
const G = getBrowserGlobals('window');
|
||||
type BoolLike = boolean|ko.Observable<boolean>|ko.Computed<boolean>;
|
||||
type BoolLike = boolean|ko.Observable<boolean>|ko.Computed<boolean>|Observable<boolean>;
|
||||
|
||||
/**
|
||||
* A helper method that can create a subscription to ko or grains observables.
|
||||
*/
|
||||
function subscribe(value: Exclude<BoolLike, boolean>, fn: (value: boolean) => void) {
|
||||
if (ko.isObservable(value)) {
|
||||
return value.subscribe(fn);
|
||||
} else if (value instanceof Observable) {
|
||||
return value.addListener(fn);
|
||||
} else {
|
||||
throw new Error('Expected an observable');
|
||||
}
|
||||
}
|
||||
|
||||
// Same logic as used by mousetrap to map 'Mod' key to platform-specific key.
|
||||
export const isMac = (typeof navigator !== 'undefined' && navigator &&
|
||||
@ -291,10 +303,11 @@ export class CommandGroup extends Disposable {
|
||||
this.onDispose(this._removeGroup.bind(this));
|
||||
|
||||
// Finally, set the activation status of the command group, subscribing if an observable.
|
||||
if (ko.isObservable(activate)) {
|
||||
this.autoDispose((activate as any).subscribeInit(this.activate, this));
|
||||
} else {
|
||||
this.activate(activate as boolean);
|
||||
if (typeof activate === 'boolean' || activate === undefined) {
|
||||
this.activate(activate ?? false);
|
||||
} else if (activate) {
|
||||
this.autoDispose(subscribe(activate, (val) => this.activate(val)));
|
||||
this.activate(unwrap(activate));
|
||||
}
|
||||
}
|
||||
|
||||
@ -343,8 +356,8 @@ type BoundedMap<T> = { [key in CommandName]?: BoundedFunc<T> };
|
||||
/**
|
||||
* Just a shorthand for CommandGroup.create constructor.
|
||||
*/
|
||||
export function createGroup<T>(commands: BoundedMap<T>, context: T, activate?: BoolLike) {
|
||||
return CommandGroup.create(null, commands, context, activate);
|
||||
export function createGroup<T>(commands: BoundedMap<T>|null, context: T, activate?: BoolLike) {
|
||||
return CommandGroup.create(null, commands ?? {}, context, activate);
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
|
@ -249,7 +249,7 @@ function toggleDisabled(boolValueOrFunc) {
|
||||
exports.toggleDisabled = toggleDisabled;
|
||||
|
||||
/**
|
||||
* Adds a css class named by an observable value. If the value changes, the previous class will be
|
||||
* Adds a css class (one or many) named by an observable value. If the value changes, the previous class will be
|
||||
* removed and the new one added. The value may be empty to avoid adding any class.
|
||||
* Similar to knockout's `css` binding with a dynamic class.
|
||||
* @param {Object} valueOrFunc An observable, a constant, or a function for a computed observable.
|
||||
@ -258,11 +258,15 @@ function cssClass(valueOrFunc) {
|
||||
var prevClass;
|
||||
return makeBinding(valueOrFunc, function(elem, value) {
|
||||
if (prevClass) {
|
||||
elem.classList.remove(prevClass);
|
||||
for(const name of prevClass.split(' ')) {
|
||||
elem.classList.remove(name);
|
||||
}
|
||||
}
|
||||
prevClass = value;
|
||||
if (value) {
|
||||
elem.classList.add(value);
|
||||
for (const name of value.split(' ')) {
|
||||
elem.classList.add(name);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
63
app/client/lib/popupUtils.ts
Normal file
63
app/client/lib/popupUtils.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import {dom, Holder, IDisposable, MultiHolder} from 'grainjs';
|
||||
|
||||
/**
|
||||
* Overrides the cursor style for the entire document.
|
||||
* @returns {Disposable} - a Disposable that restores the cursor style to its original value.
|
||||
*/
|
||||
export function documentCursor(type: 'ns-resize' | 'grabbing'): IDisposable {
|
||||
const cursorStyle: HTMLStyleElement = document.createElement('style');
|
||||
cursorStyle.innerHTML = `*{cursor: ${type}!important;}`;
|
||||
cursorStyle.id = 'cursor-style';
|
||||
document.head.appendChild(cursorStyle);
|
||||
const cursorOwner = {
|
||||
dispose() {
|
||||
if (this.isDisposed()) { return; }
|
||||
document.head.removeChild(cursorStyle);
|
||||
},
|
||||
isDisposed() {
|
||||
return !cursorStyle.isConnected;
|
||||
}
|
||||
};
|
||||
return cursorOwner;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Helper function to create a movable element.
|
||||
* @param options Handlers for the movable element.
|
||||
*/
|
||||
export function movable<T>(options: {
|
||||
onMove: (dx: number, dy: number, state: T) => void,
|
||||
onStart: () => T,
|
||||
}) {
|
||||
return (el: HTMLElement) => {
|
||||
// Remember the initial position of the mouse.
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
dom.onElem(el, 'mousedown', (md) => {
|
||||
// Only handle left mouse button.
|
||||
if (md.button !== 0) { return; }
|
||||
startX = md.clientX;
|
||||
startY = md.clientY;
|
||||
const state = options.onStart();
|
||||
|
||||
// We create a holder first so that we can dispose elements earlier on mouseup, and have a fallback
|
||||
// in case of a situation when the dom is removed before mouseup.
|
||||
const holder = new Holder();
|
||||
const owner = MultiHolder.create(holder);
|
||||
dom.autoDisposeElem(el, holder);
|
||||
|
||||
owner.autoDispose(dom.onElem(document, 'mousemove', (mv) => {
|
||||
const dx = mv.clientX - startX;
|
||||
const dy = mv.clientY - startY;
|
||||
options.onMove(dx, dy, state);
|
||||
}));
|
||||
owner.autoDispose(dom.onElem(document, 'mouseup', () => {
|
||||
holder.clear();
|
||||
}));
|
||||
owner.autoDispose(documentCursor('ns-resize'));
|
||||
md.stopPropagation();
|
||||
md.preventDefault();
|
||||
}, { useCapture: true });
|
||||
};
|
||||
}
|
@ -285,7 +285,7 @@ export class AppModelImpl extends Disposable implements AppModel {
|
||||
this.showNewSiteModal(state.params?.planType);
|
||||
}
|
||||
|
||||
G.window.resetSeenPopups = (seen = false) => {
|
||||
G.window.resetDismissedPopups = (seen = false) => {
|
||||
this.dismissedPopups.set(seen ? DismissedPopup.values : []);
|
||||
this.behavioralPromptsManager.reset();
|
||||
};
|
||||
|
@ -375,7 +375,7 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
|
||||
const comparison = comparisonUrlId ?
|
||||
await this._api.getDocAPI(urlId).compareDoc(comparisonUrlId, { detail: true }) : undefined;
|
||||
|
||||
const gristDoc = gdModule.GristDoc.create(flow, this._appObj, docComm, this, openDocResponse,
|
||||
const gristDoc = gdModule.GristDoc.create(flow, this._appObj, this.appModel, docComm, this, openDocResponse,
|
||||
this.appModel.topAppModel.plugins, {comparison});
|
||||
|
||||
// Move ownership of docComm to GristDoc.
|
||||
|
@ -200,7 +200,11 @@ export interface ChatMessage {
|
||||
/**
|
||||
* The formula returned from the AI. It is only set when the sender is the AI.
|
||||
*/
|
||||
formula?: string;
|
||||
formula?: string|null;
|
||||
/**
|
||||
* Suggested actions returned from the AI.
|
||||
*/
|
||||
action?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -135,9 +135,26 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
|
||||
// CSS class to add to formula cells, incl. to show that we are editing this field's formula.
|
||||
this.formulaCssClass = ko.pureComputed<string|null>(() => {
|
||||
const col = this.column();
|
||||
return this.column().isTransforming() ? "transform_field" :
|
||||
(this.editingFormula() ? "formula_field_edit" :
|
||||
(col.isFormula() && col.formula() !== "" ? "formula_field" : null));
|
||||
|
||||
// If the current column is transforming, assign the CSS class "transform_field"
|
||||
if (col.isTransforming()) {
|
||||
if ( col.origCol().isFormula() && col.origCol().formula() !== "") {
|
||||
return "transform_field formula_field";
|
||||
}
|
||||
return "transform_field";
|
||||
}
|
||||
// If the column is not transforming but a formula is being edited
|
||||
else if (this.editingFormula()) {
|
||||
return "formula_field_edit";
|
||||
}
|
||||
// If a formula exists and it is not empty
|
||||
else if (col.isFormula() && col.formula() !== "") {
|
||||
return "formula_field";
|
||||
}
|
||||
// If none of the above conditions are met, assign null
|
||||
else {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
// The fields's display column
|
||||
|
@ -3,7 +3,6 @@ import {CursorPos} from 'app/client/components/Cursor';
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {BEHAVIOR, ColumnRec} from 'app/client/models/entities/ColumnRec';
|
||||
import {buildHighlightedCode, cssCodeBlock} from 'app/client/ui/CodeHighlight';
|
||||
import {buildAiButton} from 'app/client/ui/FormulaAssistance';
|
||||
import {GristTooltips} from 'app/client/ui/GristTooltips';
|
||||
import {cssBlockedCursor, cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
|
||||
import {withInfoTooltip} from 'app/client/ui/tooltips';
|
||||
@ -13,7 +12,6 @@ import {testId, theme} from 'app/client/ui2018/cssVars';
|
||||
import {textInput} from 'app/client/ui2018/editableLabel';
|
||||
import {cssIconButton, icon} from 'app/client/ui2018/icons';
|
||||
import {IconName} from 'app/client/ui2018/IconList';
|
||||
import {GRIST_FORMULA_ASSISTANT} from 'app/client/models/features';
|
||||
import {selectMenu, selectOption, selectTitle} from 'app/client/ui2018/menus';
|
||||
import {createFormulaErrorObs, cssError} from 'app/client/widgets/FormulaEditor';
|
||||
import {sanitizeIdent} from 'app/common/gutil';
|
||||
@ -134,6 +132,8 @@ export function buildFormulaConfig(
|
||||
|
||||
// Helper function to clear temporary state (will be called when column changes or formula editor closes)
|
||||
const clearState = () => bundleChanges(() => {
|
||||
// For a detached editor, we may have already been disposed when user switched page.
|
||||
if (owner.isDisposed()) { return; }
|
||||
maybeFormula.set(false);
|
||||
maybeTrigger.set(false);
|
||||
formulaField = null;
|
||||
@ -277,6 +277,8 @@ export function buildFormulaConfig(
|
||||
|
||||
// Converts column to formula column.
|
||||
const onSaveConvertToFormula = async (column: ColumnRec, formula: string) => {
|
||||
// For a detached editor, we may have already been disposed when user switched page.
|
||||
if (owner.isDisposed()) { return; }
|
||||
// For non formula column, we will not convert it to formula column when expression is empty,
|
||||
// as it means we were trying to convert data column to formula column, but changed our mind.
|
||||
const notBlank = Boolean(formula);
|
||||
@ -362,7 +364,6 @@ export function buildFormulaConfig(
|
||||
]),
|
||||
formulaBuilder(onSaveConvertToFormula),
|
||||
cssEmptySeparator(),
|
||||
dom.maybe(GRIST_FORMULA_ASSISTANT(), () => cssRow(buildAiButton(gristDoc, origColumn))),
|
||||
cssRow(textButton(
|
||||
t("Convert to trigger formula"),
|
||||
dom.on("click", convertFormulaToTrigger),
|
||||
|
@ -1,7 +1,9 @@
|
||||
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 {icon} from 'app/client/ui2018/icons';
|
||||
import {Disposable, dom, DomArg, DomContents, IDisposable, makeTestId, Observable, styled} from 'grainjs';
|
||||
import {Disposable, dom, DomContents, DomElementArg,
|
||||
IDisposable, makeTestId, Observable, styled} from 'grainjs';
|
||||
|
||||
const POPUP_INITIAL_PADDING_PX = 16;
|
||||
const POPUP_MIN_HEIGHT = 300;
|
||||
@ -15,9 +17,11 @@ export interface PopupOptions {
|
||||
content?: () => DomContents;
|
||||
onClose?: () => void;
|
||||
closeButton?: boolean;
|
||||
closeButtonHover?: () => DomContents;
|
||||
autoHeight?: boolean;
|
||||
/** Defaults to false. */
|
||||
stopClickPropagationOnMove?: boolean;
|
||||
args?: DomElementArg[];
|
||||
}
|
||||
|
||||
export class FloatingPopup extends Disposable {
|
||||
@ -34,7 +38,7 @@ export class FloatingPopup extends Disposable {
|
||||
private _resize = false;
|
||||
private _cursorGrab: IDisposable|null = null;
|
||||
|
||||
constructor(protected _options: PopupOptions = {}, private _args: DomArg[] = []) {
|
||||
constructor(protected _options: PopupOptions = {}) {
|
||||
super();
|
||||
|
||||
if (_options.stopClickPropagationOnMove){
|
||||
@ -98,7 +102,7 @@ export class FloatingPopup extends Disposable {
|
||||
}
|
||||
|
||||
protected _buildArgs(): any {
|
||||
return this._args;
|
||||
return this._options.args ?? [];
|
||||
}
|
||||
|
||||
private _rememberPosition() {
|
||||
@ -272,12 +276,12 @@ export class FloatingPopup extends Disposable {
|
||||
// Copy buttons on the left side of the header, to automatically
|
||||
// center the title.
|
||||
cssPopupButtons(
|
||||
!this._options.closeButton ? null : cssPopupHeaderButton(
|
||||
icon('CrossSmall'),
|
||||
),
|
||||
cssPopupHeaderButton(
|
||||
icon('Maximize')
|
||||
),
|
||||
!this._options.closeButton ? null : cssPopupHeaderButton(
|
||||
icon('CrossBig'),
|
||||
),
|
||||
dom.style('visibility', 'hidden'),
|
||||
),
|
||||
cssPopupTitle(
|
||||
@ -285,19 +289,20 @@ export class FloatingPopup extends Disposable {
|
||||
testId('title'),
|
||||
),
|
||||
cssPopupButtons(
|
||||
!this._options.closeButton ? null : cssPopupHeaderButton(
|
||||
icon('CrossSmall'),
|
||||
dom.on('click', () => {
|
||||
this._options.onClose?.() ?? this._closePopup();
|
||||
}),
|
||||
testId('close'),
|
||||
),
|
||||
this._popupMinimizeButtonElement = cssPopupHeaderButton(
|
||||
isMinimized ? icon('Maximize'): icon('Minimize'),
|
||||
hoverTooltip(isMinimized ? 'Maximize' : 'Minimize', {key: 'docTutorialTooltip'}),
|
||||
dom.on('click', () => this._minimizeOrMaximize()),
|
||||
testId('minimize-maximize'),
|
||||
),
|
||||
!this._options.closeButton ? null : cssPopupHeaderButton(
|
||||
icon('CrossBig'),
|
||||
dom.on('click', () => {
|
||||
this._options.onClose?.() ?? this._closePopup();
|
||||
}),
|
||||
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()),
|
||||
dom.on('touchstart', ev => ev.stopPropagation()),
|
||||
@ -345,20 +350,7 @@ export class FloatingPopup extends Disposable {
|
||||
private _forceCursor() {
|
||||
this._cursorGrab?.dispose();
|
||||
const type = this._resize ? 'ns-resize' : 'grabbing';
|
||||
const cursorStyle: HTMLStyleElement = document.createElement('style');
|
||||
cursorStyle.innerHTML = `*{cursor: ${type}!important;}`;
|
||||
cursorStyle.id = 'cursor-style';
|
||||
document.head.appendChild(cursorStyle);
|
||||
const cursorOwner = {
|
||||
dispose() {
|
||||
if (this.isDisposed()) { return; }
|
||||
document.head.removeChild(cursorStyle);
|
||||
},
|
||||
isDisposed() {
|
||||
return !cursorStyle.isConnected;
|
||||
}
|
||||
};
|
||||
this._cursorGrab = cursorOwner;
|
||||
this._cursorGrab = documentCursor(type);
|
||||
}
|
||||
}
|
||||
|
||||
@ -372,7 +364,7 @@ const POPUP_HEIGHT = `min(var(--height), calc(100% - (2 * ${POPUP_INITIAL_PADDIN
|
||||
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)))`;
|
||||
|
||||
const cssPopup = styled('div', `
|
||||
const cssPopup = styled('div.floating-popup', `
|
||||
position: fixed;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
@ -1,605 +0,0 @@
|
||||
import * as AceEditor from 'app/client/components/AceEditor';
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {ColumnRec} from 'app/client/models/entities/ColumnRec';
|
||||
import {buildHighlightedCode} from 'app/client/ui/CodeHighlight';
|
||||
import {FloatingPopup} from 'app/client/ui/FloatingPopup';
|
||||
import {createUserImage} from 'app/client/ui/UserImage';
|
||||
import {basicButton, primaryButton, textButton} from 'app/client/ui2018/buttons';
|
||||
import {theme} from 'app/client/ui2018/cssVars';
|
||||
import {cssTextInput, rawTextInput} from 'app/client/ui2018/editableLabel';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {AssistanceResponse, AssistanceState} from 'app/common/AssistancePrompts';
|
||||
import {Disposable, dom, makeTestId, MultiHolder, obsArray, Observable, styled} from 'grainjs';
|
||||
import noop from 'lodash/noop';
|
||||
|
||||
const testId = makeTestId('test-assistant-');
|
||||
|
||||
export function buildAiButton(grist: GristDoc, column: ColumnRec) {
|
||||
column = grist.docModel.columns.createFloatingRowModel(column.origColRef);
|
||||
return textButton(
|
||||
dom.autoDispose(column),
|
||||
'Open AI assistant',
|
||||
testId('open-button'),
|
||||
dom.on('click', () => openAIAssistant(grist, column))
|
||||
);
|
||||
}
|
||||
|
||||
interface Context {
|
||||
grist: GristDoc;
|
||||
column: ColumnRec;
|
||||
}
|
||||
|
||||
function buildFormula(owner: MultiHolder, props: Context) {
|
||||
const { grist, column } = props;
|
||||
const formula = Observable.create(owner, column.formula.peek());
|
||||
const calcSize = (fullDom: HTMLElement, size: any) => {
|
||||
return {
|
||||
width: fullDom.clientWidth,
|
||||
height: size.height,
|
||||
};
|
||||
};
|
||||
const editor = AceEditor.create({
|
||||
column,
|
||||
gristDoc: grist,
|
||||
calcSize,
|
||||
editorState: formula,
|
||||
});
|
||||
owner.autoDispose(editor);
|
||||
const buildDom = () => {
|
||||
return cssFormulaWrapper(
|
||||
dom.cls('formula_field_sidepane'),
|
||||
editor.buildDom((aceObj: any) => {
|
||||
aceObj.setFontSize(11);
|
||||
aceObj.setHighlightActiveLine(false);
|
||||
aceObj.getSession().setUseWrapMode(false);
|
||||
aceObj.renderer.setPadding(0);
|
||||
setTimeout(() => editor.editor.focus());
|
||||
editor.setValue(formula.get());
|
||||
}),
|
||||
);
|
||||
};
|
||||
return {
|
||||
buildDom,
|
||||
set(value: string) {
|
||||
editor.setValue(value);
|
||||
},
|
||||
get() {
|
||||
return editor.getValue();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildControls(
|
||||
owner: MultiHolder,
|
||||
props: Context & {
|
||||
currentFormula: () => string;
|
||||
savedClicked: () => void,
|
||||
robotClicked: () => void,
|
||||
}
|
||||
) {
|
||||
|
||||
const hasHistory = props.column.chatHistory.peek().get().messages.length > 0;
|
||||
|
||||
// State variables, to show various parts of the UI.
|
||||
const saveButtonVisible = Observable.create(owner, true);
|
||||
const previewButtonVisible = Observable.create(owner, true);
|
||||
const robotWellVisible = Observable.create(owner, !hasHistory);
|
||||
const robotButtonVisible = Observable.create(owner, !hasHistory && !robotWellVisible.get());
|
||||
const helpWellVisible = Observable.create(owner, !hasHistory);
|
||||
|
||||
|
||||
|
||||
// Click handlers
|
||||
const saveClicked = async () => {
|
||||
await preview();
|
||||
props.savedClicked();
|
||||
};
|
||||
const previewClicked = async () => await preview();
|
||||
const robotClicked = () => props.robotClicked();
|
||||
|
||||
// Public API
|
||||
const preview = async () => {
|
||||
// Currently we don't have a preview, so just save.
|
||||
const formula = props.currentFormula();
|
||||
const tableId = props.column.table.peek().tableId.peek();
|
||||
const colId = props.column.colId.peek();
|
||||
props.grist.docData.sendAction([
|
||||
'ModifyColumn',
|
||||
tableId,
|
||||
colId,
|
||||
{ formula, isFormula: true },
|
||||
]).catch(reportError);
|
||||
};
|
||||
|
||||
const buildWells = () => {
|
||||
return cssContainer(
|
||||
cssWell(
|
||||
'Grist’s AI Formula Assistance. Need help? Our AI assistant can help. ',
|
||||
textButton('Ask the bot.', dom.on('click', robotClicked)),
|
||||
dom.show(robotWellVisible)
|
||||
),
|
||||
cssWell(
|
||||
'Formula Help. See our Function List and Formula Cheat Sheet, or visit our Community for more help.',
|
||||
dom.show(helpWellVisible)
|
||||
),
|
||||
dom.show(use => use(robotWellVisible) || use(helpWellVisible))
|
||||
);
|
||||
};
|
||||
const buildDom = () => {
|
||||
return [
|
||||
cssButtonsWrapper(
|
||||
cssButtons(
|
||||
primaryButton('Save', dom.show(saveButtonVisible), dom.on('click', saveClicked)),
|
||||
basicButton('Preview', dom.show(previewButtonVisible), dom.on('click', previewClicked)),
|
||||
textButton('🤖', dom.show(robotButtonVisible), dom.on('click', robotClicked)),
|
||||
dom.show(
|
||||
use => use(previewButtonVisible) || use(saveButtonVisible) || use(robotButtonVisible)
|
||||
)
|
||||
)
|
||||
),
|
||||
buildWells(),
|
||||
];
|
||||
};
|
||||
return {
|
||||
buildDom,
|
||||
preview,
|
||||
hideHelp() {
|
||||
robotWellVisible.set(false);
|
||||
helpWellVisible.set(false);
|
||||
},
|
||||
hideRobot() {
|
||||
robotButtonVisible.set(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function buildChat(owner: Disposable, context: Context & { formulaClicked: (formula?: string) => void }) {
|
||||
const { grist, column } = context;
|
||||
|
||||
const history = owner.autoDispose(obsArray(column.chatHistory.peek().get().messages));
|
||||
const hasHistory = history.get().length > 0;
|
||||
const enabled = Observable.create(owner, hasHistory);
|
||||
const introVisible = Observable.create(owner, !hasHistory);
|
||||
owner.autoDispose(history.addListener((cur) => {
|
||||
const chatHistory = column.chatHistory.peek();
|
||||
chatHistory.set({...chatHistory.get(), messages: [...cur]});
|
||||
}));
|
||||
|
||||
const submit = async (regenerate: boolean = false) => {
|
||||
// Send most recent question, and send back any conversation
|
||||
// state we have been asked to track.
|
||||
const chatHistory = column.chatHistory.peek().get();
|
||||
const messages = chatHistory.messages.filter(msg => msg.sender === 'user');
|
||||
const description = messages[messages.length - 1]?.message || '';
|
||||
console.debug('description', {description});
|
||||
const {reply, suggestedActions, state} = await askAI(grist, {
|
||||
column, description, state: chatHistory.state,
|
||||
regenerate,
|
||||
});
|
||||
console.debug('suggestedActions', {suggestedActions, reply});
|
||||
const firstAction = suggestedActions[0] as any;
|
||||
// Add the formula to the history.
|
||||
const formula = firstAction ? firstAction[3].formula as string : undefined;
|
||||
// Add to history
|
||||
history.push({
|
||||
message: formula || reply || '(no reply)',
|
||||
sender: 'ai',
|
||||
formula
|
||||
});
|
||||
// If back-end is capable of conversation, keep its state.
|
||||
if (state) {
|
||||
const chatHistoryNew = column.chatHistory.peek();
|
||||
const value = chatHistoryNew.get();
|
||||
value.state = state;
|
||||
chatHistoryNew.set(value);
|
||||
}
|
||||
return formula;
|
||||
};
|
||||
|
||||
const chatEnterClicked = async (val: string) => {
|
||||
if (!val) { return; }
|
||||
// Hide intro.
|
||||
introVisible.set(false);
|
||||
// Add question to the history.
|
||||
history.push({
|
||||
message: val,
|
||||
sender: 'user',
|
||||
});
|
||||
// Submit all questions to the AI.
|
||||
context.formulaClicked(await submit());
|
||||
};
|
||||
|
||||
const regenerateClick = async () => {
|
||||
// Remove the last AI response from the history.
|
||||
history.pop();
|
||||
// And submit again.
|
||||
context.formulaClicked(await submit(true));
|
||||
};
|
||||
|
||||
const newChat = () => {
|
||||
// Clear the history.
|
||||
history.set([]);
|
||||
column.chatHistory.peek().set({messages: []});
|
||||
// Show intro.
|
||||
introVisible.set(true);
|
||||
};
|
||||
|
||||
const userPrompt = Observable.create(owner, '');
|
||||
|
||||
const userImage = () => {
|
||||
const user = grist.app.topAppModel.appObs.get()?.currentUser || null;
|
||||
if (user) {
|
||||
return (createUserImage(user, 'medium'));
|
||||
} else {
|
||||
// TODO: this will not happen, as this should be only for logged in users.
|
||||
return (dom('div', ''));
|
||||
}
|
||||
};
|
||||
|
||||
const buildHistory = () => {
|
||||
return cssVBox(
|
||||
dom.forEach(history, entry => {
|
||||
if (entry.sender === 'user') {
|
||||
return cssMessage(
|
||||
cssAvatar(userImage()),
|
||||
dom.text(entry.message),
|
||||
);
|
||||
} else {
|
||||
return cssAiMessage(
|
||||
cssAvatar(cssAiImage()),
|
||||
buildHighlightedCode(entry.message, {
|
||||
gristTheme: grist.currentTheme,
|
||||
maxLines: 10,
|
||||
}, cssCodeStyles.cls('')),
|
||||
cssCopyIconWrapper(
|
||||
icon('Copy', dom.on('click', () => context.formulaClicked(entry.message))),
|
||||
)
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const buildIntro = () => {
|
||||
return cssVBox(
|
||||
dom.cls(cssTopGreenBorder.className),
|
||||
dom.cls(cssTypography.className),
|
||||
dom.style('flex-grow', '1'),
|
||||
dom.style('min-height', '0'),
|
||||
dom.style('overflow-y', 'auto'),
|
||||
dom.maybe(introVisible, () =>
|
||||
cssHContainer(
|
||||
dom.style('margin-bottom', '10px'),
|
||||
cssVBox(
|
||||
dom('h4', 'Grist’s AI Assistance'),
|
||||
dom('h5', 'Tips'),
|
||||
cssWell(
|
||||
'“Example prompt” Some instructions for how to draft a prompt. A link to even more examples in support.'
|
||||
),
|
||||
cssWell(
|
||||
'Example Values. Instruction about entering example values in the column, maybe with an image?'
|
||||
),
|
||||
dom('h5', 'Capabilities'),
|
||||
cssWell(
|
||||
'Formula Assistance Only. Python code. Spreadsheet functions? May sometimes get it wrong. '
|
||||
),
|
||||
cssWell('Conversational. Remembers what was said and allows follow-up corrections.'),
|
||||
dom('h5', 'Data'),
|
||||
cssWell(
|
||||
'Data Usage. Something about how we can see prompts to improve the feature and product, but cannot see doc.'
|
||||
),
|
||||
cssWell(
|
||||
'Data Sharing. Something about OpenAI, what’s being transmitted. How does it expose doc data?'
|
||||
),
|
||||
textButton('Learn more', dom.style('align-self', 'flex-start'))
|
||||
)
|
||||
)
|
||||
),
|
||||
dom.maybe(
|
||||
use => !use(introVisible),
|
||||
() => buildHistory()
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const buildButtons = () => {
|
||||
// We will show buttons only if we have a history.
|
||||
return dom.maybe(use => use(history).length > 0, () => cssVContainer(
|
||||
cssHBox(
|
||||
cssPlainButton(icon('Script'), 'New Chat', dom.on('click', newChat)),
|
||||
cssPlainButton(icon('Revert'), 'Regenerate', dom.on('click', regenerateClick), dom.style('margin-left', '8px')),
|
||||
),
|
||||
dom.style('padding-bottom', '0'),
|
||||
dom.style('padding-top', '12px'),
|
||||
));
|
||||
};
|
||||
|
||||
const buildInput = () => {
|
||||
return cssHContainer(
|
||||
dom.cls(cssTopBorder.className),
|
||||
dom.cls(cssVSpace.className),
|
||||
cssInputWrapper(
|
||||
dom.cls(cssTextInput.className),
|
||||
dom.cls(cssTypography.className),
|
||||
rawTextInput(userPrompt, chatEnterClicked, noop),
|
||||
icon('FieldAny')
|
||||
),
|
||||
buildButtons()
|
||||
);
|
||||
};
|
||||
|
||||
const buildDom = () => {
|
||||
return dom.maybe(enabled, () => cssVFullBox(
|
||||
buildIntro(),
|
||||
cssSpacer(),
|
||||
buildInput(),
|
||||
dom.style('overflow', 'hidden'),
|
||||
dom.style('flex-grow', '1')
|
||||
));
|
||||
};
|
||||
|
||||
return {
|
||||
buildDom,
|
||||
show() {
|
||||
enabled.set(true);
|
||||
introVisible.set(true);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds and opens up a Formula Popup with an AI assistant.
|
||||
*/
|
||||
function openAIAssistant(grist: GristDoc, column: ColumnRec) {
|
||||
const owner = MultiHolder.create(null);
|
||||
const props: Context = { grist, column };
|
||||
|
||||
// Build up all components, and wire up them to each other.
|
||||
|
||||
// First is the formula editor displayed in the upper part of the popup.
|
||||
const formulaEditor = buildFormula(owner, props);
|
||||
|
||||
// Next are the buttons in the middle. It has a Save, Preview, and Robot button, and probably some wells
|
||||
// with tips or other buttons.
|
||||
const controls = buildControls(owner, {
|
||||
...props,
|
||||
// Pass a formula accessor, it is used to get the current formula and apply or preview it.
|
||||
currentFormula: () => formulaEditor.get(),
|
||||
// Event or saving, we listen to it to close the popup.
|
||||
savedClicked() {
|
||||
grist.formulaPopup.clear();
|
||||
},
|
||||
// Handler for robot icon click. We hide the robot icon and the help, and show the chat area.
|
||||
robotClicked() {
|
||||
chat.show();
|
||||
controls.hideHelp();
|
||||
controls.hideRobot();
|
||||
}
|
||||
});
|
||||
|
||||
// Now, the chat area. It has a history of previous questions, and a prompt for the user to ask a new
|
||||
// question.
|
||||
const chat = buildChat(owner, {...props,
|
||||
// When a formula is clicked (or just was returned from the AI), we set it in the formula editor and hit
|
||||
// the preview button.
|
||||
formulaClicked: (formula?: string) => {
|
||||
if (formula) {
|
||||
formulaEditor.set(formula);
|
||||
controls.preview().catch(reportError);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const header = `${column.table.peek().tableNameDef.peek()}.${column.label.peek()}`;
|
||||
const popup = FloatingPopup.create(null, {
|
||||
title: () => header,
|
||||
content: () => [
|
||||
formulaEditor.buildDom(),
|
||||
controls.buildDom(),
|
||||
chat.buildDom(),
|
||||
],
|
||||
onClose: () => grist.formulaPopup.clear(),
|
||||
closeButton: true,
|
||||
autoHeight: true,
|
||||
});
|
||||
|
||||
popup.autoDispose(owner);
|
||||
popup.showPopup();
|
||||
|
||||
// Add this popup to the main holder (and dispose the previous one).
|
||||
grist.formulaPopup.autoDispose(popup);
|
||||
}
|
||||
|
||||
async function askAI(grist: GristDoc, options: {
|
||||
column: ColumnRec,
|
||||
description: string,
|
||||
regenerate?: boolean,
|
||||
state?: AssistanceState
|
||||
}): Promise<AssistanceResponse> {
|
||||
const {column, description, state, regenerate} = options;
|
||||
const tableId = column.table.peek().tableId.peek();
|
||||
const colId = column.colId.peek();
|
||||
try {
|
||||
const result = await grist.docComm.getAssistance({
|
||||
context: {type: 'formula', tableId, colId},
|
||||
text: description,
|
||||
state,
|
||||
regenerate,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
reportError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const cssVBox = styled('div', `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`);
|
||||
|
||||
const cssFormulaWrapper = styled('div.formula_field_edit.formula_editor', `
|
||||
position: relative;
|
||||
padding: 5px 0 5px 24px;
|
||||
flex: auto;
|
||||
overflow-y: auto;
|
||||
`);
|
||||
|
||||
const cssVFullBox = styled(cssVBox, `
|
||||
flex-grow: 1;
|
||||
`);
|
||||
|
||||
const cssHBox = styled('div', `
|
||||
display: flex;
|
||||
`);
|
||||
|
||||
const cssVSpace = styled('div', `
|
||||
padding-top: 18px;
|
||||
padding-bottom: 18px;
|
||||
`);
|
||||
|
||||
const cssHContainer = styled('div', `
|
||||
padding-left: 18px;
|
||||
padding-right: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`);
|
||||
|
||||
const cssButtonsWrapper = styled(cssHContainer, `
|
||||
padding-top: 0;
|
||||
background-color: ${theme.formulaEditorBg};
|
||||
`);
|
||||
|
||||
const cssVContainer = styled('div', `
|
||||
padding-top: 18px;
|
||||
padding-bottom: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`);
|
||||
|
||||
const cssContainer = styled('div', `
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`);
|
||||
|
||||
const cssSpacer = styled('div', `
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
`);
|
||||
|
||||
const cssButtons = styled('div', `
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 8px 0px;
|
||||
`);
|
||||
|
||||
const cssWell = styled('div', `
|
||||
padding: 8px;
|
||||
color: ${theme.inputFg};
|
||||
border-radius: 4px;
|
||||
background-color: ${theme.rightPanelBg};
|
||||
& + & {
|
||||
margin-top: 8px;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssMessage = styled('div', `
|
||||
display: grid;
|
||||
grid-template-columns: 60px 1fr;
|
||||
padding-right: 54px;
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
`);
|
||||
|
||||
const cssAiMessage = styled('div', `
|
||||
display: grid;
|
||||
grid-template-columns: 60px 1fr 54px;
|
||||
padding-top: 20px;
|
||||
padding-bottom: 20px;
|
||||
background: #D9D9D94f;
|
||||
|
||||
`);
|
||||
|
||||
const cssCodeStyles = styled('div', `
|
||||
background: #E3E3E3;
|
||||
border: none;
|
||||
& .ace-chrome {
|
||||
background: #E3E3E3;
|
||||
border: none;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssAvatar = styled('div', `
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
`);
|
||||
|
||||
const cssAiImage = styled('div', `
|
||||
flex: none;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
border-radius: 50%;
|
||||
background-color: white;
|
||||
background-image: var(--icon-GristLogo);
|
||||
background-size: 22px 22px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
`);
|
||||
|
||||
const cssTopGreenBorder = styled('div', `
|
||||
border-top: 1px solid ${theme.accentBorder};
|
||||
`);
|
||||
|
||||
const cssTypography = styled('div', `
|
||||
color: ${theme.inputFg};
|
||||
`);
|
||||
|
||||
const cssTopBorder = styled('div', `
|
||||
border-top: 1px solid ${theme.inputBorder};
|
||||
`);
|
||||
|
||||
const cssCopyIconWrapper = styled('div', `
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
cursor: pointer;
|
||||
.${cssAiMessage.className}:hover & {
|
||||
display: flex;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssInputWrapper = styled('div', `
|
||||
display: flex;
|
||||
background-color: ${theme.mainPanelBg};
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-right: 8px !important;
|
||||
--icon-color: ${theme.controlSecondaryFg};
|
||||
&:hover, &:focus-within {
|
||||
--icon-color: ${theme.accentIcon};
|
||||
}
|
||||
& > input {
|
||||
outline: none;
|
||||
padding: 0px;
|
||||
align-self: stretch;
|
||||
flex: 1;
|
||||
border: none;
|
||||
background-color: inherit;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssPlainButton = styled(basicButton, `
|
||||
border-color: ${theme.inputBorder};
|
||||
color: ${theme.controlSecondaryFg};
|
||||
--icon-color: ${theme.controlSecondaryFg};
|
||||
display: inline-flex;
|
||||
gap: 10px;
|
||||
align-items: flex-end;
|
||||
border-radius: 3px;
|
||||
padding: 5px 7px;
|
||||
padding-right: 13px;
|
||||
`);
|
@ -86,6 +86,7 @@ function resize(el: HTMLTextAreaElement) {
|
||||
export function autoGrow(text: Observable<string>) {
|
||||
return (el: HTMLTextAreaElement) => {
|
||||
el.addEventListener('input', () => resize(el));
|
||||
dom.autoDisposeElem(el, text.addListener(() => resize(el)));
|
||||
setTimeout(() => resize(el), 10);
|
||||
dom.autoDisposeElem(el, text.addListener(val => {
|
||||
// Changes to the text are not reflected by the input event (witch is used by the autoGrow)
|
||||
|
@ -266,7 +266,11 @@ export function setHoverTooltip(
|
||||
*/
|
||||
export function tooltipCloseButton(ctl: ITooltipControl): HTMLElement {
|
||||
return cssTooltipCloseButton(icon('CrossSmall'),
|
||||
dom.on('click', () => ctl.close()),
|
||||
dom.on('mousedown', (ev) =>{
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
ctl.close();
|
||||
}),
|
||||
testId('tooltip-close'),
|
||||
);
|
||||
}
|
||||
|
@ -113,6 +113,7 @@ export type IconName = "ChartArea" |
|
||||
"ResizePanel" |
|
||||
"Revert" |
|
||||
"RightAlign" |
|
||||
"Robot" |
|
||||
"Script" |
|
||||
"Search" |
|
||||
"Settings" |
|
||||
@ -254,6 +255,7 @@ export const IconList: IconName[] = ["ChartArea",
|
||||
"ResizePanel",
|
||||
"Revert",
|
||||
"RightAlign",
|
||||
"Robot",
|
||||
"Script",
|
||||
"Search",
|
||||
"Settings",
|
||||
|
@ -200,6 +200,7 @@ export class ConditionalStyle extends Disposable {
|
||||
editRow: vsi?.moveEditRowToCursor(),
|
||||
refElem,
|
||||
setupCleanup: setupEditorCleanup,
|
||||
canDetach: false,
|
||||
});
|
||||
// Add editor to document holder - this will prevent multiple formula editor instances.
|
||||
this._gristDoc.fieldEditorHolder.autoDispose(editorHolder);
|
||||
|
@ -4,6 +4,8 @@ import { FormulaTransform } from 'app/client/components/FormulaTransform';
|
||||
import { GristDoc } from 'app/client/components/GristDoc';
|
||||
import { addColTypeSuffix } from 'app/client/components/TypeConversion';
|
||||
import { TypeTransform } from 'app/client/components/TypeTransform';
|
||||
import { FloatingEditor } from 'app/client/widgets/FloatingEditor';
|
||||
import { UnsavedChange } from 'app/client/components/UnsavedChanges';
|
||||
import dom from 'app/client/lib/dom';
|
||||
import { KoArray } from 'app/client/lib/koArray';
|
||||
import * as kd from 'app/client/lib/koDom';
|
||||
@ -23,7 +25,7 @@ import { theme } from 'app/client/ui2018/cssVars';
|
||||
import { IOptionFull, menu, select } from 'app/client/ui2018/menus';
|
||||
import { DiffBox } from 'app/client/widgets/DiffBox';
|
||||
import { buildErrorDom } from 'app/client/widgets/ErrorDom';
|
||||
import { FieldEditor, saveWithoutEditor, setupEditorCleanup } from 'app/client/widgets/FieldEditor';
|
||||
import { FieldEditor, saveWithoutEditor } from 'app/client/widgets/FieldEditor';
|
||||
import { CellDiscussionPopup, EmptyCell } from 'app/client/widgets/DiscussionEditor';
|
||||
import { openFormulaEditor } from 'app/client/widgets/FormulaEditor';
|
||||
import { NewAbstractWidget } from 'app/client/widgets/NewAbstractWidget';
|
||||
@ -34,7 +36,7 @@ import * as gristTypes from 'app/common/gristTypes';
|
||||
import { getReferencedTableId, isFullReferencingType } from 'app/common/gristTypes';
|
||||
import { CellValue } from 'app/plugin/GristData';
|
||||
import { Computed, Disposable, fromKo, dom as grainjsDom,
|
||||
Holder, IDisposable, makeTestId, MultiHolder, styled, toKo } from 'grainjs';
|
||||
makeTestId, MultiHolder, Observable, styled, toKo } from 'grainjs';
|
||||
import * as ko from 'knockout';
|
||||
import * as _ from 'underscore';
|
||||
|
||||
@ -100,19 +102,19 @@ export class FieldBuilder extends Disposable {
|
||||
private readonly _rowMap: Map<DataRowModel, Element>;
|
||||
private readonly _isTransformingFormula: ko.Computed<boolean>;
|
||||
private readonly _isTransformingType: ko.Computed<boolean>;
|
||||
private readonly _fieldEditorHolder: Holder<IDisposable>;
|
||||
private readonly _widgetCons: ko.Computed<{create: (...args: any[]) => NewAbstractWidget}>;
|
||||
private readonly _docModel: DocModel;
|
||||
private readonly _readonly: Computed<boolean>;
|
||||
private readonly _comments: ko.Computed<boolean>;
|
||||
private readonly _showRefConfigPopup: ko.Observable<boolean>;
|
||||
private readonly _isEditorActive = Observable.create(this, false);
|
||||
|
||||
public constructor(public readonly gristDoc: GristDoc, public readonly field: ViewFieldRec,
|
||||
private _cursor: Cursor, private _options: { isPreview?: boolean } = {}) {
|
||||
super();
|
||||
|
||||
this._docModel = gristDoc.docModel;
|
||||
this.origColumn = field.column();
|
||||
this.origColumn = field.origCol();
|
||||
this.options = field.widgetOptionsJson;
|
||||
this._comments = ko.pureComputed(() => toKo(ko, COMMENTS())());
|
||||
|
||||
@ -183,10 +185,6 @@ export class FieldBuilder extends Disposable {
|
||||
(this.columnTransform instanceof TypeTransform);
|
||||
}));
|
||||
|
||||
// This holds a single FieldEditor. When a new FieldEditor is created (on edit), it replaces the
|
||||
// previous one if any.
|
||||
this._fieldEditorHolder = Holder.create(this);
|
||||
|
||||
// Map from rowModel to cell dom for the field to which this fieldBuilder applies.
|
||||
this._rowMap = new Map();
|
||||
|
||||
@ -580,7 +578,7 @@ export class FieldBuilder extends Disposable {
|
||||
if (this.isDisposed()) { return null; } // Work around JS errors during field removal.
|
||||
const value = row.cells[this.field.colId()];
|
||||
const cell = value && value();
|
||||
if ((value) && this._isRightType()(cell, this.options) || row._isAddRow.peek()) {
|
||||
if ((value as any) && this._isRightType()(cell, this.options) || row._isAddRow.peek()) {
|
||||
return this.widgetImpl();
|
||||
} else if (gristTypes.isVersions(cell)) {
|
||||
return this.diffImpl;
|
||||
@ -677,39 +675,40 @@ export class FieldBuilder extends Disposable {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear previous editor. Some caveats:
|
||||
// - The floating editor has an async cleanup routine, but it promises that it won't affect as.
|
||||
// - All other editors should be synchronous, so this line will remove all opened editors.
|
||||
const holder = this.gristDoc.fieldEditorHolder;
|
||||
// If the global editor is from our own field, we will dispose it immediately, otherwise we will
|
||||
// rely on the clipboard to dispose it by grabbing focus.
|
||||
const clearOwn = () => this.isEditorActive() && holder.clear();
|
||||
|
||||
// If this is censored value, don't open up the editor, unless it is a formula field.
|
||||
const cell = editRow.cells[this.field.colId()];
|
||||
const value = cell && cell();
|
||||
if (gristTypes.isCensored(value) && !this.origColumn.isFormula.peek()) {
|
||||
this._fieldEditorHolder.clear();
|
||||
return;
|
||||
return clearOwn();
|
||||
}
|
||||
|
||||
const editorCtor: typeof NewBaseEditor =
|
||||
UserTypeImpl.getEditorConstructor(this.options().widget, this._readOnlyPureType());
|
||||
// constructor may be null for a read-only non-formula field, though not today.
|
||||
if (!editorCtor) {
|
||||
// Actually, we only expect buildEditorDom() to be called when isEditorActive() is false (i.e.
|
||||
// _fieldEditorHolder is already clear), but clear here explicitly for clarity.
|
||||
this._fieldEditorHolder.clear();
|
||||
return;
|
||||
return clearOwn();
|
||||
}
|
||||
|
||||
// if editor doesn't support readonly mode, don't show it
|
||||
if (this._readonly.get() && editorCtor.supportsReadonly && !editorCtor.supportsReadonly()) {
|
||||
this._fieldEditorHolder.clear();
|
||||
return;
|
||||
return clearOwn();
|
||||
}
|
||||
|
||||
if (!this._readonly.get() && saveWithoutEditor(editorCtor, editRow, this.field, options.init)) {
|
||||
this._fieldEditorHolder.clear();
|
||||
return;
|
||||
return clearOwn();
|
||||
}
|
||||
|
||||
const cellElem = this._rowMap.get(mainRowModel)!;
|
||||
|
||||
// The editor may dispose itself; the Holder will know to clear itself in this case.
|
||||
const fieldEditor = FieldEditor.create(this._fieldEditorHolder, {
|
||||
const fieldEditor = FieldEditor.create(holder, {
|
||||
gristDoc: this.gristDoc,
|
||||
field: this.field,
|
||||
cursor: this._cursor,
|
||||
@ -720,15 +719,13 @@ export class FieldBuilder extends Disposable {
|
||||
startVal: this._readonly.get() ? undefined : options.init, // don't start with initial value
|
||||
readonly: this._readonly.get() // readonly for editor will not be observable
|
||||
});
|
||||
|
||||
// Put the FieldEditor into a holder in GristDoc too. This way any existing FieldEditor (perhaps
|
||||
// for another field, or for another BaseView) will get disposed at this time. The reason to
|
||||
// still maintain a Holder in this FieldBuilder is mainly to match older behavior; changing that
|
||||
// will entail a number of other tweaks related to the order of creating and disposal.
|
||||
this.gristDoc.fieldEditorHolder.autoDispose(fieldEditor);
|
||||
this._isEditorActive.set(true);
|
||||
|
||||
// expose the active editor in a grist doc as an observable
|
||||
fieldEditor.onDispose(() => this.gristDoc.activeEditor.set(null));
|
||||
fieldEditor.onDispose(() => {
|
||||
this._isEditorActive.set(false);
|
||||
this.gristDoc.activeEditor.set(null);
|
||||
});
|
||||
this.gristDoc.activeEditor.set(fieldEditor);
|
||||
}
|
||||
|
||||
@ -742,11 +739,12 @@ export class FieldBuilder extends Disposable {
|
||||
if (editRow._isAddRow.peek() || this._readonly.get()) {
|
||||
return;
|
||||
}
|
||||
const holder = this.gristDoc.fieldEditorHolder;
|
||||
|
||||
const cell = editRow.cells[this.field.colId()];
|
||||
const value = cell && cell();
|
||||
if (gristTypes.isCensored(value)) {
|
||||
this._fieldEditorHolder.clear();
|
||||
holder.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -770,7 +768,8 @@ export class FieldBuilder extends Disposable {
|
||||
}
|
||||
|
||||
public isEditorActive() {
|
||||
return !this._fieldEditorHolder.isEmpty();
|
||||
const holder = this.gristDoc.fieldEditorHolder;
|
||||
return !holder.isEmpty() && this._isEditorActive.get();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -782,19 +781,74 @@ export class FieldBuilder extends Disposable {
|
||||
editValue?: string,
|
||||
onSave?: (column: ColumnRec, formula: string) => Promise<void>,
|
||||
onCancel?: () => void) {
|
||||
const editorHolder = openFormulaEditor({
|
||||
// Remember position when the popup was opened.
|
||||
const position = this.gristDoc.cursorPosition.get();
|
||||
|
||||
// Create a controller for the floating editor. It is primarily responsible for moving the editor
|
||||
// dom from the place where it was rendered to the popup (and moving it back).
|
||||
const floatController = {
|
||||
attach: async (content: HTMLElement) => {
|
||||
// If we haven't change page and the element is still in the DOM, move the editor to the
|
||||
// back to where it was rendered. It still has it's content, so no need to dispose it.
|
||||
if (refElem.isConnected) {
|
||||
formulaEditor.attach(refElem);
|
||||
} else {
|
||||
// Else, we will navigate to the position we left off, dispose the editor and the content.
|
||||
formulaEditor.dispose();
|
||||
grainjsDom.domDispose(content);
|
||||
await this.gristDoc.recursiveMoveToCursorPos(position!, true);
|
||||
}
|
||||
},
|
||||
detach() {
|
||||
return formulaEditor.detach();
|
||||
},
|
||||
autoDispose(el: Disposable) {
|
||||
return formulaEditor.autoDispose(el);
|
||||
},
|
||||
dispose() {
|
||||
formulaEditor.dispose();
|
||||
}
|
||||
};
|
||||
|
||||
// Create a custom cleanup method, that won't destroy us when we loose focus while being detached.
|
||||
function setupEditorCleanup(
|
||||
owner: MultiHolder, gristDoc: GristDoc,
|
||||
editingFormula: ko.Computed<boolean>, _saveEdit: () => Promise<unknown>
|
||||
) {
|
||||
// Just override the behavior on focus lost.
|
||||
const saveOnFocus = () => floatingExtension.active.get() ? void 0 : _saveEdit().catch(reportError);
|
||||
UnsavedChange.create(owner, async () => { await saveOnFocus(); });
|
||||
gristDoc.app.on('clipboard_focus', saveOnFocus);
|
||||
owner.onDispose(() => {
|
||||
gristDoc.app.off('clipboard_focus', saveOnFocus);
|
||||
editingFormula(false);
|
||||
});
|
||||
}
|
||||
|
||||
// Get the field model from metatables, as the one provided by the caller might be some floating one, that
|
||||
// will change when user navigates around.
|
||||
const field = this.gristDoc.docModel.viewFields.getRowModel(this.field.getRowId());
|
||||
|
||||
// Finally create the editor passing only the field, which will enable detachable flavor of formula editor.
|
||||
const formulaEditor = openFormulaEditor({
|
||||
gristDoc: this.gristDoc,
|
||||
column: this.field.column(),
|
||||
field,
|
||||
editingFormula: this.field.editingFormula,
|
||||
setupCleanup: setupEditorCleanup,
|
||||
editRow,
|
||||
refElem,
|
||||
editValue,
|
||||
canDetach: true,
|
||||
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);
|
||||
|
||||
// Add editor to document holder - this will prevent multiple formula editor instances.
|
||||
this.gristDoc.fieldEditorHolder.autoDispose(editorHolder);
|
||||
this.gristDoc.fieldEditorHolder.autoDispose(formulaEditor);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
import * as commands from 'app/client/components/commands';
|
||||
import {Cursor} from 'app/client/components/Cursor';
|
||||
import {Cursor, CursorPos} from 'app/client/components/Cursor';
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {UnsavedChange} from 'app/client/components/UnsavedChanges';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
@ -12,9 +12,10 @@ import {IEditorCommandGroup, NewBaseEditor} from 'app/client/widgets/NewBaseEdit
|
||||
import {asyncOnce} from "app/common/AsyncCreate";
|
||||
import {CellValue} from "app/common/DocActions";
|
||||
import * as gutil from 'app/common/gutil';
|
||||
import {Disposable, Emitter, Holder, MultiHolder} from 'grainjs';
|
||||
import isEqual = require('lodash/isEqual');
|
||||
import {CellPosition} from "app/client/components/CellPosition";
|
||||
import {FloatingEditor} from 'app/client/widgets/FloatingEditor';
|
||||
import isEqual = require('lodash/isEqual');
|
||||
import {Disposable, dom, Emitter, Holder, MultiHolder, Observable} from 'grainjs';
|
||||
|
||||
type IEditorConstructor = typeof NewBaseEditor;
|
||||
|
||||
@ -63,6 +64,7 @@ export class FieldEditor extends Disposable {
|
||||
public readonly saveEmitter = this.autoDispose(new Emitter());
|
||||
public readonly cancelEmitter = this.autoDispose(new Emitter());
|
||||
public readonly changeEmitter = this.autoDispose(new Emitter());
|
||||
public floatingEditor: FloatingEditor;
|
||||
|
||||
private _gristDoc: GristDoc;
|
||||
private _field: ViewFieldRec;
|
||||
@ -76,6 +78,8 @@ export class FieldEditor extends Disposable {
|
||||
private _editorHasChanged = false;
|
||||
private _isFormula = false;
|
||||
private _readonly = false;
|
||||
private _detached = Observable.create(this, false);
|
||||
private _detachedAt: CursorPos|null = null;
|
||||
|
||||
constructor(options: {
|
||||
gristDoc: GristDoc,
|
||||
@ -154,6 +158,9 @@ 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);
|
||||
|
||||
if (offerToMakeFormula) {
|
||||
this._offerToMakeFormula();
|
||||
}
|
||||
@ -162,9 +169,16 @@ export class FieldEditor extends Disposable {
|
||||
// when user or server refreshes the browser
|
||||
this._gristDoc.editorMonitor.monitorEditor(this);
|
||||
|
||||
// For detached editor, we don't need to cleanup anything.
|
||||
// It will be cleanuped automatically.
|
||||
const onCleanup = async () => {
|
||||
if (this._detached.get()) { return; }
|
||||
await this._saveEdit();
|
||||
};
|
||||
|
||||
// for readonly field we don't need to do anything special
|
||||
if (!options.readonly) {
|
||||
setupEditorCleanup(this, this._gristDoc, this._field.editingFormula, this._saveEdit);
|
||||
setupEditorCleanup(this, this._gristDoc, this._field.editingFormula, onCleanup);
|
||||
} else {
|
||||
setupReadonlyEditorCleanup(this, this._gristDoc, this._field, () => this._cancelEdit());
|
||||
}
|
||||
@ -190,7 +204,13 @@ export class FieldEditor extends Disposable {
|
||||
cellValue = cellCurrentValue;
|
||||
}
|
||||
|
||||
const error = getFormulaError(this._gristDoc, this._editRow, column);
|
||||
const errorHolder = new MultiHolder();
|
||||
|
||||
const error = getFormulaError(errorHolder, {
|
||||
gristDoc: this._gristDoc,
|
||||
editRow: this._editRow,
|
||||
field: this._field
|
||||
});
|
||||
|
||||
// For readonly mode use the default behavior of Formula Editor
|
||||
// TODO: cleanup this flag - it gets modified in too many places
|
||||
@ -198,9 +218,11 @@ export class FieldEditor extends Disposable {
|
||||
// Enter formula-editing mode (e.g. click-on-column inserts its ID) only if we are opening the
|
||||
// editor by typing into it (and overriding previous formula). In other cases (e.g. double-click),
|
||||
// we defer this mode until the user types something.
|
||||
this._field.editingFormula(this._isFormula && editValue !== undefined);
|
||||
const active = this._isFormula && editValue !== undefined;
|
||||
this._field.editingFormula(active);
|
||||
}
|
||||
|
||||
this._detached.set(false);
|
||||
this._editorHasChanged = false;
|
||||
// Replace the item in the Holder with a new one, disposing the previous one.
|
||||
const editor = this._editorHolder.autoDispose(editorCtor.create({
|
||||
@ -214,10 +236,13 @@ export class FieldEditor extends Disposable {
|
||||
editValue,
|
||||
cursorPos,
|
||||
state,
|
||||
canDetach: true,
|
||||
commands: this._editCommands,
|
||||
readonly : this._readonly
|
||||
}));
|
||||
|
||||
editor.autoDispose(errorHolder);
|
||||
|
||||
// if editor supports live changes, connect it to the change emitter
|
||||
if (editor.editorState) {
|
||||
editor.autoDispose(editor.editorState.addListener((currentState) => {
|
||||
@ -235,6 +260,28 @@ export class FieldEditor extends Disposable {
|
||||
editor.attach(this._cellElem);
|
||||
}
|
||||
|
||||
public detach() {
|
||||
this._detached.set(true);
|
||||
this._detachedAt = this._gristDoc.cursorPosition.get()!;
|
||||
return this._editorHolder.get()!.detach()!;
|
||||
}
|
||||
|
||||
public async attach(content: HTMLElement) {
|
||||
// If we are disconnected from the dom (maybe page was changed or something), we can't
|
||||
// simply attach the editor back, we need to rebuild it.
|
||||
if (!this._cellElem.isConnected) {
|
||||
dom.domDispose(content);
|
||||
if (await this._gristDoc.recursiveMoveToCursorPos(this._detachedAt!, true)) {
|
||||
await this._gristDoc.activateEditorAtCursor();
|
||||
}
|
||||
this.dispose();
|
||||
return;
|
||||
}
|
||||
this._detached.set(false);
|
||||
this._editorHolder.get()?.attach(this._cellElem);
|
||||
this._field.viewSection.peek().hasFocus(true);
|
||||
}
|
||||
|
||||
public getDom() {
|
||||
return this._editorHolder.get()?.getDom();
|
||||
}
|
||||
@ -242,7 +289,7 @@ export class FieldEditor extends Disposable {
|
||||
// calculate current cell's absolute position
|
||||
public cellPosition() {
|
||||
const rowId = this._editRow.getRowId();
|
||||
const colRef = this._field.colRef.peek();
|
||||
const colRef = this._field.column.peek().origColRef.peek();
|
||||
const sectionId = this._field.viewSection.peek().id.peek();
|
||||
const position = {
|
||||
rowId,
|
||||
@ -344,7 +391,7 @@ export class FieldEditor extends Disposable {
|
||||
col.updateColValues({isFormula, formula}),
|
||||
// If we're saving a non-empty formula, then also add an empty record to the table
|
||||
// so that the formula calculation is visible to the user.
|
||||
(this._editRow._isAddRow.peek() && formula !== "" ?
|
||||
(!this._detached.get() && this._editRow._isAddRow.peek() && formula !== "" ?
|
||||
this._editRow.updateColValues({}) : undefined),
|
||||
]));
|
||||
}
|
||||
|
129
app/client/widgets/FloatingEditor.ts
Normal file
129
app/client/widgets/FloatingEditor.ts
Normal file
@ -0,0 +1,129 @@
|
||||
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 {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';
|
||||
|
||||
export interface IFloatingOwner extends IDisposableOwner {
|
||||
detach(): HTMLElement;
|
||||
attach(content: HTMLElement): Promise<void>|void;
|
||||
}
|
||||
|
||||
const testId = makeTestId('test-floating-editor-');
|
||||
|
||||
export class FloatingEditor extends Disposable {
|
||||
|
||||
public active = Observable.create<boolean>(this, false);
|
||||
|
||||
constructor(private _fieldEditor: IFloatingOwner, private _gristDoc: GristDoc) {
|
||||
super();
|
||||
this.autoDispose(commands.createGroup({
|
||||
detachEditor: this.createPopup.bind(this),
|
||||
}, this, true));
|
||||
}
|
||||
|
||||
public createPopup() {
|
||||
const editor = this._fieldEditor;
|
||||
|
||||
const popupOwner = Holder.create(editor);
|
||||
const tempOwner = new MultiHolder();
|
||||
try {
|
||||
// Create a layer to grab the focus, when we will move the editor to the popup. Otherwise the focus
|
||||
// will be moved to the clipboard which can destroy us (as it will be treated as a clickaway). So here
|
||||
// we are kind of simulating always focused editor (even if it is not in the dom for a brief moment).
|
||||
FocusLayer.create(tempOwner, { defaultFocusElem: document.activeElement as any});
|
||||
|
||||
// Take some data from gristDoc to create a title.
|
||||
const cursor = this._gristDoc.cursorPosition.get()!;
|
||||
const vs = this._gristDoc.docModel.viewSections.getRowModel(cursor.sectionId!);
|
||||
const table = vs.tableId.peek();
|
||||
const field = vs.viewFields.peek().at(cursor.fieldIndex!)!;
|
||||
const title = `${table}.${field.label.peek()}`;
|
||||
|
||||
let content: HTMLElement;
|
||||
// Now create the popup. It will be owned by the editor itself.
|
||||
const popup = FloatingPopup.create(popupOwner, {
|
||||
content: () => (content = editor.detach()), // this will be called immediately, and will move some dom between
|
||||
// existing editor and the popup. We need to save it, so we can
|
||||
// detach it on close.
|
||||
title: () => title, // We are not reactive yet
|
||||
closeButton: true, // Show the close button with a hover
|
||||
closeButtonHover: () => 'Return to cell',
|
||||
onClose: async () => {
|
||||
const layer = FocusLayer.create(null, { defaultFocusElem: document.activeElement as any});
|
||||
try {
|
||||
detachNode(content);
|
||||
popupOwner.dispose();
|
||||
await editor.attach(content);
|
||||
} finally {
|
||||
layer.dispose();
|
||||
}
|
||||
},
|
||||
args: [testId('popup')]
|
||||
});
|
||||
// Set a public flag that we are active.
|
||||
this.active.set(true);
|
||||
popup.onDispose(() => {
|
||||
this.active.set(false);
|
||||
});
|
||||
|
||||
// Show the popup with the editor.
|
||||
popup.showPopup();
|
||||
} finally {
|
||||
// Dispose the focus layer, we only needed it for the time when the dom was moved between parents.
|
||||
tempOwner.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createDetachedIcon(...args: IDomArgs<HTMLDivElement>) {
|
||||
return cssResizeIconWrapper(
|
||||
cssSmallIcon('Maximize'),
|
||||
dom.on('click', (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
commands.allCommands.detachEditor.run();
|
||||
}),
|
||||
dom.on('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}),
|
||||
testId('detach-button'),
|
||||
...args
|
||||
);
|
||||
}
|
||||
|
||||
const cssSmallIcon = styled(icon, `
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
`);
|
||||
|
||||
const cssResizeIconWrapper = styled('div', `
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
top: -20px;
|
||||
line-height: 0px;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
--icon-color: ${theme.cellBg};
|
||||
background: var(--grist-theme-control-primary-bg, var(--grist-primary-fg));
|
||||
height: 20px;
|
||||
width: 21px;
|
||||
--icon-color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 0px;
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
&:hover {
|
||||
background: var(--grist-theme-control-primary-hover-bg, var(--grist-primary-fg-hover))
|
||||
}
|
||||
& > div {
|
||||
transition: background .05s ease-in-out;
|
||||
}
|
||||
`);
|
1101
app/client/widgets/FormulaAssistant.ts
Normal file
1101
app/client/widgets/FormulaAssistant.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,22 +1,26 @@
|
||||
import * as AceEditor from 'app/client/components/AceEditor';
|
||||
import {createGroup} from 'app/client/components/commands';
|
||||
import {CommandName} from 'app/client/components/commandList';
|
||||
import * as commands from 'app/client/components/commands';
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {DataRowModel} from 'app/client/models/DataRowModel';
|
||||
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 {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 {NewBaseEditor, Options} from 'app/client/widgets/NewBaseEditor';
|
||||
import {undef} from 'app/common/gutil';
|
||||
import {Computed, Disposable, dom, MultiHolder, Observable, styled, subscribe} from 'grainjs';
|
||||
import {isRaisedException} from "app/common/gristTypes";
|
||||
import {decodeObject, RaisedException} from "app/plugin/objtypes";
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {ColumnRec} from 'app/client/models/DocModel';
|
||||
import {asyncOnce} from 'app/common/AsyncCreate';
|
||||
import {reportError} from 'app/client/models/errors';
|
||||
import {CellValue} from 'app/common/DocActions';
|
||||
import {isRaisedException} from 'app/common/gristTypes';
|
||||
import {undef} from 'app/common/gutil';
|
||||
import {decodeObject, RaisedException} from 'app/plugin/objtypes';
|
||||
import {Computed, Disposable, dom, Holder, MultiHolder, Observable, styled, subscribe} from 'grainjs';
|
||||
import debounce = require('lodash/debounce');
|
||||
|
||||
// How wide to expand the FormulaEditor when an error is shown in it.
|
||||
@ -25,8 +29,10 @@ const t = makeT('FormulaEditor');
|
||||
|
||||
export interface IFormulaEditorOptions extends Options {
|
||||
cssClass?: string;
|
||||
editingFormula: ko.Computed<boolean>,
|
||||
column: ColumnRec,
|
||||
editingFormula: ko.Computed<boolean>;
|
||||
column: ColumnRec;
|
||||
field?: ViewFieldRec;
|
||||
canDetach?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@ -42,10 +48,13 @@ export interface IFormulaEditorOptions extends Options {
|
||||
* should save the value on `blur` event.
|
||||
*/
|
||||
export class FormulaEditor extends NewBaseEditor {
|
||||
public isDetached = Observable.create(this, false);
|
||||
protected options: IFormulaEditorOptions;
|
||||
|
||||
private _formulaEditor: any;
|
||||
private _commandGroup: any;
|
||||
private _dom: HTMLElement;
|
||||
private _editorPlacement!: EditorPlacement;
|
||||
private _placementHolder = Holder.create(this);
|
||||
|
||||
constructor(options: IFormulaEditorOptions) {
|
||||
super(options);
|
||||
@ -67,19 +76,50 @@ export class FormulaEditor extends NewBaseEditor {
|
||||
readonly: options.readonly
|
||||
});
|
||||
|
||||
const allCommands = !options.readonly
|
||||
? Object.assign({ setCursor: this._onSetCursor }, options.commands)
|
||||
// for readonly mode don't grab cursor when clicked away - just move the cursor
|
||||
: options.commands;
|
||||
this._commandGroup = this.autoDispose(createGroup(allCommands, this, editingFormula));
|
||||
|
||||
// For editable editor we will grab the cursor when we are in the formula editing mode.
|
||||
const cursorCommands = options.readonly ? {} : { setCursor: this._onSetCursor };
|
||||
const isActive = Computed.create(this, use => Boolean(use(editingFormula)));
|
||||
const commandGroup = this.autoDispose(commands.createGroup(cursorCommands, this, isActive));
|
||||
|
||||
// We will create a group of editor commands right away.
|
||||
const editorGroup = this.autoDispose(commands.createGroup({
|
||||
...options.commands,
|
||||
}, this, true));
|
||||
|
||||
// Merge those two groups into one.
|
||||
const aceCommands: any = {
|
||||
knownKeys: {...commandGroup.knownKeys, ...editorGroup.knownKeys},
|
||||
commands: {...commandGroup.commands, ...editorGroup.commands},
|
||||
};
|
||||
|
||||
// Tab, Shift + Tab, Enter should be handled by the editor itself when we are in the detached mode.
|
||||
// We will create disabled group, but will push those commands to the editor directly.
|
||||
const passThrough = (name: CommandName) => () => {
|
||||
if (this.isDetached.get()) {
|
||||
// For detached editor, just leave the default behavior.
|
||||
return true;
|
||||
}
|
||||
// Else invoke regular command.
|
||||
commands.allCommands[name]?.run();
|
||||
return false;
|
||||
};
|
||||
const detachedCommands = this.autoDispose(commands.createGroup({
|
||||
nextField: passThrough('nextField'),
|
||||
prevField: passThrough('prevField'),
|
||||
fieldEditSave: passThrough('fieldEditSave'),
|
||||
}, this, false /* don't activate, we're just borrowing constructor */));
|
||||
|
||||
Object.assign(aceCommands.knownKeys, detachedCommands.knownKeys);
|
||||
Object.assign(aceCommands.commands, detachedCommands.commands);
|
||||
|
||||
const hideErrDetails = Observable.create(this, true);
|
||||
const raisedException = Computed.create(this, use => {
|
||||
if (!options.formulaError) {
|
||||
if (!options.formulaError || !use(options.formulaError)) {
|
||||
return null;
|
||||
}
|
||||
const error = isRaisedException(use(options.formulaError)) ?
|
||||
decodeObject(use(options.formulaError)) as RaisedException:
|
||||
const error = isRaisedException(use(options.formulaError)!) ?
|
||||
decodeObject(use(options.formulaError)!) as RaisedException:
|
||||
new RaisedException(["Unknown error"]);
|
||||
return error;
|
||||
});
|
||||
@ -98,10 +138,13 @@ export class FormulaEditor extends NewBaseEditor {
|
||||
|
||||
// Once the exception details are available, update the sizing. The extra delay is to allow
|
||||
// the DOM to update before resizing.
|
||||
this.autoDispose(errorDetails.addListener(() => setTimeout(() => this._formulaEditor.resize(), 0)));
|
||||
this.autoDispose(errorDetails.addListener(() => setTimeout(this.resize.bind(this), 0)));
|
||||
|
||||
const canDetach = GRIST_FORMULA_ASSISTANT().get() && options.canDetach && !options.readonly;
|
||||
|
||||
this.autoDispose(this._formulaEditor);
|
||||
this._dom = dom('div.default_editor.formula_editor_wrapper',
|
||||
this._dom = cssFormulaEditor(
|
||||
buildRobotIcon(),
|
||||
// switch border shadow
|
||||
dom.cls("readonly_editor", options.readonly),
|
||||
createMobileButtons(options.commands),
|
||||
@ -109,9 +152,28 @@ export class FormulaEditor extends NewBaseEditor {
|
||||
|
||||
// This shouldn't be needed, but needed for tests.
|
||||
dom.on('mousedown', (ev) => {
|
||||
// If we are detached, allow user to click and select error text.
|
||||
if (this.isDetached.get()) {
|
||||
// If the focus is already in this editor, don't steal it. This is needed for detached editor with
|
||||
// some input elements (mainly the AI assistant).
|
||||
const inInput = document.activeElement instanceof HTMLInputElement
|
||||
|| document.activeElement instanceof HTMLTextAreaElement;
|
||||
if (inInput && this._dom.contains(document.activeElement)) {
|
||||
return;
|
||||
}
|
||||
// Allow clicking the error message.
|
||||
if (ev.target instanceof HTMLElement && (
|
||||
ev.target.classList.contains('error_msg') ||
|
||||
ev.target.classList.contains('error_details_inner')
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
ev.preventDefault();
|
||||
this._formulaEditor.getEditor().focus();
|
||||
this.focus();
|
||||
}),
|
||||
canDetach ? createDetachedIcon(dom.hide(this.isDetached)) : null,
|
||||
cssFormulaEditor.cls('-detached', this.isDetached),
|
||||
dom('div.formula_editor.formula_field_edit', testId('formula-editor'),
|
||||
this._formulaEditor.buildDom((aceObj: any) => {
|
||||
aceObj.setFontSize(11);
|
||||
@ -121,7 +183,7 @@ export class FormulaEditor extends NewBaseEditor {
|
||||
const val = initialValue;
|
||||
const pos = Math.min(options.cursorPos, val.length);
|
||||
this._formulaEditor.setValue(val, pos);
|
||||
this._formulaEditor.attachCommandGroup(this._commandGroup);
|
||||
this._formulaEditor.attachCommandGroup(aceCommands);
|
||||
|
||||
// enable formula editing if state was passed
|
||||
if (options.state || options.readonly) {
|
||||
@ -132,19 +194,32 @@ export class FormulaEditor extends NewBaseEditor {
|
||||
aceObj.gotoLine(0, 0); // By moving, ace editor won't highlight anything
|
||||
}
|
||||
// This catches any change to the value including e.g. via backspace or paste.
|
||||
aceObj.once("change", () => editingFormula?.(true));
|
||||
aceObj.once("change", () => {
|
||||
editingFormula?.(true);
|
||||
});
|
||||
})
|
||||
),
|
||||
(options.formulaError ? [
|
||||
dom.maybe(options.formulaError, () => [
|
||||
dom('div.error_msg', testId('formula-error-msg'),
|
||||
dom.on('click', () => {
|
||||
if (this.isDetached.get()) { return; }
|
||||
if (errorDetails.get()){
|
||||
hideErrDetails.set(!hideErrDetails.get());
|
||||
this._formulaEditor.resize();
|
||||
}
|
||||
}),
|
||||
dom.maybe(errorDetails, () =>
|
||||
dom.domComputed(hideErrDetails, (hide) => cssCollapseIcon(hide ? 'Expand' : 'Collapse'))
|
||||
dom.domComputed(hideErrDetails, (hide) => cssCollapseIcon(
|
||||
hide ? 'Expand' : 'Collapse',
|
||||
testId('formula-error-expand'),
|
||||
dom.on('click', () => {
|
||||
if (!this.isDetached.get()) { return; }
|
||||
if (errorDetails.get()){
|
||||
hideErrDetails.set(!hideErrDetails.get());
|
||||
this._formulaEditor.resize();
|
||||
}
|
||||
})
|
||||
))
|
||||
),
|
||||
dom.text(errorText),
|
||||
),
|
||||
@ -156,24 +231,37 @@ export class FormulaEditor extends NewBaseEditor {
|
||||
testId('formula-error-details'),
|
||||
)
|
||||
)
|
||||
] : null
|
||||
)
|
||||
]),
|
||||
dom.maybe(this.isDetached, () => {
|
||||
return dom.create(FormulaAssistant, {
|
||||
column: this.options.column,
|
||||
field: this.options.field,
|
||||
gristDoc: this.options.gristDoc,
|
||||
editor: this,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public attach(cellElem: Element): void {
|
||||
this._editorPlacement = EditorPlacement.create(this, this._dom, cellElem, {margins: getButtonMargins()});
|
||||
this.isDetached.set(false);
|
||||
this._editorPlacement = EditorPlacement.create(
|
||||
this._placementHolder, this._dom, cellElem, {margins: getButtonMargins()});
|
||||
// Reposition the editor if needed for external reasons (in practice, window resize).
|
||||
this.autoDispose(this._editorPlacement.onReposition.addListener(
|
||||
this._formulaEditor.resize, this._formulaEditor));
|
||||
this.autoDispose(this._editorPlacement.onReposition.addListener(this._formulaEditor.resize, this._formulaEditor));
|
||||
this._formulaEditor.onAttach();
|
||||
this._formulaEditor.editor.focus();
|
||||
this._formulaEditor.resize();
|
||||
this.focus();
|
||||
}
|
||||
|
||||
public getDom(): HTMLElement {
|
||||
return this._dom;
|
||||
}
|
||||
|
||||
public setFormula(formula: string) {
|
||||
this._formulaEditor.setValue(formula);
|
||||
}
|
||||
|
||||
public getCellValue() {
|
||||
const value = this._formulaEditor.getValue();
|
||||
// Strip the leading "=" sign, if any, in case users think it should start the formula body (as
|
||||
@ -190,14 +278,47 @@ export class FormulaEditor extends NewBaseEditor {
|
||||
return aceObj.getSession().getDocument().positionToIndex(aceObj.getCursorPosition());
|
||||
}
|
||||
|
||||
public focus() {
|
||||
if (this.isDisposed()) { return; }
|
||||
this._formulaEditor.getEditor().focus();
|
||||
}
|
||||
|
||||
public resize() {
|
||||
if (this.isDisposed()) { return; }
|
||||
this._formulaEditor.resize();
|
||||
}
|
||||
|
||||
public detach() {
|
||||
// Remove the element from the dom (to prevent any autodispose) from happening.
|
||||
this._dom.parentNode?.removeChild(this._dom);
|
||||
// First mark that we are detached, to show the buttons,
|
||||
// and halt the autosizing mechanism.
|
||||
this.isDetached.set(true);
|
||||
// Finally, destroy the normal inline placement helper.
|
||||
this._placementHolder.clear();
|
||||
// We are going in the full formula edit mode right away.
|
||||
this.options.editingFormula(true);
|
||||
// Set the focus in timeout, as the dom is added after this function.
|
||||
setTimeout(() => !this.isDisposed() && this._formulaEditor.resize(), 0);
|
||||
// Return the dom, it will be moved to the floating editor.
|
||||
return this._dom;
|
||||
}
|
||||
|
||||
private _calcSize(elem: HTMLElement, desiredElemSize: ISize) {
|
||||
if (this.isDetached.get()) {
|
||||
// If we are detached, we will stop autosizing.
|
||||
return {
|
||||
height: 0,
|
||||
width: 0
|
||||
};
|
||||
}
|
||||
const errorBox: HTMLElement|null = this._dom.querySelector('.error_details');
|
||||
const errorBoxStartHeight = errorBox?.getBoundingClientRect().height || 0;
|
||||
const errorBoxDesiredHeight = errorBox?.scrollHeight || 0;
|
||||
|
||||
// If we have an error to show, ask for a larger size for formulaEditor.
|
||||
const desiredSize = {
|
||||
width: Math.max(desiredElemSize.width, (this.options.formulaError ? minFormulaErrorWidth : 0)),
|
||||
width: Math.max(desiredElemSize.width, (this.options.formulaError.get() ? minFormulaErrorWidth : 0)),
|
||||
// Ask for extra space for the error; we'll decide how to allocate it below.
|
||||
height: desiredElemSize.height + (errorBoxDesiredHeight - errorBoxStartHeight),
|
||||
};
|
||||
@ -216,35 +337,42 @@ export class FormulaEditor extends NewBaseEditor {
|
||||
}
|
||||
|
||||
// TODO: update regexes to unicode?
|
||||
private _onSetCursor(row: DataRowModel, col: ViewFieldRec) {
|
||||
|
||||
if (!col) { return; } // if clicked on row header, no col to insert
|
||||
|
||||
private _onSetCursor(row?: DataRowModel, col?: ViewFieldRec) {
|
||||
// Don't do anything when we are readonly.
|
||||
if (this.options.readonly) { return; }
|
||||
// If we don't have column information, we can't insert anything.
|
||||
if (!col) { return; }
|
||||
|
||||
const colId = col.origCol.peek().colId.peek();
|
||||
|
||||
const aceObj = this._formulaEditor.getEditor();
|
||||
|
||||
if (!aceObj.selection.isEmpty()) { // If text selected, replace whole selection
|
||||
aceObj.session.replace(aceObj.selection.getRange(), '$' + col.colId());
|
||||
// Rect only to columns in the same table.
|
||||
if (col.tableId.peek() !== this.options.column.table.peek().tableId.peek()) {
|
||||
// aceObj.focus();
|
||||
this.options.gristDoc.onSetCursorPos(row, col).catch(reportError);
|
||||
return;
|
||||
}
|
||||
|
||||
} else { // Not a selection, gotta figure out what to replace
|
||||
if (!aceObj.selection.isEmpty()) {
|
||||
// If text selected, replace whole selection
|
||||
aceObj.session.replace(aceObj.selection.getRange(), '$' + colId);
|
||||
} else {
|
||||
// Not a selection, gotta figure out what to replace
|
||||
const pos = aceObj.getCursorPosition();
|
||||
const line = aceObj.session.getLine(pos.row);
|
||||
const result = _isInIdentifier(line, pos.column); // returns {start, end, id} | null
|
||||
|
||||
if (!result) { // Not touching an identifier, insert colId as normal
|
||||
aceObj.insert("$" + col.colId());
|
||||
|
||||
if (!result) {
|
||||
// Not touching an identifier, insert colId as normal
|
||||
aceObj.insert('$' + colId);
|
||||
// We are touching an identifier
|
||||
} else if (result.ident.startsWith("$")) { // If ident is a colId, replace it
|
||||
|
||||
} else if (result.ident.startsWith('$')) {
|
||||
// If ident is a colId, replace it
|
||||
const idRange = AceEditor.makeRange(pos.row, result.start, pos.row, result.end);
|
||||
aceObj.session.replace(idRange, "$" + col.colId());
|
||||
aceObj.session.replace(idRange, '$' + colId);
|
||||
}
|
||||
|
||||
// Else touching a normal identifier, dont mangle it
|
||||
// Else touching a normal identifier, don't mangle it
|
||||
}
|
||||
|
||||
// Resize editor in case it is needed.
|
||||
this._formulaEditor.resize();
|
||||
aceObj.focus();
|
||||
@ -276,6 +404,7 @@ export function openFormulaEditor(options: {
|
||||
gristDoc: GristDoc,
|
||||
// Associated formula from a different column (for example style rule).
|
||||
column?: ColumnRec,
|
||||
// Associated formula from a view field. If provided together with column, this field is used
|
||||
field?: ViewFieldRec,
|
||||
editingFormula?: ko.Computed<boolean>,
|
||||
// Needed to get exception value, if any.
|
||||
@ -285,24 +414,38 @@ export function openFormulaEditor(options: {
|
||||
editValue?: string,
|
||||
onSave?: (column: ColumnRec, formula: string) => Promise<void>,
|
||||
onCancel?: () => void,
|
||||
canDetach?: boolean,
|
||||
// Called after editor is created to set up editor cleanup (e.g. saving on click-away).
|
||||
setupCleanup: (
|
||||
owner: MultiHolder,
|
||||
owner: Disposable,
|
||||
doc: GristDoc,
|
||||
editingFormula: ko.Computed<boolean>,
|
||||
save: () => Promise<void>
|
||||
) => void,
|
||||
}): Disposable {
|
||||
}): FormulaEditor {
|
||||
const {gristDoc, editRow, refElem, setupCleanup} = options;
|
||||
const holder = MultiHolder.create(null);
|
||||
const attachedHolder = new MultiHolder();
|
||||
|
||||
if (options.field) {
|
||||
options.column = options.field.origCol();
|
||||
} else if (options.canDetach) {
|
||||
throw new Error('Field is required for detached editor');
|
||||
}
|
||||
|
||||
// We can't rely on the field passed in, we need to create our own.
|
||||
const column = options.column ?? options.field?.column();
|
||||
|
||||
if (!column) {
|
||||
throw new Error(t('Column or field is required'));
|
||||
throw new Error('Column or field is required');
|
||||
}
|
||||
|
||||
// AsyncOnce ensures it's called once even if triggered multiple times.
|
||||
const saveEdit = asyncOnce(async () => {
|
||||
const detached = editor.isDetached.get();
|
||||
if (detached) {
|
||||
editor.dispose();
|
||||
return;
|
||||
}
|
||||
const formula = String(editor.getCellValue());
|
||||
if (formula !== column.formula.peek()) {
|
||||
if (options.onSave) {
|
||||
@ -310,9 +453,9 @@ export function openFormulaEditor(options: {
|
||||
} else {
|
||||
await column.updateColValues({formula});
|
||||
}
|
||||
holder.dispose();
|
||||
editor.dispose();
|
||||
} else {
|
||||
holder.dispose();
|
||||
editor.dispose();
|
||||
options.onCancel?.();
|
||||
}
|
||||
});
|
||||
@ -321,23 +464,31 @@ export function openFormulaEditor(options: {
|
||||
const editCommands = {
|
||||
fieldEditSave: () => { saveEdit().catch(reportError); },
|
||||
fieldEditSaveHere: () => { saveEdit().catch(reportError); },
|
||||
fieldEditCancel: () => { holder.dispose(); options.onCancel?.(); },
|
||||
fieldEditCancel: () => { editor.dispose(); options.onCancel?.(); },
|
||||
};
|
||||
|
||||
// Replace the item in the Holder with a new one, disposing the previous one.
|
||||
const editor = FormulaEditor.create(holder, {
|
||||
const formulaError = editRow ? getFormulaError(attachedHolder, {
|
||||
gristDoc,
|
||||
editRow,
|
||||
column,
|
||||
field: options.field,
|
||||
}) : undefined;
|
||||
const editor = FormulaEditor.create(null, {
|
||||
gristDoc,
|
||||
column,
|
||||
field: options.field,
|
||||
editingFormula: options.editingFormula,
|
||||
rowId: editRow ? editRow.id() : 0,
|
||||
cellValue: column.formula(),
|
||||
formulaError: editRow ? getFormulaError(gristDoc, editRow, column) : undefined,
|
||||
formulaError,
|
||||
editValue: options.editValue,
|
||||
cursorPos: Number.POSITIVE_INFINITY, // Position of the caret within the editor.
|
||||
commands: editCommands,
|
||||
cssClass: 'formula_editor_sidepane',
|
||||
readonly : false
|
||||
} as IFormulaEditorOptions);
|
||||
readonly : false,
|
||||
canDetach: options.canDetach
|
||||
} as IFormulaEditorOptions) as FormulaEditor;
|
||||
editor.autoDispose(attachedHolder);
|
||||
editor.attach(refElem);
|
||||
|
||||
const editingFormula = options.editingFormula ?? options?.field?.editingFormula;
|
||||
@ -353,30 +504,92 @@ export function openFormulaEditor(options: {
|
||||
if (!column.formula()) {
|
||||
editingFormula(true);
|
||||
}
|
||||
setupCleanup(holder, gristDoc, editingFormula, saveEdit);
|
||||
return holder;
|
||||
setupCleanup(editor, gristDoc, editingFormula, saveEdit);
|
||||
return editor;
|
||||
}
|
||||
|
||||
/**
|
||||
* If the cell at the given row and column is a formula value containing an exception, return an
|
||||
* observable with this exception, and fetch more details to add to the observable.
|
||||
*/
|
||||
export function getFormulaError(
|
||||
gristDoc: GristDoc, editRow: DataRowModel, column: ColumnRec
|
||||
): Observable<CellValue>|undefined {
|
||||
export function getFormulaError(owner: Disposable, options: {
|
||||
gristDoc: GristDoc,
|
||||
editRow: DataRowModel,
|
||||
column?: ColumnRec,
|
||||
field?: ViewFieldRec,
|
||||
}): Observable<CellValue|undefined> {
|
||||
const {gristDoc, editRow} = options;
|
||||
const formulaError = Observable.create(owner, undefined as any);
|
||||
// When we don't have a field information we don't need to be reactive at all.
|
||||
if (!options.field) {
|
||||
const column = options.column!;
|
||||
const colId = column.colId.peek();
|
||||
const cellCurrentValue = editRow.cells[colId].peek();
|
||||
const onValueChange = errorMonitor(gristDoc, column, editRow, owner, formulaError);
|
||||
const subscription = editRow.cells[colId].subscribe(onValueChange);
|
||||
owner.autoDispose(subscription);
|
||||
onValueChange(editRow.cells[colId].peek());
|
||||
return formulaError;
|
||||
} else {
|
||||
|
||||
// We can't rely on the editRow we got, as this is owned by the view. When we will be detached the view will be
|
||||
// gone. So, we will create our own observable that will be updated when the row is updated.
|
||||
const errorRow: DataRowModel = gristDoc.getTableModel(options.field.tableId.peek()).createFloatingRowModel() as any;
|
||||
errorRow.assign(editRow.getRowId());
|
||||
owner.autoDispose(errorRow);
|
||||
|
||||
// When we have a field information we will grab the error from the column that is currently connected to the field.
|
||||
// This will change when user is using the preview feature in detached editor, where a new column is created, and
|
||||
// field starts showing it instead of the original column.
|
||||
Computed.create(owner, use => {
|
||||
// This pattern creates a subscription using compute observable.
|
||||
|
||||
// Create an holder for everything that is created during recomputation. It will be returned as the value
|
||||
// of the computed observable, and will be disposed when the value changes.
|
||||
const holder = MultiHolder.create(use.owner);
|
||||
|
||||
// Now subscribe to the column in the field, this is the part that will be changed when user creates a preview.
|
||||
const column = use(options.field!.column);
|
||||
const colId = use(column.colId);
|
||||
const onValueChange = errorMonitor(gristDoc, column, errorRow, holder, formulaError);
|
||||
// Unsubscribe when computed is recomputed.
|
||||
holder.autoDispose(errorRow.cells[colId].subscribe(onValueChange));
|
||||
// Trigger the subscription to get the initial value.
|
||||
onValueChange(errorRow.cells[colId].peek());
|
||||
|
||||
// Return the holder, it will be disposed when the value changes.
|
||||
return holder;
|
||||
});
|
||||
}
|
||||
return formulaError;
|
||||
}
|
||||
|
||||
function errorMonitor(
|
||||
gristDoc: GristDoc,
|
||||
column: ColumnRec,
|
||||
editRow: DataRowModel,
|
||||
holder: Disposable,
|
||||
formulaError: Observable<CellValue|undefined> ) {
|
||||
return function onValueChange(cellCurrentValue: CellValue) {
|
||||
const isFormula = column.isFormula() || column.hasTriggerFormula();
|
||||
if (isFormula && isRaisedException(cellCurrentValue)) {
|
||||
const formulaError = Observable.create(null, cellCurrentValue);
|
||||
gristDoc.docData.getFormulaError(column.table().tableId(), colId, editRow.getRowId())
|
||||
if (!formulaError.get()) {
|
||||
// Don't update it when there is already an error (to avoid flickering).
|
||||
formulaError.set(cellCurrentValue);
|
||||
}
|
||||
gristDoc.docData.getFormulaError(column.table().tableId(), column.colId(), editRow.getRowId())
|
||||
.then(value => {
|
||||
if (holder.isDisposed()) { return; }
|
||||
formulaError.set(value);
|
||||
})
|
||||
.catch(reportError);
|
||||
return formulaError;
|
||||
.catch((er) => {
|
||||
if (!holder.isDisposed()) {
|
||||
reportError(er);
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
} else {
|
||||
formulaError.set(undefined);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@ -429,8 +642,44 @@ export function createFormulaErrorObs(owner: MultiHolder, gristDoc: GristDoc, or
|
||||
const cssCollapseIcon = styled(icon, `
|
||||
margin: -3px 4px 0 4px;
|
||||
--icon-color: ${colors.slate};
|
||||
cursor: pointer;
|
||||
`);
|
||||
|
||||
export const cssError = styled('div', `
|
||||
color: ${theme.errorText};
|
||||
`);
|
||||
|
||||
const cssFormulaEditor = styled('div.default_editor.formula_editor_wrapper', `
|
||||
&-detached {
|
||||
height: 100%;
|
||||
position: relative;
|
||||
box-shadow: none;
|
||||
}
|
||||
&-detached .formula_editor {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
&-detached .error_msg, &-detached .error_details {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 1;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&-detached .code_editor_container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&-detached .ace_editor {
|
||||
height: 100% !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.floating-popup .formula_editor {
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.floating-popup .error_details {
|
||||
min-height: 100px;
|
||||
}
|
||||
`);
|
||||
|
@ -33,7 +33,7 @@ export class NTextEditor extends NewBaseEditor {
|
||||
options.editValue, String(options.cellValue ?? ""));
|
||||
this.editorState = Observable.create<string>(this, initialValue);
|
||||
|
||||
this.commandGroup = this.autoDispose(createGroup(options.commands, null, true));
|
||||
this.commandGroup = this.autoDispose(createGroup(options.commands, this, true));
|
||||
this._alignment = options.field.widgetOptionsJson.peek().alignment || 'left';
|
||||
this._dom =
|
||||
dom('div.default_editor',
|
||||
|
@ -17,7 +17,7 @@ export interface Options {
|
||||
gristDoc: GristDoc;
|
||||
cellValue: CellValue;
|
||||
rowId: number;
|
||||
formulaError?: Observable<CellValue>;
|
||||
formulaError: Observable<CellValue|undefined>;
|
||||
editValue?: string;
|
||||
cursorPos: number;
|
||||
commands: IEditorCommandGroup;
|
||||
@ -83,6 +83,11 @@ export abstract class NewBaseEditor extends Disposable {
|
||||
*/
|
||||
public abstract attach(cellElem: Element): void;
|
||||
|
||||
/**
|
||||
* Called to detach the editor and show it in the floating popup.
|
||||
*/
|
||||
public detach(): HTMLElement|null { return null; }
|
||||
|
||||
/**
|
||||
* Returns DOM container with the editor, typically present and attached after attach() has been
|
||||
* called.
|
||||
|
@ -104,6 +104,8 @@ export const DismissedPopup = StringUnion(
|
||||
'deleteRecords', // confirmation for deleting records keyboard shortcut,
|
||||
'deleteFields', // confirmation for deleting columns keyboard shortcut,
|
||||
'tutorialFirstCard', // first card of the tutorial,
|
||||
'formulaHelpInfo', // formula help info shown in the popup editor,
|
||||
'formulaAssistantInfo', // formula assistant info shown in the popup editor,
|
||||
);
|
||||
export type DismissedPopup = typeof DismissedPopup.type;
|
||||
|
||||
|
@ -74,6 +74,9 @@ export const commonUrls = {
|
||||
plans: "https://www.getgrist.com/pricing",
|
||||
sproutsProgram: "https://www.getgrist.com/sprouts-program",
|
||||
contact: "https://www.getgrist.com/contact",
|
||||
community: 'https://community.getgrist.com',
|
||||
functions: 'https://support.getgrist.com/functions',
|
||||
formulaSheet: 'https://support.getgrist.com/formula-cheat-sheet',
|
||||
|
||||
basicTutorial: 'https://templates.getgrist.com/woXtXUBmiN5T/Grist-Basics',
|
||||
basicTutorialImage: 'https://www.getgrist.com/wp-content/uploads/2021/08/lightweight-crm.png',
|
||||
|
@ -237,10 +237,74 @@ export class HuggingFaceAssistant implements Assistant {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test assistant that mimics ChatGPT and just returns the input.
|
||||
*/
|
||||
export class EchoAssistant implements Assistant {
|
||||
public async apply(doc: AssistanceDoc, request: AssistanceRequest): Promise<AssistanceResponse> {
|
||||
const messages = request.state?.messages || [];
|
||||
if (messages.length === 0) {
|
||||
messages.push({
|
||||
role: 'system',
|
||||
content: ''
|
||||
});
|
||||
messages.push({
|
||||
role: 'user', content: request.text,
|
||||
});
|
||||
} else {
|
||||
if (request.regenerate) {
|
||||
if (messages[messages.length - 1].role !== 'user') {
|
||||
messages.pop();
|
||||
}
|
||||
}
|
||||
messages.push({
|
||||
role: 'user', content: request.text,
|
||||
});
|
||||
}
|
||||
let completion = request.text;
|
||||
const reply = completion;
|
||||
const history = { messages };
|
||||
history.messages.push({
|
||||
role: 'assistant',
|
||||
content: completion,
|
||||
});
|
||||
// This model likes returning markdown. Code will typically
|
||||
// be in a code block with ``` delimiters.
|
||||
let lines = completion.split('\n');
|
||||
if (lines[0].startsWith('```')) {
|
||||
lines.shift();
|
||||
completion = lines.join('\n');
|
||||
const parts = completion.split('```');
|
||||
if (parts.length > 1) {
|
||||
completion = parts[0];
|
||||
}
|
||||
lines = completion.split('\n');
|
||||
}
|
||||
// This model likes repeating the function signature and
|
||||
// docstring, so we try to strip that out.
|
||||
completion = lines.join('\n');
|
||||
while (completion.includes('"""')) {
|
||||
const parts = completion.split('"""');
|
||||
completion = parts[parts.length - 1];
|
||||
}
|
||||
|
||||
// If there's no code block, don't treat the answer as a formula.
|
||||
if (!reply.includes('```')) {
|
||||
completion = '';
|
||||
}
|
||||
const response = await completionToResponse(doc, request, completion, reply);
|
||||
response.state = history;
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiate an assistant, based on environment variables.
|
||||
*/
|
||||
function getAssistant() {
|
||||
if (process.env.OPENAI_API_KEY === 'test') {
|
||||
return new EchoAssistant();
|
||||
}
|
||||
if (process.env.OPENAI_API_KEY) {
|
||||
return new OpenAIAssistant();
|
||||
}
|
||||
|
@ -114,6 +114,7 @@
|
||||
--icon-ResizePanel: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHJlY3QgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiB4PSI0IiB5PSIyIiB3aWR0aD0iMiIgaGVpZ2h0PSIxMiIgcng9IjEiLz48L3N2Zz4=');
|
||||
--icon-Revert: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMuMTIzMTcgMy4wMDMzTDMuMDg3MjggNi45MTEwM0w2LjgxMzM2IDUuNjY0MzYiIHN0cm9rZT0iIzE2QjM3OCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PHBhdGggZD0iTTEzLjYxNTggMTAuMDc5OUMxNC4wMzY0IDguNjg0NzggMTMuODg1NSA3LjE3OTc0IDEzLjE5NjUgNS44OTU4NiAxMi41MDc0IDQuNjExOTggMTEuMzM2NSAzLjY1NDQzIDkuOTQxMzcgMy4yMzM4NSA4LjU0NjI3IDIuODEzMjcgNy4wNDEyMyAyLjk2NDExIDUuNzU3MzUgMy42NTMyIDQuNDczNDcgNC4zNDIzIDMuNTE1OTIgNS41MTMxOCAzLjA5NTM0IDYuOTA4MjhNNi40Mjk0MyAxMy41ODc2QzUuODQ4OCAxMy4zNjgzIDUuMzA5NjIgMTMuMDUyIDQuODM0ODQgMTIuNjUyMk0xMC4wMzk4IDEzLjY4MTZDOS40OTk1MyAxMy44NTM0IDguOTM1OTUgMTMuOTQwNyA4LjM2OTAzIDEzLjk0MDJIOC4yNDMwNE0xMi44ODY4IDExLjU3MjhDMTIuNTQ4MiAxMi4wNjA4IDEyLjEzMzIgMTIuNDkxMiAxMS42NTc3IDEyLjg0NzNNMy42NjIxNSAxMS4yODA0QzMuMzUxNDggMTAuNzY2NiAzLjEyNzc2IDEwLjIwNTEgMyA5LjYxODQxIiBzdHJva2U9IiMxNkIzNzgiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjwvc3ZnPg==');
|
||||
--icon-RightAlign: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIuNSw4LjUgQzIuMjIzODU3NjMsOC41IDIsOC4yNzYxNDIzNyAyLDggQzIsNy43MjM4NTc2MyAyLjIyMzg1NzYzLDcuNSAyLjUsNy41IEwxMy41LDcuNSBDMTMuNzc2MTQyNCw3LjUgMTQsNy43MjM4NTc2MyAxNCw4IEMxNCw4LjI3NjE0MjM3IDEzLjc3NjE0MjQsOC41IDEzLjUsOC41IEwyLjUsOC41IFogTTIuNSw0IEMyLjIyMzg1NzYzLDQgMiwzLjc3NjE0MjM3IDIsMy41IEMyLDMuMjIzODU3NjMgMi4yMjM4NTc2MywzIDIuNSwzIEwxMy41LDMgQzEzLjc3NjE0MjQsMyAxNCwzLjIyMzg1NzYzIDE0LDMuNSBDMTQsMy43NzYxNDIzNyAxMy43NzYxNDI0LDQgMTMuNSw0IEwyLjUsNCBaIE04LjUsMTMgQzguMjIzODU3NjMsMTMgOCwxMi43NzYxNDI0IDgsMTIuNSBDOCwxMi4yMjM4NTc2IDguMjIzODU3NjMsMTIgOC41LDEyIEwxMy41LDEyIEMxMy43NzYxNDI0LDEyIDE0LDEyLjIyMzg1NzYgMTQsMTIuNSBDMTQsMTIuNzc2MTQyNCAxMy43NzYxNDI0LDEzIDEzLjUsMTMgTDguNSwxMyBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4=');
|
||||
--icon-Robot: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTE2IDJWMEgxMFYySDEyVjRINFYySDZWMEgwVjJIMlY0SDBWMTZIMTZWNEgxNFYySDE2Wk0xMCA3QzEwLjYgNyAxMSA3LjQgMTEgOEMxMSA4LjYgMTAuNiA5IDEwIDlDOS40IDkgOSA4LjYgOSA4QzkgNy40IDkuNCA3IDEwIDdaTTYgN0M2LjYgNyA3IDcuNCA3IDhDNyA4LjYgNi42IDkgNiA5QzUuNCA5IDUgOC42IDUgOEM1IDcuNCA1LjQgNyA2IDdaTTEyIDEzSDRWMTFIMTJWMTNaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4=');
|
||||
--icon-Script: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJjdXJyZW50Q29sb3IiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBjbGFzcz0iZmVhdGhlciBmZWF0aGVyLWRhdGFiYXNlIj48cmVjdCB3aWR0aD0iMTkuMzI4IiBoZWlnaHQ9IjE5LjMyOCIgeD0iMi4zMzYiIHk9IjIuMzM2IiByeT0iNC4wMjYiIHJ4PSIzLjc0NSIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJidXR0Ii8+PGcgc3Ryb2tlLXdpZHRoPSIxLjU0MyIgc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWNhcD0iYnV0dCIgc3Ryb2tlLWxpbmVqb2luPSJtaXRlciI+PHBhdGggZD0iTTE4LjczNDQ0OSA5LjAyMTI1NTZMNS4yNjU1NTEyIDkuMDA5NTM2OE0xOC43MzQ0NDkgMTQuNzM4NTg2TDUuMjY1NTUxMiAxNC43MjY4NjciIHRyYW5zZm9ybT0ibWF0cml4KC45Mzk2OSAwIDAgMS4wMDUzMSAuNzI0IC0uMDYzKSIvPjwvZz48L3N2Zz4=');
|
||||
--icon-Search: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLjQzNjIxMjcsMTAuNzI5MTA1OSBMMTQuODUzNTUzNCwxNC4xNDY0NDY2IEMxNS4wNDg4MTU1LDE0LjM0MTcwODggMTUuMDQ4ODE1NSwxNC42NTgyOTEyIDE0Ljg1MzU1MzQsMTQuODUzNTUzNCBDMTQuNjU4MjkxMiwxNS4wNDg4MTU1IDE0LjM0MTcwODgsMTUuMDQ4ODE1NSAxNC4xNDY0NDY2LDE0Ljg1MzU1MzQgTDEwLjcyOTEwNTksMTEuNDM2MjEyNyBDOS41OTIzMzg0OCwxMi40MTEwNDg3IDguMTE0OTQ3NzEsMTMgNi41LDEzIEMyLjkxMDE0OTEzLDEzIDAsMTAuMDg5ODUwOSAwLDYuNSBDMCwyLjkxMDE0OTEzIDIuOTEwMTQ5MTMsMCA2LjUsMCBDMTAuMDg5ODUwOSwwIDEzLDIuOTEwMTQ5MTMgMTMsNi41IEMxMyw4LjExNDk0NzcxIDEyLjQxMTA0ODcsOS41OTIzMzg0OCAxMS40MzYyMTI3LDEwLjcyOTEwNTkgWiBNMTAuNDA5NTc0NywxMC4zNjg0OTIxIEMxMS4zOTI4MzI1LDkuMzc0ODU3OCAxMiw4LjAwODMzNDY4IDEyLDYuNSBDMTIsMy40NjI0MzM4OCA5LjUzNzU2NjEyLDEgNi41LDEgQzMuNDYyNDMzODgsMSAxLDMuNDYyNDMzODggMSw2LjUgQzEsOS41Mzc1NjYxMiAzLjQ2MjQzMzg4LDEyIDYuNSwxMiBDOC4wMDgzMzQ2OCwxMiA5LjM3NDg1NzgsMTEuMzkyODMyNSAxMC4zNjg0OTIxLDEwLjQwOTU3NDcgQzEwLjM3NDkwMDEsMTAuNDAyMzg3OSAxMC4zODE1NTE2LDEwLjM5NTM0MTYgMTAuMzg4NDQ2NiwxMC4zODg0NDY2IEMxMC4zOTUzNDE2LDEwLjM4MTU1MTYgMTAuNDAyMzg3OSwxMC4zNzQ5MDAxIDEwLjQwOTU3NDcsMTAuMzY4NDkyMSBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4=');
|
||||
--icon-Settings: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMuOTgwNDcxOSwzLjI3MzM2NTEyIEM0LjgxNDE0NDY1LDIuNTc4NDA2ODQgNS44NTY2MjI5MSwyLjEyNTQ1Njc0IDcsMi4wMjI0MjE1MSBMNywwLjUgQzcsMC4yMjM4NTc2MjUgNy4yMjM4NTc2MywwIDcuNSwwIEM3Ljc3NjE0MjM3LDAgOCwwLjIyMzg1NzYyNSA4LDAuNSBMOCwyLjAyMjQyMTUxIEM5LjE0MzM3NzA5LDIuMTI1NDU2NzQgMTAuMTg1ODU1NCwyLjU3ODQwNjg0IDExLjAxOTUyODEsMy4yNzMzNjUxMiBMMTIuMDk2NDQ2NiwyLjE5NjQ0NjYxIEMxMi4yOTE3MDg4LDIuMDAxMTg0NDYgMTIuNjA4MjkxMiwyLjAwMTE4NDQ2IDEyLjgwMzU1MzQsMi4xOTY0NDY2MSBDMTIuOTk4ODE1NSwyLjM5MTcwODc2IDEyLjk5ODgxNTUsMi43MDgyOTEyNCAxMi44MDM1NTM0LDIuOTAzNTUzMzkgTDExLjcyNjYzNDksMy45ODA0NzE5IEMxMi40MjE1OTMyLDQuODE0MTQ0NjUgMTIuODc0NTQzMyw1Ljg1NjYyMjkxIDEyLjk3NzU3ODUsNyBMMTQuNSw3IEMxNC43NzYxNDI0LDcgMTUsNy4yMjM4NTc2MyAxNSw3LjUgQzE1LDcuNzc2MTQyMzcgMTQuNzc2MTQyNCw4IDE0LjUsOCBMMTIuOTc3NTc4NSw4IEMxMi44NzQ1NDMzLDkuMTQzMzc3MDkgMTIuNDIxNTkzMiwxMC4xODU4NTU0IDExLjcyNjYzNDksMTEuMDE5NTI4MSBMMTIuODAzNTUzNCwxMi4wOTY0NDY2IEMxMi45OTg4MTU1LDEyLjI5MTcwODggMTIuOTk4ODE1NSwxMi42MDgyOTEyIDEyLjgwMzU1MzQsMTIuODAzNTUzNCBDMTIuNjA4MjkxMiwxMi45OTg4MTU1IDEyLjI5MTcwODgsMTIuOTk4ODE1NSAxMi4wOTY0NDY2LDEyLjgwMzU1MzQgTDExLjAxOTUyODEsMTEuNzI2NjM0OSBDMTAuMTg1ODU1NCwxMi40MjE1OTMyIDkuMTQzMzc3MDksMTIuODc0NTQzMyA4LDEyLjk3NzU3ODUgTDgsMTQuNSBDOCwxNC43NzYxNDI0IDcuNzc2MTQyMzcsMTUgNy41LDE1IEM3LjIyMzg1NzYzLDE1IDcsMTQuNzc2MTQyNCA3LDE0LjUgTDcsMTIuOTc3NTc4NSBDNS44NTY2MjI5MSwxMi44NzQ1NDMzIDQuODE0MTQ0NjUsMTIuNDIxNTkzMiAzLjk4MDQ3MTksMTEuNzI2NjM0OSBMMi45MDM1NTMzOSwxMi44MDM1NTM0IEMyLjcwODI5MTI0LDEyLjk5ODgxNTUgMi4zOTE3MDg3NiwxMi45OTg4MTU1IDIuMTk2NDQ2NjEsMTIuODAzNTUzNCBDMi4wMDExODQ0NiwxMi42MDgyOTEyIDIuMDAxMTg0NDYsMTIuMjkxNzA4OCAyLjE5NjQ0NjYxLDEyLjA5NjQ0NjYgTDMuMjczMzY1MTIsMTEuMDE5NTI4MSBDMi41Nzg0MDY4NCwxMC4xODU4NTU0IDIuMTI1NDU2NzQsOS4xNDMzNzcwOSAyLjAyMjQyMTUxLDggTDAuNSw4IEMwLjIyMzg1NzYyNSw4IDAsNy43NzYxNDIzNyAwLDcuNSBDMCw3LjIyMzg1NzYzIDAuMjIzODU3NjI1LDcgMC41LDcgTDIuMDIyNDIxNTEsNyBDMi4xMjU0NTY3NCw1Ljg1NjYyMjkxIDIuNTc4NDA2ODQsNC44MTQxNDQ2NSAzLjI3MzM2NTEyLDMuOTgwNDcxOSBMMi4xOTY0NDY2MSwyLjkwMzU1MzM5IEMyLjAwMTE4NDQ2LDIuNzA4MjkxMjQgMi4wMDExODQ0NiwyLjM5MTcwODc2IDIuMTk2NDQ2NjEsMi4xOTY0NDY2MSBDMi4zOTE3MDg3NiwyLjAwMTE4NDQ2IDIuNzA4MjkxMjQsMi4wMDExODQ0NiAyLjkwMzU1MzM5LDIuMTk2NDQ2NjEgTDMuOTgwNDcxOSwzLjI3MzM2NTEyIFogTTcuNSwxMCBDNi4xMTkyODgxMywxMCA1LDguODgwNzExODcgNSw3LjUgQzUsNi4xMTkyODgxMyA2LjExOTI4ODEzLDUgNy41LDUgQzguODgwNzExODcsNSAxMCw2LjExOTI4ODEzIDEwLDcuNSBDMTAsOC44ODA3MTE4NyA4Ljg4MDcxMTg3LDEwIDcuNSwxMCBaIE03LjUsOSBDOC4zMjg0MjcxMiw5IDksOC4zMjg0MjcxMiA5LDcuNSBDOSw2LjY3MTU3Mjg4IDguMzI4NDI3MTIsNiA3LjUsNiBDNi42NzE1NzI4OCw2IDYsNi42NzE1NzI4OCA2LDcuNSBDNiw4LjMyODQyNzEyIDYuNjcxNTcyODgsOSA3LjUsOSBaIE03LjUsMTIgQzkuOTg1MjgxMzcsMTIgMTIsOS45ODUyODEzNyAxMiw3LjUgQzEyLDUuMDE0NzE4NjMgOS45ODUyODEzNywzIDcuNSwzIEM1LjAxNDcxODYzLDMgMyw1LjAxNDcxODYzIDMsNy41IEMzLDkuOTg1MjgxMzcgNS4wMTQ3MTg2MywxMiA3LjUsMTIgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+');
|
||||
|
9
static/ui-icons/UI/Robot.svg
Normal file
9
static/ui-icons/UI/Robot.svg
Normal file
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 63.1 (92452) - https://sketch.com -->
|
||||
<title>Icons / UI / Robot</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<g id="Icons-/-UI-/-Robot" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<path d="M16 2V0H10V2H12V4H4V2H6V0H0V2H2V4H0V16H16V4H14V2H16ZM10 7C10.6 7 11 7.4 11 8C11 8.6 10.6 9 10 9C9.4 9 9 8.6 9 8C9 7.4 9.4 7 10 7ZM6 7C6.6 7 7 7.4 7 8C7 8.6 6.6 9 6 9C5.4 9 5 8.6 5 8C5 7.4 5.4 7 6 7ZM12 13H4V11H12V13Z" id="Combined-Shape" fill="#000000" fill-rule="nonzero"></path>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 733 B |
@ -74,7 +74,7 @@ describe('DocTutorial', function () {
|
||||
await gu.skipWelcomeQuestions();
|
||||
|
||||
// Make sure we have clean start.
|
||||
await driver.executeScript('resetSeenPopups();');
|
||||
await driver.executeScript('resetDismissedPopups();');
|
||||
await gu.waitForServer();
|
||||
await driver.navigate().refresh();
|
||||
await gu.waitForDocMenuToLoad();
|
||||
|
@ -13,7 +13,7 @@ describe('RightPanel', function() {
|
||||
await mainSession.tempNewDoc(cleanup);
|
||||
|
||||
// Reset prefs.
|
||||
await driver.executeScript('resetSeenPopups();');
|
||||
await driver.executeScript('resetDismissedPopups();');
|
||||
await gu.waitForServer();
|
||||
|
||||
// Refresh for a clean start.
|
||||
|
@ -960,9 +960,15 @@ export async function waitForServer(optTimeout: number = 2000) {
|
||||
* Sends UserActions using client api from the browser.
|
||||
*/
|
||||
export async function sendActions(actions: UserAction[]) {
|
||||
await driver.executeScript(`
|
||||
gristDocPageModel.gristDoc.get().docModel.docData.sendActions(${JSON.stringify(actions)});
|
||||
const result = await driver.executeAsyncScript(`
|
||||
const done = arguments[arguments.length - 1];
|
||||
const prom = gristDocPageModel.gristDoc.get().docModel.docData.sendActions(${JSON.stringify(actions)});
|
||||
prom.then(() => done(null));
|
||||
prom.catch((err) => done(String(err?.message || err)));
|
||||
`);
|
||||
if (result) {
|
||||
throw new Error(result as string);
|
||||
}
|
||||
await waitForServer();
|
||||
}
|
||||
|
||||
|
@ -303,6 +303,10 @@ export class EnvironmentSnapshot {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public get(key: string): string|undefined {
|
||||
return this._oldEnv[key];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBuildFile(relativePath: string): Promise<string> {
|
||||
|
Loading…
Reference in New Issue
Block a user