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.
|
* Note: Ace defers to standard behavior when false is returned.
|
||||||
*/
|
*/
|
||||||
AceEditor.prototype.attachCommandGroup = function(commandGroup) {
|
AceEditor.prototype.attachCommandGroup = function(commandGroup) {
|
||||||
_.each(commandGroup.knownKeys, (command, key) => {
|
_.each(commandGroup.knownKeys, (commandName, key) => {
|
||||||
this.editor.commands.addCommand({
|
this.editor.commands.addCommand({
|
||||||
name: command,
|
name: commandName,
|
||||||
// We are setting readonly as true to enable all commands
|
// We are setting readonly as true to enable all commands
|
||||||
// in a readonly mode.
|
// in a readonly mode.
|
||||||
// Because FieldEditor in readonly mode will rewire all commands that
|
// 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
|
// 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.
|
// 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
|
// 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.
|
// help for Windows users who have different pixel ratio.
|
||||||
this.editorDom.style.width = size.width ? Math.ceil(size.width) + 'px' : 'auto';
|
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();
|
this.editor.resize();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import {ISuggestionWithValue} from 'app/common/ActiveDocAPI';
|
import {ISuggestionWithValue} from 'app/common/ActiveDocAPI';
|
||||||
|
import {commonUrls} from 'app/common/gristUrls';
|
||||||
import * as ace from 'brace';
|
import * as ace from 'brace';
|
||||||
|
|
||||||
export interface ICompletionOptions {
|
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
|
// 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.
|
// 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();
|
rowData.funcname.slice(linkStart, linkEnd).toLowerCase();
|
||||||
newTokens.push({value: href, type: 'grist_link_hidden'});
|
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 {NEW_FILTER_JSON} = require('app/client/models/ColumnFilter');
|
||||||
const {CombinedStyle} = require("app/client/models/Styles");
|
const {CombinedStyle} = require("app/client/models/Styles");
|
||||||
const {buildRenameColumn} = require('app/client/ui/ColumnTitle');
|
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.
|
// 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
|
// 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.
|
// Holds column index that is hovered, works only in full-edit formula mode.
|
||||||
this.hoverColumn = ko.observable(-1);
|
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
|
// Debounced method to change current hover column, this is needed
|
||||||
// as mouse when moved from field to field will switch the hover-column
|
// 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.
|
// 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.
|
// will be discarded.
|
||||||
this.changeHover = debounce((index) => {
|
this.changeHover = debounce((index) => {
|
||||||
if (this.isDisposed()) { return; }
|
if (this.isDisposed()) { return; }
|
||||||
if (this.gristDoc.docModel.editingFormula()) {
|
if (this.editingFormula()) {
|
||||||
this.hoverColumn(index);
|
this.hoverColumn(index);
|
||||||
}
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
@ -1054,8 +1065,9 @@ GridView.prototype.buildDom = function() {
|
|||||||
|
|
||||||
let filterTriggerCtl;
|
let filterTriggerCtl;
|
||||||
const isTooltip = ko.pureComputed(() =>
|
const isTooltip = ko.pureComputed(() =>
|
||||||
self.gristDoc.docModel.editingFormula() &&
|
self.editingFormula() &&
|
||||||
ko.unwrap(self.hoverColumn) === field._index());
|
ko.unwrap(self.hoverColumn) === field._index()
|
||||||
|
);
|
||||||
return dom(
|
return dom(
|
||||||
'div.column_name.field',
|
'div.column_name.field',
|
||||||
kd.style('--frozen-position', () => ko.unwrap(this.frozenPositions.at(field._index()))),
|
kd.style('--frozen-position', () => ko.unwrap(this.frozenPositions.at(field._index()))),
|
||||||
@ -1070,7 +1082,7 @@ GridView.prototype.buildDom = function() {
|
|||||||
dom.autoDispose(tooltip),
|
dom.autoDispose(tooltip),
|
||||||
dom.autoDispose(isTooltip.subscribe((show) => {
|
dom.autoDispose(isTooltip.subscribe((show) => {
|
||||||
if (show) {
|
if (show) {
|
||||||
tooltip.show(`Click to insert $${field.colId.peek()}`);
|
tooltip.show(t(`Click to insert`) + ` $${field.origCol.peek().colId.peek()}`);
|
||||||
} else {
|
} else {
|
||||||
tooltip.hide();
|
tooltip.hide();
|
||||||
}
|
}
|
||||||
@ -1316,7 +1328,7 @@ GridView.prototype.buildDom = function() {
|
|||||||
dom.autoDispose(isSelected),
|
dom.autoDispose(isSelected),
|
||||||
dom.on("mouseenter", () => self.changeHover(field._index())),
|
dom.on("mouseenter", () => self.changeHover(field._index())),
|
||||||
kd.toggleClass("hover-column", () =>
|
kd.toggleClass("hover-column", () =>
|
||||||
self.gristDoc.docModel.editingFormula() &&
|
self.editingFormula() &&
|
||||||
ko.unwrap(self.hoverColumn) === (field._index())),
|
ko.unwrap(self.hoverColumn) === (field._index())),
|
||||||
kd.style('width', field.widthPx),
|
kd.style('width', field.widthPx),
|
||||||
//TODO: Ensure that fields in a row resize when
|
//TODO: Ensure that fields in a row resize when
|
||||||
@ -1624,7 +1636,7 @@ GridView.prototype.dropCols = function() {
|
|||||||
// column movement, propose renaming the column.
|
// column movement, propose renaming the column.
|
||||||
if (Date.now() - this._colClickTime < SHORT_CLICK_IN_MS && oldIndices.length === 1 &&
|
if (Date.now() - this._colClickTime < SHORT_CLICK_IN_MS && oldIndices.length === 1 &&
|
||||||
idx === oldIndices[0]) {
|
idx === oldIndices[0]) {
|
||||||
this.currentEditingColumnIndex(idx);
|
commands.allCommands.renameField.run();
|
||||||
}
|
}
|
||||||
this._colClickTime = 0;
|
this._colClickTime = 0;
|
||||||
this.cellSelector.currentDragType(selector.NONE);
|
this.cellSelector.currentDragType(selector.NONE);
|
||||||
|
@ -28,7 +28,7 @@ import {makeT} from 'app/client/lib/localization';
|
|||||||
import {createSessionObs} from 'app/client/lib/sessionObs';
|
import {createSessionObs} from 'app/client/lib/sessionObs';
|
||||||
import {setTestState} from 'app/client/lib/testState';
|
import {setTestState} from 'app/client/lib/testState';
|
||||||
import {selectFiles} from 'app/client/lib/uploads';
|
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 BaseRowModel from 'app/client/models/BaseRowModel';
|
||||||
import DataTableModel from 'app/client/models/DataTableModel';
|
import DataTableModel from 'app/client/models/DataTableModel';
|
||||||
import {DataTableModelWithDiff} from 'app/client/models/DataTableModelWithDiff';
|
import {DataTableModelWithDiff} from 'app/client/models/DataTableModelWithDiff';
|
||||||
@ -205,6 +205,7 @@ export class GristDoc extends DisposableWithEvents {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public readonly app: App,
|
public readonly app: App,
|
||||||
|
public readonly appModel: AppModel,
|
||||||
public readonly docComm: DocComm,
|
public readonly docComm: DocComm,
|
||||||
public readonly docPageModel: DocPageModel,
|
public readonly docPageModel: DocPageModel,
|
||||||
openDocResponse: OpenLocalDocResult,
|
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.
|
// 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.
|
// This is overridden by the formula editor to insert "$col" variables when clicking cells.
|
||||||
setCursor(rowModel: BaseRowModel, fieldModel?: ViewFieldRec) {
|
setCursor: this.onSetCursorPos.bind(this),
|
||||||
return this.setCursorPos({
|
|
||||||
rowIndex: rowModel?._index() || 0,
|
|
||||||
fieldIndex: fieldModel?._index() || 0,
|
|
||||||
sectionId: fieldModel?.viewSection().getRowId(),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
}, this, true));
|
}, this, true));
|
||||||
|
|
||||||
this.listenTo(app.comm, 'docUserAction', this.onDocUserAction);
|
this.listenTo(app.comm, 'docUserAction', this.onDocUserAction);
|
||||||
@ -614,6 +609,14 @@ export class GristDoc extends DisposableWithEvents {
|
|||||||
return Object.assign(pos, viewInstance ? viewInstance.cursor.getCursorPos() : {});
|
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) {
|
public async setCursorPos(cursorPos: CursorPos) {
|
||||||
if (cursorPos.sectionId && cursorPos.sectionId !== this.externalSectionId.get()) {
|
if (cursorPos.sectionId && cursorPos.sectionId !== this.externalSectionId.get()) {
|
||||||
const desiredSection: ViewSectionRec = this.docModel.viewSections.getRowModel(cursorPos.sectionId);
|
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
|
* Opens up an editor at cursor position
|
||||||
* @param input Optional. Cell's initial value
|
* @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();
|
const view = await this._waitForView();
|
||||||
view?.activateEditorAtCursor(options);
|
view?.activateEditorAtCursor(options);
|
||||||
}
|
}
|
||||||
|
@ -31,8 +31,8 @@ import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
|
|||||||
import {byteString} from 'app/common/gutil';
|
import {byteString} from 'app/common/gutil';
|
||||||
import {FetchUrlOptions, UploadResult} from 'app/common/uploads';
|
import {FetchUrlOptions, UploadResult} from 'app/common/uploads';
|
||||||
import {ParseOptions, ParseOptionSchema} from 'app/plugin/FileParserAPI';
|
import {ParseOptions, ParseOptionSchema} from 'app/plugin/FileParserAPI';
|
||||||
import {Computed, dom, DomContents, fromKo, Holder, IDisposable, MultiHolder, MutableObsArray, obsArray, Observable,
|
import {Computed, Disposable, dom, DomContents, fromKo, Holder, IDisposable,
|
||||||
styled} from 'grainjs';
|
MultiHolder, MutableObsArray, obsArray, Observable, styled} from 'grainjs';
|
||||||
import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox';
|
import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox';
|
||||||
import {ACCESS_DENIED, AUTH_INTERRUPTED, canReadPrivateFiles, getGoogleCodeForReading} from 'app/client/ui/googleAuth';
|
import {ACCESS_DENIED, AUTH_INTERRUPTED, canReadPrivateFiles, getGoogleCodeForReading} from 'app/client/ui/googleAuth';
|
||||||
import debounce = require('lodash/debounce');
|
import debounce = require('lodash/debounce');
|
||||||
@ -824,6 +824,7 @@ export class Importer extends DisposableWithEvents {
|
|||||||
editingFormula: field.editingFormula,
|
editingFormula: field.editingFormula,
|
||||||
refElem,
|
refElem,
|
||||||
editRow,
|
editRow,
|
||||||
|
canDetach: false,
|
||||||
setupCleanup: this._setupFormulaEditorCleanup.bind(this),
|
setupCleanup: this._setupFormulaEditorCleanup.bind(this),
|
||||||
onSave: async (column, formula) => {
|
onSave: async (column, formula) => {
|
||||||
if (formula === column.formula.peek()) { return; }
|
if (formula === column.formula.peek()) { return; }
|
||||||
@ -842,7 +843,7 @@ export class Importer extends DisposableWithEvents {
|
|||||||
* focus.
|
* focus.
|
||||||
*/
|
*/
|
||||||
private _setupFormulaEditorCleanup(
|
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);
|
const saveEdit = () => _saveEdit().catch(reportError);
|
||||||
|
|
||||||
|
@ -108,6 +108,8 @@ export type CommandName =
|
|||||||
| 'clearSectionLinks'
|
| 'clearSectionLinks'
|
||||||
| 'transformUpdate'
|
| 'transformUpdate'
|
||||||
| 'clearCopySelection'
|
| 'clearCopySelection'
|
||||||
|
| 'detachEditor'
|
||||||
|
| 'activateAssistant'
|
||||||
;
|
;
|
||||||
|
|
||||||
|
|
||||||
@ -259,6 +261,11 @@ export const groups: CommendGroupDef[] = [{
|
|||||||
keys: [],
|
keys: [],
|
||||||
desc: 'Shortcut to open video tour from home left panel',
|
desc: 'Shortcut to open video tour from home left panel',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'activateAssistant',
|
||||||
|
keys: [],
|
||||||
|
desc: 'Activate assistant',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}, {
|
}, {
|
||||||
group: 'Navigation',
|
group: 'Navigation',
|
||||||
@ -391,6 +398,10 @@ export const groups: CommendGroupDef[] = [{
|
|||||||
name: 'fieldEditSave',
|
name: 'fieldEditSave',
|
||||||
keys: ['Enter'],
|
keys: ['Enter'],
|
||||||
desc: 'Finish editing a cell, saving the value'
|
desc: 'Finish editing a cell, saving the value'
|
||||||
|
}, {
|
||||||
|
name: 'detachEditor',
|
||||||
|
keys: [''],
|
||||||
|
desc: 'Detach active editor'
|
||||||
}, {
|
}, {
|
||||||
name: 'fieldEditSaveHere',
|
name: 'fieldEditSaveHere',
|
||||||
keys: [],
|
keys: [],
|
||||||
|
@ -8,18 +8,30 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as Mousetrap from 'app/client/lib/Mousetrap';
|
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 dom from 'app/client/lib/dom';
|
||||||
import 'app/client/lib/koUtil'; // for subscribeInit
|
|
||||||
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
|
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
|
||||||
import {CommandDef, CommandName, CommendGroupDef, groups} from 'app/client/components/commandList';
|
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 _ from 'underscore';
|
||||||
import * as ko from 'knockout';
|
import * as ko from 'knockout';
|
||||||
|
|
||||||
const G = getBrowserGlobals('window');
|
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.
|
// Same logic as used by mousetrap to map 'Mod' key to platform-specific key.
|
||||||
export const isMac = (typeof navigator !== 'undefined' && navigator &&
|
export const isMac = (typeof navigator !== 'undefined' && navigator &&
|
||||||
@ -291,10 +303,11 @@ export class CommandGroup extends Disposable {
|
|||||||
this.onDispose(this._removeGroup.bind(this));
|
this.onDispose(this._removeGroup.bind(this));
|
||||||
|
|
||||||
// Finally, set the activation status of the command group, subscribing if an observable.
|
// Finally, set the activation status of the command group, subscribing if an observable.
|
||||||
if (ko.isObservable(activate)) {
|
if (typeof activate === 'boolean' || activate === undefined) {
|
||||||
this.autoDispose((activate as any).subscribeInit(this.activate, this));
|
this.activate(activate ?? false);
|
||||||
} else {
|
} else if (activate) {
|
||||||
this.activate(activate as boolean);
|
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.
|
* Just a shorthand for CommandGroup.create constructor.
|
||||||
*/
|
*/
|
||||||
export function createGroup<T>(commands: BoundedMap<T>, context: T, activate?: BoolLike) {
|
export function createGroup<T>(commands: BoundedMap<T>|null, context: T, activate?: BoolLike) {
|
||||||
return CommandGroup.create(null, commands, context, activate);
|
return CommandGroup.create(null, commands ?? {}, context, activate);
|
||||||
}
|
}
|
||||||
|
|
||||||
//----------------------------------------------------------------------
|
//----------------------------------------------------------------------
|
||||||
|
@ -249,7 +249,7 @@ function toggleDisabled(boolValueOrFunc) {
|
|||||||
exports.toggleDisabled = toggleDisabled;
|
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.
|
* 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.
|
* Similar to knockout's `css` binding with a dynamic class.
|
||||||
* @param {Object} valueOrFunc An observable, a constant, or a function for a computed observable.
|
* @param {Object} valueOrFunc An observable, a constant, or a function for a computed observable.
|
||||||
@ -258,11 +258,15 @@ function cssClass(valueOrFunc) {
|
|||||||
var prevClass;
|
var prevClass;
|
||||||
return makeBinding(valueOrFunc, function(elem, value) {
|
return makeBinding(valueOrFunc, function(elem, value) {
|
||||||
if (prevClass) {
|
if (prevClass) {
|
||||||
elem.classList.remove(prevClass);
|
for(const name of prevClass.split(' ')) {
|
||||||
|
elem.classList.remove(name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
prevClass = value;
|
prevClass = value;
|
||||||
if (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);
|
this.showNewSiteModal(state.params?.planType);
|
||||||
}
|
}
|
||||||
|
|
||||||
G.window.resetSeenPopups = (seen = false) => {
|
G.window.resetDismissedPopups = (seen = false) => {
|
||||||
this.dismissedPopups.set(seen ? DismissedPopup.values : []);
|
this.dismissedPopups.set(seen ? DismissedPopup.values : []);
|
||||||
this.behavioralPromptsManager.reset();
|
this.behavioralPromptsManager.reset();
|
||||||
};
|
};
|
||||||
|
@ -375,7 +375,7 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
|
|||||||
const comparison = comparisonUrlId ?
|
const comparison = comparisonUrlId ?
|
||||||
await this._api.getDocAPI(urlId).compareDoc(comparisonUrlId, { detail: true }) : undefined;
|
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});
|
this.appModel.topAppModel.plugins, {comparison});
|
||||||
|
|
||||||
// Move ownership of docComm to GristDoc.
|
// 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.
|
* 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.
|
// CSS class to add to formula cells, incl. to show that we are editing this field's formula.
|
||||||
this.formulaCssClass = ko.pureComputed<string|null>(() => {
|
this.formulaCssClass = ko.pureComputed<string|null>(() => {
|
||||||
const col = this.column();
|
const col = this.column();
|
||||||
return this.column().isTransforming() ? "transform_field" :
|
|
||||||
(this.editingFormula() ? "formula_field_edit" :
|
// If the current column is transforming, assign the CSS class "transform_field"
|
||||||
(col.isFormula() && col.formula() !== "" ? "formula_field" : null));
|
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
|
// The fields's display column
|
||||||
|
@ -3,7 +3,6 @@ import {CursorPos} from 'app/client/components/Cursor';
|
|||||||
import {GristDoc} from 'app/client/components/GristDoc';
|
import {GristDoc} from 'app/client/components/GristDoc';
|
||||||
import {BEHAVIOR, ColumnRec} from 'app/client/models/entities/ColumnRec';
|
import {BEHAVIOR, ColumnRec} from 'app/client/models/entities/ColumnRec';
|
||||||
import {buildHighlightedCode, cssCodeBlock} from 'app/client/ui/CodeHighlight';
|
import {buildHighlightedCode, cssCodeBlock} from 'app/client/ui/CodeHighlight';
|
||||||
import {buildAiButton} from 'app/client/ui/FormulaAssistance';
|
|
||||||
import {GristTooltips} from 'app/client/ui/GristTooltips';
|
import {GristTooltips} from 'app/client/ui/GristTooltips';
|
||||||
import {cssBlockedCursor, cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
|
import {cssBlockedCursor, cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
|
||||||
import {withInfoTooltip} from 'app/client/ui/tooltips';
|
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 {textInput} from 'app/client/ui2018/editableLabel';
|
||||||
import {cssIconButton, icon} from 'app/client/ui2018/icons';
|
import {cssIconButton, icon} from 'app/client/ui2018/icons';
|
||||||
import {IconName} from 'app/client/ui2018/IconList';
|
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 {selectMenu, selectOption, selectTitle} from 'app/client/ui2018/menus';
|
||||||
import {createFormulaErrorObs, cssError} from 'app/client/widgets/FormulaEditor';
|
import {createFormulaErrorObs, cssError} from 'app/client/widgets/FormulaEditor';
|
||||||
import {sanitizeIdent} from 'app/common/gutil';
|
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)
|
// Helper function to clear temporary state (will be called when column changes or formula editor closes)
|
||||||
const clearState = () => bundleChanges(() => {
|
const clearState = () => bundleChanges(() => {
|
||||||
|
// For a detached editor, we may have already been disposed when user switched page.
|
||||||
|
if (owner.isDisposed()) { return; }
|
||||||
maybeFormula.set(false);
|
maybeFormula.set(false);
|
||||||
maybeTrigger.set(false);
|
maybeTrigger.set(false);
|
||||||
formulaField = null;
|
formulaField = null;
|
||||||
@ -277,6 +277,8 @@ export function buildFormulaConfig(
|
|||||||
|
|
||||||
// Converts column to formula column.
|
// Converts column to formula column.
|
||||||
const onSaveConvertToFormula = async (column: ColumnRec, formula: string) => {
|
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,
|
// 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.
|
// as it means we were trying to convert data column to formula column, but changed our mind.
|
||||||
const notBlank = Boolean(formula);
|
const notBlank = Boolean(formula);
|
||||||
@ -362,7 +364,6 @@ export function buildFormulaConfig(
|
|||||||
]),
|
]),
|
||||||
formulaBuilder(onSaveConvertToFormula),
|
formulaBuilder(onSaveConvertToFormula),
|
||||||
cssEmptySeparator(),
|
cssEmptySeparator(),
|
||||||
dom.maybe(GRIST_FORMULA_ASSISTANT(), () => cssRow(buildAiButton(gristDoc, origColumn))),
|
|
||||||
cssRow(textButton(
|
cssRow(textButton(
|
||||||
t("Convert to trigger formula"),
|
t("Convert to trigger formula"),
|
||||||
dom.on("click", convertFormulaToTrigger),
|
dom.on("click", convertFormulaToTrigger),
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
|
import {documentCursor} from 'app/client/lib/popupUtils';
|
||||||
import {hoverTooltip} from 'app/client/ui/tooltips';
|
import {hoverTooltip} from 'app/client/ui/tooltips';
|
||||||
import {isNarrowScreen, isNarrowScreenObs, theme, vars} from 'app/client/ui2018/cssVars';
|
import {isNarrowScreen, isNarrowScreenObs, theme, vars} from 'app/client/ui2018/cssVars';
|
||||||
import {icon} from 'app/client/ui2018/icons';
|
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_INITIAL_PADDING_PX = 16;
|
||||||
const POPUP_MIN_HEIGHT = 300;
|
const POPUP_MIN_HEIGHT = 300;
|
||||||
@ -15,9 +17,11 @@ export interface PopupOptions {
|
|||||||
content?: () => DomContents;
|
content?: () => DomContents;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
closeButton?: boolean;
|
closeButton?: boolean;
|
||||||
|
closeButtonHover?: () => DomContents;
|
||||||
autoHeight?: boolean;
|
autoHeight?: boolean;
|
||||||
/** Defaults to false. */
|
/** Defaults to false. */
|
||||||
stopClickPropagationOnMove?: boolean;
|
stopClickPropagationOnMove?: boolean;
|
||||||
|
args?: DomElementArg[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class FloatingPopup extends Disposable {
|
export class FloatingPopup extends Disposable {
|
||||||
@ -34,7 +38,7 @@ export class FloatingPopup extends Disposable {
|
|||||||
private _resize = false;
|
private _resize = false;
|
||||||
private _cursorGrab: IDisposable|null = null;
|
private _cursorGrab: IDisposable|null = null;
|
||||||
|
|
||||||
constructor(protected _options: PopupOptions = {}, private _args: DomArg[] = []) {
|
constructor(protected _options: PopupOptions = {}) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
if (_options.stopClickPropagationOnMove){
|
if (_options.stopClickPropagationOnMove){
|
||||||
@ -98,7 +102,7 @@ export class FloatingPopup extends Disposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected _buildArgs(): any {
|
protected _buildArgs(): any {
|
||||||
return this._args;
|
return this._options.args ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
private _rememberPosition() {
|
private _rememberPosition() {
|
||||||
@ -272,12 +276,12 @@ export class FloatingPopup extends Disposable {
|
|||||||
// Copy buttons on the left side of the header, to automatically
|
// Copy buttons on the left side of the header, to automatically
|
||||||
// center the title.
|
// center the title.
|
||||||
cssPopupButtons(
|
cssPopupButtons(
|
||||||
!this._options.closeButton ? null : cssPopupHeaderButton(
|
|
||||||
icon('CrossSmall'),
|
|
||||||
),
|
|
||||||
cssPopupHeaderButton(
|
cssPopupHeaderButton(
|
||||||
icon('Maximize')
|
icon('Maximize')
|
||||||
),
|
),
|
||||||
|
!this._options.closeButton ? null : cssPopupHeaderButton(
|
||||||
|
icon('CrossBig'),
|
||||||
|
),
|
||||||
dom.style('visibility', 'hidden'),
|
dom.style('visibility', 'hidden'),
|
||||||
),
|
),
|
||||||
cssPopupTitle(
|
cssPopupTitle(
|
||||||
@ -285,19 +289,20 @@ export class FloatingPopup extends Disposable {
|
|||||||
testId('title'),
|
testId('title'),
|
||||||
),
|
),
|
||||||
cssPopupButtons(
|
cssPopupButtons(
|
||||||
!this._options.closeButton ? null : cssPopupHeaderButton(
|
|
||||||
icon('CrossSmall'),
|
|
||||||
dom.on('click', () => {
|
|
||||||
this._options.onClose?.() ?? this._closePopup();
|
|
||||||
}),
|
|
||||||
testId('close'),
|
|
||||||
),
|
|
||||||
this._popupMinimizeButtonElement = cssPopupHeaderButton(
|
this._popupMinimizeButtonElement = cssPopupHeaderButton(
|
||||||
isMinimized ? icon('Maximize'): icon('Minimize'),
|
isMinimized ? icon('Maximize'): icon('Minimize'),
|
||||||
hoverTooltip(isMinimized ? 'Maximize' : 'Minimize', {key: 'docTutorialTooltip'}),
|
hoverTooltip(isMinimized ? 'Maximize' : 'Minimize', {key: 'docTutorialTooltip'}),
|
||||||
dom.on('click', () => this._minimizeOrMaximize()),
|
dom.on('click', () => this._minimizeOrMaximize()),
|
||||||
testId('minimize-maximize'),
|
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.
|
// Disable dragging when a button in the header is clicked.
|
||||||
dom.on('mousedown', ev => ev.stopPropagation()),
|
dom.on('mousedown', ev => ev.stopPropagation()),
|
||||||
dom.on('touchstart', ev => ev.stopPropagation()),
|
dom.on('touchstart', ev => ev.stopPropagation()),
|
||||||
@ -345,20 +350,7 @@ export class FloatingPopup extends Disposable {
|
|||||||
private _forceCursor() {
|
private _forceCursor() {
|
||||||
this._cursorGrab?.dispose();
|
this._cursorGrab?.dispose();
|
||||||
const type = this._resize ? 'ns-resize' : 'grabbing';
|
const type = this._resize ? 'ns-resize' : 'grabbing';
|
||||||
const cursorStyle: HTMLStyleElement = document.createElement('style');
|
this._cursorGrab = documentCursor(type);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -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_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 POPUP_WIDTH = `min(436px, calc(100% - (2 * ${POPUP_INITIAL_PADDING_PX}px)))`;
|
||||||
|
|
||||||
const cssPopup = styled('div', `
|
const cssPopup = styled('div.floating-popup', `
|
||||||
position: fixed;
|
position: fixed;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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>) {
|
export function autoGrow(text: Observable<string>) {
|
||||||
return (el: HTMLTextAreaElement) => {
|
return (el: HTMLTextAreaElement) => {
|
||||||
el.addEventListener('input', () => resize(el));
|
el.addEventListener('input', () => resize(el));
|
||||||
|
dom.autoDisposeElem(el, text.addListener(() => resize(el)));
|
||||||
setTimeout(() => resize(el), 10);
|
setTimeout(() => resize(el), 10);
|
||||||
dom.autoDisposeElem(el, text.addListener(val => {
|
dom.autoDisposeElem(el, text.addListener(val => {
|
||||||
// Changes to the text are not reflected by the input event (witch is used by the autoGrow)
|
// 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 {
|
export function tooltipCloseButton(ctl: ITooltipControl): HTMLElement {
|
||||||
return cssTooltipCloseButton(icon('CrossSmall'),
|
return cssTooltipCloseButton(icon('CrossSmall'),
|
||||||
dom.on('click', () => ctl.close()),
|
dom.on('mousedown', (ev) =>{
|
||||||
|
ev.stopPropagation();
|
||||||
|
ev.preventDefault();
|
||||||
|
ctl.close();
|
||||||
|
}),
|
||||||
testId('tooltip-close'),
|
testId('tooltip-close'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -113,6 +113,7 @@ export type IconName = "ChartArea" |
|
|||||||
"ResizePanel" |
|
"ResizePanel" |
|
||||||
"Revert" |
|
"Revert" |
|
||||||
"RightAlign" |
|
"RightAlign" |
|
||||||
|
"Robot" |
|
||||||
"Script" |
|
"Script" |
|
||||||
"Search" |
|
"Search" |
|
||||||
"Settings" |
|
"Settings" |
|
||||||
@ -254,6 +255,7 @@ export const IconList: IconName[] = ["ChartArea",
|
|||||||
"ResizePanel",
|
"ResizePanel",
|
||||||
"Revert",
|
"Revert",
|
||||||
"RightAlign",
|
"RightAlign",
|
||||||
|
"Robot",
|
||||||
"Script",
|
"Script",
|
||||||
"Search",
|
"Search",
|
||||||
"Settings",
|
"Settings",
|
||||||
|
@ -200,6 +200,7 @@ export class ConditionalStyle extends Disposable {
|
|||||||
editRow: vsi?.moveEditRowToCursor(),
|
editRow: vsi?.moveEditRowToCursor(),
|
||||||
refElem,
|
refElem,
|
||||||
setupCleanup: setupEditorCleanup,
|
setupCleanup: setupEditorCleanup,
|
||||||
|
canDetach: false,
|
||||||
});
|
});
|
||||||
// Add editor to document holder - this will prevent multiple formula editor instances.
|
// Add editor to document holder - this will prevent multiple formula editor instances.
|
||||||
this._gristDoc.fieldEditorHolder.autoDispose(editorHolder);
|
this._gristDoc.fieldEditorHolder.autoDispose(editorHolder);
|
||||||
|
@ -4,6 +4,8 @@ import { FormulaTransform } from 'app/client/components/FormulaTransform';
|
|||||||
import { GristDoc } from 'app/client/components/GristDoc';
|
import { GristDoc } from 'app/client/components/GristDoc';
|
||||||
import { addColTypeSuffix } from 'app/client/components/TypeConversion';
|
import { addColTypeSuffix } from 'app/client/components/TypeConversion';
|
||||||
import { TypeTransform } from 'app/client/components/TypeTransform';
|
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 dom from 'app/client/lib/dom';
|
||||||
import { KoArray } from 'app/client/lib/koArray';
|
import { KoArray } from 'app/client/lib/koArray';
|
||||||
import * as kd from 'app/client/lib/koDom';
|
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 { IOptionFull, menu, select } from 'app/client/ui2018/menus';
|
||||||
import { DiffBox } from 'app/client/widgets/DiffBox';
|
import { DiffBox } from 'app/client/widgets/DiffBox';
|
||||||
import { buildErrorDom } from 'app/client/widgets/ErrorDom';
|
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 { CellDiscussionPopup, EmptyCell } from 'app/client/widgets/DiscussionEditor';
|
||||||
import { openFormulaEditor } from 'app/client/widgets/FormulaEditor';
|
import { openFormulaEditor } from 'app/client/widgets/FormulaEditor';
|
||||||
import { NewAbstractWidget } from 'app/client/widgets/NewAbstractWidget';
|
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 { getReferencedTableId, isFullReferencingType } from 'app/common/gristTypes';
|
||||||
import { CellValue } from 'app/plugin/GristData';
|
import { CellValue } from 'app/plugin/GristData';
|
||||||
import { Computed, Disposable, fromKo, dom as grainjsDom,
|
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 ko from 'knockout';
|
||||||
import * as _ from 'underscore';
|
import * as _ from 'underscore';
|
||||||
|
|
||||||
@ -100,19 +102,19 @@ export class FieldBuilder extends Disposable {
|
|||||||
private readonly _rowMap: Map<DataRowModel, Element>;
|
private readonly _rowMap: Map<DataRowModel, Element>;
|
||||||
private readonly _isTransformingFormula: ko.Computed<boolean>;
|
private readonly _isTransformingFormula: ko.Computed<boolean>;
|
||||||
private readonly _isTransformingType: 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 _widgetCons: ko.Computed<{create: (...args: any[]) => NewAbstractWidget}>;
|
||||||
private readonly _docModel: DocModel;
|
private readonly _docModel: DocModel;
|
||||||
private readonly _readonly: Computed<boolean>;
|
private readonly _readonly: Computed<boolean>;
|
||||||
private readonly _comments: ko.Computed<boolean>;
|
private readonly _comments: ko.Computed<boolean>;
|
||||||
private readonly _showRefConfigPopup: ko.Observable<boolean>;
|
private readonly _showRefConfigPopup: ko.Observable<boolean>;
|
||||||
|
private readonly _isEditorActive = Observable.create(this, false);
|
||||||
|
|
||||||
public constructor(public readonly gristDoc: GristDoc, public readonly field: ViewFieldRec,
|
public constructor(public readonly gristDoc: GristDoc, public readonly field: ViewFieldRec,
|
||||||
private _cursor: Cursor, private _options: { isPreview?: boolean } = {}) {
|
private _cursor: Cursor, private _options: { isPreview?: boolean } = {}) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this._docModel = gristDoc.docModel;
|
this._docModel = gristDoc.docModel;
|
||||||
this.origColumn = field.column();
|
this.origColumn = field.origCol();
|
||||||
this.options = field.widgetOptionsJson;
|
this.options = field.widgetOptionsJson;
|
||||||
this._comments = ko.pureComputed(() => toKo(ko, COMMENTS())());
|
this._comments = ko.pureComputed(() => toKo(ko, COMMENTS())());
|
||||||
|
|
||||||
@ -183,10 +185,6 @@ export class FieldBuilder extends Disposable {
|
|||||||
(this.columnTransform instanceof TypeTransform);
|
(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.
|
// Map from rowModel to cell dom for the field to which this fieldBuilder applies.
|
||||||
this._rowMap = new Map();
|
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.
|
if (this.isDisposed()) { return null; } // Work around JS errors during field removal.
|
||||||
const value = row.cells[this.field.colId()];
|
const value = row.cells[this.field.colId()];
|
||||||
const cell = value && value();
|
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();
|
return this.widgetImpl();
|
||||||
} else if (gristTypes.isVersions(cell)) {
|
} else if (gristTypes.isVersions(cell)) {
|
||||||
return this.diffImpl;
|
return this.diffImpl;
|
||||||
@ -677,39 +675,40 @@ export class FieldBuilder extends Disposable {
|
|||||||
return;
|
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.
|
// 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 cell = editRow.cells[this.field.colId()];
|
||||||
const value = cell && cell();
|
const value = cell && cell();
|
||||||
if (gristTypes.isCensored(value) && !this.origColumn.isFormula.peek()) {
|
if (gristTypes.isCensored(value) && !this.origColumn.isFormula.peek()) {
|
||||||
this._fieldEditorHolder.clear();
|
return clearOwn();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const editorCtor: typeof NewBaseEditor =
|
const editorCtor: typeof NewBaseEditor =
|
||||||
UserTypeImpl.getEditorConstructor(this.options().widget, this._readOnlyPureType());
|
UserTypeImpl.getEditorConstructor(this.options().widget, this._readOnlyPureType());
|
||||||
// constructor may be null for a read-only non-formula field, though not today.
|
// constructor may be null for a read-only non-formula field, though not today.
|
||||||
if (!editorCtor) {
|
if (!editorCtor) {
|
||||||
// Actually, we only expect buildEditorDom() to be called when isEditorActive() is false (i.e.
|
return clearOwn();
|
||||||
// _fieldEditorHolder is already clear), but clear here explicitly for clarity.
|
|
||||||
this._fieldEditorHolder.clear();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// if editor doesn't support readonly mode, don't show it
|
|
||||||
if (this._readonly.get() && editorCtor.supportsReadonly && !editorCtor.supportsReadonly()) {
|
if (this._readonly.get() && editorCtor.supportsReadonly && !editorCtor.supportsReadonly()) {
|
||||||
this._fieldEditorHolder.clear();
|
return clearOwn();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this._readonly.get() && saveWithoutEditor(editorCtor, editRow, this.field, options.init)) {
|
if (!this._readonly.get() && saveWithoutEditor(editorCtor, editRow, this.field, options.init)) {
|
||||||
this._fieldEditorHolder.clear();
|
return clearOwn();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const cellElem = this._rowMap.get(mainRowModel)!;
|
const cellElem = this._rowMap.get(mainRowModel)!;
|
||||||
|
|
||||||
// The editor may dispose itself; the Holder will know to clear itself in this case.
|
// 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,
|
gristDoc: this.gristDoc,
|
||||||
field: this.field,
|
field: this.field,
|
||||||
cursor: this._cursor,
|
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
|
startVal: this._readonly.get() ? undefined : options.init, // don't start with initial value
|
||||||
readonly: this._readonly.get() // readonly for editor will not be observable
|
readonly: this._readonly.get() // readonly for editor will not be observable
|
||||||
});
|
});
|
||||||
|
this._isEditorActive.set(true);
|
||||||
// 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);
|
|
||||||
|
|
||||||
// expose the active editor in a grist doc as an observable
|
// 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);
|
this.gristDoc.activeEditor.set(fieldEditor);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -742,11 +739,12 @@ export class FieldBuilder extends Disposable {
|
|||||||
if (editRow._isAddRow.peek() || this._readonly.get()) {
|
if (editRow._isAddRow.peek() || this._readonly.get()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const holder = this.gristDoc.fieldEditorHolder;
|
||||||
|
|
||||||
const cell = editRow.cells[this.field.colId()];
|
const cell = editRow.cells[this.field.colId()];
|
||||||
const value = cell && cell();
|
const value = cell && cell();
|
||||||
if (gristTypes.isCensored(value)) {
|
if (gristTypes.isCensored(value)) {
|
||||||
this._fieldEditorHolder.clear();
|
holder.clear();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -770,7 +768,8 @@ export class FieldBuilder extends Disposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public isEditorActive() {
|
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,
|
editValue?: string,
|
||||||
onSave?: (column: ColumnRec, formula: string) => Promise<void>,
|
onSave?: (column: ColumnRec, formula: string) => Promise<void>,
|
||||||
onCancel?: () => 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,
|
gristDoc: this.gristDoc,
|
||||||
column: this.field.column(),
|
field,
|
||||||
editingFormula: this.field.editingFormula,
|
editingFormula: this.field.editingFormula,
|
||||||
setupCleanup: setupEditorCleanup,
|
setupCleanup: setupEditorCleanup,
|
||||||
editRow,
|
editRow,
|
||||||
refElem,
|
refElem,
|
||||||
editValue,
|
editValue,
|
||||||
|
canDetach: true,
|
||||||
onSave,
|
onSave,
|
||||||
onCancel
|
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.
|
// 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 * 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 {GristDoc} from 'app/client/components/GristDoc';
|
||||||
import {UnsavedChange} from 'app/client/components/UnsavedChanges';
|
import {UnsavedChange} from 'app/client/components/UnsavedChanges';
|
||||||
import {makeT} from 'app/client/lib/localization';
|
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 {asyncOnce} from "app/common/AsyncCreate";
|
||||||
import {CellValue} from "app/common/DocActions";
|
import {CellValue} from "app/common/DocActions";
|
||||||
import * as gutil from 'app/common/gutil';
|
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 {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;
|
type IEditorConstructor = typeof NewBaseEditor;
|
||||||
|
|
||||||
@ -63,6 +64,7 @@ export class FieldEditor extends Disposable {
|
|||||||
public readonly saveEmitter = this.autoDispose(new Emitter());
|
public readonly saveEmitter = this.autoDispose(new Emitter());
|
||||||
public readonly cancelEmitter = this.autoDispose(new Emitter());
|
public readonly cancelEmitter = this.autoDispose(new Emitter());
|
||||||
public readonly changeEmitter = this.autoDispose(new Emitter());
|
public readonly changeEmitter = this.autoDispose(new Emitter());
|
||||||
|
public floatingEditor: FloatingEditor;
|
||||||
|
|
||||||
private _gristDoc: GristDoc;
|
private _gristDoc: GristDoc;
|
||||||
private _field: ViewFieldRec;
|
private _field: ViewFieldRec;
|
||||||
@ -76,6 +78,8 @@ export class FieldEditor extends Disposable {
|
|||||||
private _editorHasChanged = false;
|
private _editorHasChanged = false;
|
||||||
private _isFormula = false;
|
private _isFormula = false;
|
||||||
private _readonly = false;
|
private _readonly = false;
|
||||||
|
private _detached = Observable.create(this, false);
|
||||||
|
private _detachedAt: CursorPos|null = null;
|
||||||
|
|
||||||
constructor(options: {
|
constructor(options: {
|
||||||
gristDoc: GristDoc,
|
gristDoc: GristDoc,
|
||||||
@ -154,6 +158,9 @@ export class FieldEditor extends Disposable {
|
|||||||
|
|
||||||
this.rebuildEditor(editValue, Number.POSITIVE_INFINITY, options.state);
|
this.rebuildEditor(editValue, Number.POSITIVE_INFINITY, options.state);
|
||||||
|
|
||||||
|
// Create a floating editor, which will be used to display the editor in a popup.
|
||||||
|
this.floatingEditor = FloatingEditor.create(this, this, this._gristDoc);
|
||||||
|
|
||||||
if (offerToMakeFormula) {
|
if (offerToMakeFormula) {
|
||||||
this._offerToMakeFormula();
|
this._offerToMakeFormula();
|
||||||
}
|
}
|
||||||
@ -162,9 +169,16 @@ export class FieldEditor extends Disposable {
|
|||||||
// when user or server refreshes the browser
|
// when user or server refreshes the browser
|
||||||
this._gristDoc.editorMonitor.monitorEditor(this);
|
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
|
// for readonly field we don't need to do anything special
|
||||||
if (!options.readonly) {
|
if (!options.readonly) {
|
||||||
setupEditorCleanup(this, this._gristDoc, this._field.editingFormula, this._saveEdit);
|
setupEditorCleanup(this, this._gristDoc, this._field.editingFormula, onCleanup);
|
||||||
} else {
|
} else {
|
||||||
setupReadonlyEditorCleanup(this, this._gristDoc, this._field, () => this._cancelEdit());
|
setupReadonlyEditorCleanup(this, this._gristDoc, this._field, () => this._cancelEdit());
|
||||||
}
|
}
|
||||||
@ -190,7 +204,13 @@ export class FieldEditor extends Disposable {
|
|||||||
cellValue = cellCurrentValue;
|
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
|
// For readonly mode use the default behavior of Formula Editor
|
||||||
// TODO: cleanup this flag - it gets modified in too many places
|
// 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
|
// 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),
|
// 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.
|
// 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;
|
this._editorHasChanged = false;
|
||||||
// Replace the item in the Holder with a new one, disposing the previous one.
|
// Replace the item in the Holder with a new one, disposing the previous one.
|
||||||
const editor = this._editorHolder.autoDispose(editorCtor.create({
|
const editor = this._editorHolder.autoDispose(editorCtor.create({
|
||||||
@ -214,10 +236,13 @@ export class FieldEditor extends Disposable {
|
|||||||
editValue,
|
editValue,
|
||||||
cursorPos,
|
cursorPos,
|
||||||
state,
|
state,
|
||||||
|
canDetach: true,
|
||||||
commands: this._editCommands,
|
commands: this._editCommands,
|
||||||
readonly : this._readonly
|
readonly : this._readonly
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
editor.autoDispose(errorHolder);
|
||||||
|
|
||||||
// if editor supports live changes, connect it to the change emitter
|
// if editor supports live changes, connect it to the change emitter
|
||||||
if (editor.editorState) {
|
if (editor.editorState) {
|
||||||
editor.autoDispose(editor.editorState.addListener((currentState) => {
|
editor.autoDispose(editor.editorState.addListener((currentState) => {
|
||||||
@ -235,6 +260,28 @@ export class FieldEditor extends Disposable {
|
|||||||
editor.attach(this._cellElem);
|
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() {
|
public getDom() {
|
||||||
return this._editorHolder.get()?.getDom();
|
return this._editorHolder.get()?.getDom();
|
||||||
}
|
}
|
||||||
@ -242,7 +289,7 @@ export class FieldEditor extends Disposable {
|
|||||||
// calculate current cell's absolute position
|
// calculate current cell's absolute position
|
||||||
public cellPosition() {
|
public cellPosition() {
|
||||||
const rowId = this._editRow.getRowId();
|
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 sectionId = this._field.viewSection.peek().id.peek();
|
||||||
const position = {
|
const position = {
|
||||||
rowId,
|
rowId,
|
||||||
@ -344,7 +391,7 @@ export class FieldEditor extends Disposable {
|
|||||||
col.updateColValues({isFormula, formula}),
|
col.updateColValues({isFormula, formula}),
|
||||||
// If we're saving a non-empty formula, then also add an empty record to the table
|
// 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.
|
// 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),
|
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 * 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 {makeT} from 'app/client/lib/localization';
|
||||||
import {DataRowModel} from 'app/client/models/DataRowModel';
|
import {DataRowModel} from 'app/client/models/DataRowModel';
|
||||||
|
import {ColumnRec} from 'app/client/models/DocModel';
|
||||||
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
||||||
|
import {reportError} from 'app/client/models/errors';
|
||||||
|
import {GRIST_FORMULA_ASSISTANT} from 'app/client/models/features';
|
||||||
import {colors, testId, theme} from 'app/client/ui2018/cssVars';
|
import {colors, testId, theme} from 'app/client/ui2018/cssVars';
|
||||||
import {icon} from 'app/client/ui2018/icons';
|
import {icon} from 'app/client/ui2018/icons';
|
||||||
import {createMobileButtons, getButtonMargins} from 'app/client/widgets/EditorButtons';
|
import {createMobileButtons, getButtonMargins} from 'app/client/widgets/EditorButtons';
|
||||||
import {EditorPlacement, ISize} from 'app/client/widgets/EditorPlacement';
|
import {EditorPlacement, ISize} from 'app/client/widgets/EditorPlacement';
|
||||||
|
import {createDetachedIcon} from 'app/client/widgets/FloatingEditor';
|
||||||
|
import {buildRobotIcon, FormulaAssistant} from 'app/client/widgets/FormulaAssistant';
|
||||||
import {NewBaseEditor, Options} from 'app/client/widgets/NewBaseEditor';
|
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 {asyncOnce} from 'app/common/AsyncCreate';
|
||||||
import {reportError} from 'app/client/models/errors';
|
|
||||||
import {CellValue} from 'app/common/DocActions';
|
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');
|
import debounce = require('lodash/debounce');
|
||||||
|
|
||||||
// How wide to expand the FormulaEditor when an error is shown in it.
|
// 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 {
|
export interface IFormulaEditorOptions extends Options {
|
||||||
cssClass?: string;
|
cssClass?: string;
|
||||||
editingFormula: ko.Computed<boolean>,
|
editingFormula: ko.Computed<boolean>;
|
||||||
column: ColumnRec,
|
column: ColumnRec;
|
||||||
|
field?: ViewFieldRec;
|
||||||
|
canDetach?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -42,10 +48,13 @@ export interface IFormulaEditorOptions extends Options {
|
|||||||
* should save the value on `blur` event.
|
* should save the value on `blur` event.
|
||||||
*/
|
*/
|
||||||
export class FormulaEditor extends NewBaseEditor {
|
export class FormulaEditor extends NewBaseEditor {
|
||||||
|
public isDetached = Observable.create(this, false);
|
||||||
|
protected options: IFormulaEditorOptions;
|
||||||
|
|
||||||
private _formulaEditor: any;
|
private _formulaEditor: any;
|
||||||
private _commandGroup: any;
|
|
||||||
private _dom: HTMLElement;
|
private _dom: HTMLElement;
|
||||||
private _editorPlacement!: EditorPlacement;
|
private _editorPlacement!: EditorPlacement;
|
||||||
|
private _placementHolder = Holder.create(this);
|
||||||
|
|
||||||
constructor(options: IFormulaEditorOptions) {
|
constructor(options: IFormulaEditorOptions) {
|
||||||
super(options);
|
super(options);
|
||||||
@ -67,19 +76,50 @@ export class FormulaEditor extends NewBaseEditor {
|
|||||||
readonly: options.readonly
|
readonly: options.readonly
|
||||||
});
|
});
|
||||||
|
|
||||||
const allCommands = !options.readonly
|
|
||||||
? Object.assign({ setCursor: this._onSetCursor }, options.commands)
|
// For editable editor we will grab the cursor when we are in the formula editing mode.
|
||||||
// for readonly mode don't grab cursor when clicked away - just move the cursor
|
const cursorCommands = options.readonly ? {} : { setCursor: this._onSetCursor };
|
||||||
: options.commands;
|
const isActive = Computed.create(this, use => Boolean(use(editingFormula)));
|
||||||
this._commandGroup = this.autoDispose(createGroup(allCommands, this, 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 hideErrDetails = Observable.create(this, true);
|
||||||
const raisedException = Computed.create(this, use => {
|
const raisedException = Computed.create(this, use => {
|
||||||
if (!options.formulaError) {
|
if (!options.formulaError || !use(options.formulaError)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const error = isRaisedException(use(options.formulaError)) ?
|
const error = isRaisedException(use(options.formulaError)!) ?
|
||||||
decodeObject(use(options.formulaError)) as RaisedException:
|
decodeObject(use(options.formulaError)!) as RaisedException:
|
||||||
new RaisedException(["Unknown error"]);
|
new RaisedException(["Unknown error"]);
|
||||||
return 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
|
// Once the exception details are available, update the sizing. The extra delay is to allow
|
||||||
// the DOM to update before resizing.
|
// 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.autoDispose(this._formulaEditor);
|
||||||
this._dom = dom('div.default_editor.formula_editor_wrapper',
|
this._dom = cssFormulaEditor(
|
||||||
|
buildRobotIcon(),
|
||||||
// switch border shadow
|
// switch border shadow
|
||||||
dom.cls("readonly_editor", options.readonly),
|
dom.cls("readonly_editor", options.readonly),
|
||||||
createMobileButtons(options.commands),
|
createMobileButtons(options.commands),
|
||||||
@ -109,9 +152,28 @@ export class FormulaEditor extends NewBaseEditor {
|
|||||||
|
|
||||||
// This shouldn't be needed, but needed for tests.
|
// This shouldn't be needed, but needed for tests.
|
||||||
dom.on('mousedown', (ev) => {
|
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();
|
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'),
|
dom('div.formula_editor.formula_field_edit', testId('formula-editor'),
|
||||||
this._formulaEditor.buildDom((aceObj: any) => {
|
this._formulaEditor.buildDom((aceObj: any) => {
|
||||||
aceObj.setFontSize(11);
|
aceObj.setFontSize(11);
|
||||||
@ -121,7 +183,7 @@ export class FormulaEditor extends NewBaseEditor {
|
|||||||
const val = initialValue;
|
const val = initialValue;
|
||||||
const pos = Math.min(options.cursorPos, val.length);
|
const pos = Math.min(options.cursorPos, val.length);
|
||||||
this._formulaEditor.setValue(val, pos);
|
this._formulaEditor.setValue(val, pos);
|
||||||
this._formulaEditor.attachCommandGroup(this._commandGroup);
|
this._formulaEditor.attachCommandGroup(aceCommands);
|
||||||
|
|
||||||
// enable formula editing if state was passed
|
// enable formula editing if state was passed
|
||||||
if (options.state || options.readonly) {
|
if (options.state || options.readonly) {
|
||||||
@ -132,48 +194,74 @@ export class FormulaEditor extends NewBaseEditor {
|
|||||||
aceObj.gotoLine(0, 0); // By moving, ace editor won't highlight anything
|
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.
|
// 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('div.error_msg', testId('formula-error-msg'),
|
||||||
dom.on('click', () => {
|
dom.on('click', () => {
|
||||||
if (errorDetails.get()){
|
if (this.isDetached.get()) { return; }
|
||||||
hideErrDetails.set(!hideErrDetails.get());
|
if (errorDetails.get()){
|
||||||
this._formulaEditor.resize();
|
hideErrDetails.set(!hideErrDetails.get());
|
||||||
}
|
this._formulaEditor.resize();
|
||||||
}),
|
}
|
||||||
dom.maybe(errorDetails, () =>
|
}),
|
||||||
dom.domComputed(hideErrDetails, (hide) => cssCollapseIcon(hide ? 'Expand' : 'Collapse'))
|
dom.maybe(errorDetails, () =>
|
||||||
),
|
dom.domComputed(hideErrDetails, (hide) => cssCollapseIcon(
|
||||||
dom.text(errorText),
|
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.maybe(use => Boolean(use(errorDetails) && !use(hideErrDetails)), () =>
|
dom.text(errorText),
|
||||||
dom('div.error_details',
|
),
|
||||||
dom('div.error_details_inner',
|
dom.maybe(use => Boolean(use(errorDetails) && !use(hideErrDetails)), () =>
|
||||||
dom.text(errorDetails),
|
dom('div.error_details',
|
||||||
),
|
dom('div.error_details_inner',
|
||||||
testId('formula-error-details'),
|
dom.text(errorDetails),
|
||||||
)
|
),
|
||||||
|
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 {
|
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).
|
// Reposition the editor if needed for external reasons (in practice, window resize).
|
||||||
this.autoDispose(this._editorPlacement.onReposition.addListener(
|
this.autoDispose(this._editorPlacement.onReposition.addListener(this._formulaEditor.resize, this._formulaEditor));
|
||||||
this._formulaEditor.resize, this._formulaEditor));
|
|
||||||
this._formulaEditor.onAttach();
|
this._formulaEditor.onAttach();
|
||||||
this._formulaEditor.editor.focus();
|
this._formulaEditor.resize();
|
||||||
|
this.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
public getDom(): HTMLElement {
|
public getDom(): HTMLElement {
|
||||||
return this._dom;
|
return this._dom;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public setFormula(formula: string) {
|
||||||
|
this._formulaEditor.setValue(formula);
|
||||||
|
}
|
||||||
|
|
||||||
public getCellValue() {
|
public getCellValue() {
|
||||||
const value = this._formulaEditor.getValue();
|
const value = this._formulaEditor.getValue();
|
||||||
// Strip the leading "=" sign, if any, in case users think it should start the formula body (as
|
// 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());
|
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) {
|
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 errorBox: HTMLElement|null = this._dom.querySelector('.error_details');
|
||||||
const errorBoxStartHeight = errorBox?.getBoundingClientRect().height || 0;
|
const errorBoxStartHeight = errorBox?.getBoundingClientRect().height || 0;
|
||||||
const errorBoxDesiredHeight = errorBox?.scrollHeight || 0;
|
const errorBoxDesiredHeight = errorBox?.scrollHeight || 0;
|
||||||
|
|
||||||
// If we have an error to show, ask for a larger size for formulaEditor.
|
// If we have an error to show, ask for a larger size for formulaEditor.
|
||||||
const desiredSize = {
|
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.
|
// Ask for extra space for the error; we'll decide how to allocate it below.
|
||||||
height: desiredElemSize.height + (errorBoxDesiredHeight - errorBoxStartHeight),
|
height: desiredElemSize.height + (errorBoxDesiredHeight - errorBoxStartHeight),
|
||||||
};
|
};
|
||||||
@ -216,35 +337,42 @@ export class FormulaEditor extends NewBaseEditor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TODO: update regexes to unicode?
|
// TODO: update regexes to unicode?
|
||||||
private _onSetCursor(row: DataRowModel, col: ViewFieldRec) {
|
private _onSetCursor(row?: DataRowModel, col?: ViewFieldRec) {
|
||||||
|
// Don't do anything when we are readonly.
|
||||||
if (!col) { return; } // if clicked on row header, no col to insert
|
|
||||||
|
|
||||||
if (this.options.readonly) { return; }
|
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();
|
const aceObj = this._formulaEditor.getEditor();
|
||||||
|
|
||||||
if (!aceObj.selection.isEmpty()) { // If text selected, replace whole selection
|
// Rect only to columns in the same table.
|
||||||
aceObj.session.replace(aceObj.selection.getRange(), '$' + col.colId());
|
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 pos = aceObj.getCursorPosition();
|
||||||
const line = aceObj.session.getLine(pos.row);
|
const line = aceObj.session.getLine(pos.row);
|
||||||
const result = _isInIdentifier(line, pos.column); // returns {start, end, id} | null
|
const result = _isInIdentifier(line, pos.column); // returns {start, end, id} | null
|
||||||
|
if (!result) {
|
||||||
if (!result) { // Not touching an identifier, insert colId as normal
|
// Not touching an identifier, insert colId as normal
|
||||||
aceObj.insert("$" + col.colId());
|
aceObj.insert('$' + colId);
|
||||||
|
// We are touching an identifier
|
||||||
// We are touching an identifier
|
} else if (result.ident.startsWith('$')) {
|
||||||
} else if (result.ident.startsWith("$")) { // If ident is a colId, replace it
|
// If ident is a colId, replace it
|
||||||
|
const idRange = AceEditor.makeRange(pos.row, result.start, pos.row, result.end);
|
||||||
const idRange = AceEditor.makeRange(pos.row, result.start, pos.row, result.end);
|
aceObj.session.replace(idRange, '$' + colId);
|
||||||
aceObj.session.replace(idRange, "$" + col.colId());
|
|
||||||
}
|
}
|
||||||
|
// Else touching a normal identifier, don't mangle it
|
||||||
// Else touching a normal identifier, dont mangle it
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resize editor in case it is needed.
|
// Resize editor in case it is needed.
|
||||||
this._formulaEditor.resize();
|
this._formulaEditor.resize();
|
||||||
aceObj.focus();
|
aceObj.focus();
|
||||||
@ -276,6 +404,7 @@ export function openFormulaEditor(options: {
|
|||||||
gristDoc: GristDoc,
|
gristDoc: GristDoc,
|
||||||
// Associated formula from a different column (for example style rule).
|
// Associated formula from a different column (for example style rule).
|
||||||
column?: ColumnRec,
|
column?: ColumnRec,
|
||||||
|
// Associated formula from a view field. If provided together with column, this field is used
|
||||||
field?: ViewFieldRec,
|
field?: ViewFieldRec,
|
||||||
editingFormula?: ko.Computed<boolean>,
|
editingFormula?: ko.Computed<boolean>,
|
||||||
// Needed to get exception value, if any.
|
// Needed to get exception value, if any.
|
||||||
@ -285,24 +414,38 @@ export function openFormulaEditor(options: {
|
|||||||
editValue?: string,
|
editValue?: string,
|
||||||
onSave?: (column: ColumnRec, formula: string) => Promise<void>,
|
onSave?: (column: ColumnRec, formula: string) => Promise<void>,
|
||||||
onCancel?: () => void,
|
onCancel?: () => void,
|
||||||
|
canDetach?: boolean,
|
||||||
// Called after editor is created to set up editor cleanup (e.g. saving on click-away).
|
// Called after editor is created to set up editor cleanup (e.g. saving on click-away).
|
||||||
setupCleanup: (
|
setupCleanup: (
|
||||||
owner: MultiHolder,
|
owner: Disposable,
|
||||||
doc: GristDoc,
|
doc: GristDoc,
|
||||||
editingFormula: ko.Computed<boolean>,
|
editingFormula: ko.Computed<boolean>,
|
||||||
save: () => Promise<void>
|
save: () => Promise<void>
|
||||||
) => void,
|
) => void,
|
||||||
}): Disposable {
|
}): FormulaEditor {
|
||||||
const {gristDoc, editRow, refElem, setupCleanup} = options;
|
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();
|
const column = options.column ?? options.field?.column();
|
||||||
|
|
||||||
if (!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.
|
// AsyncOnce ensures it's called once even if triggered multiple times.
|
||||||
const saveEdit = asyncOnce(async () => {
|
const saveEdit = asyncOnce(async () => {
|
||||||
|
const detached = editor.isDetached.get();
|
||||||
|
if (detached) {
|
||||||
|
editor.dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const formula = String(editor.getCellValue());
|
const formula = String(editor.getCellValue());
|
||||||
if (formula !== column.formula.peek()) {
|
if (formula !== column.formula.peek()) {
|
||||||
if (options.onSave) {
|
if (options.onSave) {
|
||||||
@ -310,9 +453,9 @@ export function openFormulaEditor(options: {
|
|||||||
} else {
|
} else {
|
||||||
await column.updateColValues({formula});
|
await column.updateColValues({formula});
|
||||||
}
|
}
|
||||||
holder.dispose();
|
editor.dispose();
|
||||||
} else {
|
} else {
|
||||||
holder.dispose();
|
editor.dispose();
|
||||||
options.onCancel?.();
|
options.onCancel?.();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -321,23 +464,31 @@ export function openFormulaEditor(options: {
|
|||||||
const editCommands = {
|
const editCommands = {
|
||||||
fieldEditSave: () => { saveEdit().catch(reportError); },
|
fieldEditSave: () => { saveEdit().catch(reportError); },
|
||||||
fieldEditSaveHere: () => { 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 formulaError = editRow ? getFormulaError(attachedHolder, {
|
||||||
const editor = FormulaEditor.create(holder, {
|
gristDoc,
|
||||||
|
editRow,
|
||||||
|
column,
|
||||||
|
field: options.field,
|
||||||
|
}) : undefined;
|
||||||
|
const editor = FormulaEditor.create(null, {
|
||||||
gristDoc,
|
gristDoc,
|
||||||
column,
|
column,
|
||||||
|
field: options.field,
|
||||||
editingFormula: options.editingFormula,
|
editingFormula: options.editingFormula,
|
||||||
rowId: editRow ? editRow.id() : 0,
|
rowId: editRow ? editRow.id() : 0,
|
||||||
cellValue: column.formula(),
|
cellValue: column.formula(),
|
||||||
formulaError: editRow ? getFormulaError(gristDoc, editRow, column) : undefined,
|
formulaError,
|
||||||
editValue: options.editValue,
|
editValue: options.editValue,
|
||||||
cursorPos: Number.POSITIVE_INFINITY, // Position of the caret within the editor.
|
cursorPos: Number.POSITIVE_INFINITY, // Position of the caret within the editor.
|
||||||
commands: editCommands,
|
commands: editCommands,
|
||||||
cssClass: 'formula_editor_sidepane',
|
cssClass: 'formula_editor_sidepane',
|
||||||
readonly : false
|
readonly : false,
|
||||||
} as IFormulaEditorOptions);
|
canDetach: options.canDetach
|
||||||
|
} as IFormulaEditorOptions) as FormulaEditor;
|
||||||
|
editor.autoDispose(attachedHolder);
|
||||||
editor.attach(refElem);
|
editor.attach(refElem);
|
||||||
|
|
||||||
const editingFormula = options.editingFormula ?? options?.field?.editingFormula;
|
const editingFormula = options.editingFormula ?? options?.field?.editingFormula;
|
||||||
@ -353,30 +504,92 @@ export function openFormulaEditor(options: {
|
|||||||
if (!column.formula()) {
|
if (!column.formula()) {
|
||||||
editingFormula(true);
|
editingFormula(true);
|
||||||
}
|
}
|
||||||
setupCleanup(holder, gristDoc, editingFormula, saveEdit);
|
setupCleanup(editor, gristDoc, editingFormula, saveEdit);
|
||||||
return holder;
|
return editor;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If the cell at the given row and column is a formula value containing an exception, return an
|
* 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.
|
* observable with this exception, and fetch more details to add to the observable.
|
||||||
*/
|
*/
|
||||||
export function getFormulaError(
|
export function getFormulaError(owner: Disposable, options: {
|
||||||
gristDoc: GristDoc, editRow: DataRowModel, column: ColumnRec
|
gristDoc: GristDoc,
|
||||||
): Observable<CellValue>|undefined {
|
editRow: DataRowModel,
|
||||||
const colId = column.colId.peek();
|
column?: ColumnRec,
|
||||||
const cellCurrentValue = editRow.cells[colId].peek();
|
field?: ViewFieldRec,
|
||||||
const isFormula = column.isFormula() || column.hasTriggerFormula();
|
}): Observable<CellValue|undefined> {
|
||||||
if (isFormula && isRaisedException(cellCurrentValue)) {
|
const {gristDoc, editRow} = options;
|
||||||
const formulaError = Observable.create(null, cellCurrentValue);
|
const formulaError = Observable.create(owner, undefined as any);
|
||||||
gristDoc.docData.getFormulaError(column.table().tableId(), colId, editRow.getRowId())
|
// When we don't have a field information we don't need to be reactive at all.
|
||||||
.then(value => {
|
if (!options.field) {
|
||||||
formulaError.set(value);
|
const column = options.column!;
|
||||||
})
|
const colId = column.colId.peek();
|
||||||
.catch(reportError);
|
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;
|
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 undefined;
|
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)) {
|
||||||
|
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((er) => {
|
||||||
|
if (!holder.isDisposed()) {
|
||||||
|
reportError(er);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
formulaError.set(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -429,8 +642,44 @@ export function createFormulaErrorObs(owner: MultiHolder, gristDoc: GristDoc, or
|
|||||||
const cssCollapseIcon = styled(icon, `
|
const cssCollapseIcon = styled(icon, `
|
||||||
margin: -3px 4px 0 4px;
|
margin: -3px 4px 0 4px;
|
||||||
--icon-color: ${colors.slate};
|
--icon-color: ${colors.slate};
|
||||||
|
cursor: pointer;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
export const cssError = styled('div', `
|
export const cssError = styled('div', `
|
||||||
color: ${theme.errorText};
|
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 ?? ""));
|
options.editValue, String(options.cellValue ?? ""));
|
||||||
this.editorState = Observable.create<string>(this, initialValue);
|
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._alignment = options.field.widgetOptionsJson.peek().alignment || 'left';
|
||||||
this._dom =
|
this._dom =
|
||||||
dom('div.default_editor',
|
dom('div.default_editor',
|
||||||
|
@ -17,7 +17,7 @@ export interface Options {
|
|||||||
gristDoc: GristDoc;
|
gristDoc: GristDoc;
|
||||||
cellValue: CellValue;
|
cellValue: CellValue;
|
||||||
rowId: number;
|
rowId: number;
|
||||||
formulaError?: Observable<CellValue>;
|
formulaError: Observable<CellValue|undefined>;
|
||||||
editValue?: string;
|
editValue?: string;
|
||||||
cursorPos: number;
|
cursorPos: number;
|
||||||
commands: IEditorCommandGroup;
|
commands: IEditorCommandGroup;
|
||||||
@ -83,6 +83,11 @@ export abstract class NewBaseEditor extends Disposable {
|
|||||||
*/
|
*/
|
||||||
public abstract attach(cellElem: Element): void;
|
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
|
* Returns DOM container with the editor, typically present and attached after attach() has been
|
||||||
* called.
|
* called.
|
||||||
|
@ -104,6 +104,8 @@ export const DismissedPopup = StringUnion(
|
|||||||
'deleteRecords', // confirmation for deleting records keyboard shortcut,
|
'deleteRecords', // confirmation for deleting records keyboard shortcut,
|
||||||
'deleteFields', // confirmation for deleting columns keyboard shortcut,
|
'deleteFields', // confirmation for deleting columns keyboard shortcut,
|
||||||
'tutorialFirstCard', // first card of the tutorial,
|
'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;
|
export type DismissedPopup = typeof DismissedPopup.type;
|
||||||
|
|
||||||
|
@ -74,6 +74,9 @@ export const commonUrls = {
|
|||||||
plans: "https://www.getgrist.com/pricing",
|
plans: "https://www.getgrist.com/pricing",
|
||||||
sproutsProgram: "https://www.getgrist.com/sprouts-program",
|
sproutsProgram: "https://www.getgrist.com/sprouts-program",
|
||||||
contact: "https://www.getgrist.com/contact",
|
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',
|
basicTutorial: 'https://templates.getgrist.com/woXtXUBmiN5T/Grist-Basics',
|
||||||
basicTutorialImage: 'https://www.getgrist.com/wp-content/uploads/2021/08/lightweight-crm.png',
|
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.
|
* Instantiate an assistant, based on environment variables.
|
||||||
*/
|
*/
|
||||||
function getAssistant() {
|
function getAssistant() {
|
||||||
|
if (process.env.OPENAI_API_KEY === 'test') {
|
||||||
|
return new EchoAssistant();
|
||||||
|
}
|
||||||
if (process.env.OPENAI_API_KEY) {
|
if (process.env.OPENAI_API_KEY) {
|
||||||
return new OpenAIAssistant();
|
return new OpenAIAssistant();
|
||||||
}
|
}
|
||||||
|
@ -114,6 +114,7 @@
|
|||||||
--icon-ResizePanel: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHJlY3QgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiB4PSI0IiB5PSIyIiB3aWR0aD0iMiIgaGVpZ2h0PSIxMiIgcng9IjEiLz48L3N2Zz4=');
|
--icon-ResizePanel: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHJlY3QgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiB4PSI0IiB5PSIyIiB3aWR0aD0iMiIgaGVpZ2h0PSIxMiIgcng9IjEiLz48L3N2Zz4=');
|
||||||
--icon-Revert: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMuMTIzMTcgMy4wMDMzTDMuMDg3MjggNi45MTEwM0w2LjgxMzM2IDUuNjY0MzYiIHN0cm9rZT0iIzE2QjM3OCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PHBhdGggZD0iTTEzLjYxNTggMTAuMDc5OUMxNC4wMzY0IDguNjg0NzggMTMuODg1NSA3LjE3OTc0IDEzLjE5NjUgNS44OTU4NiAxMi41MDc0IDQuNjExOTggMTEuMzM2NSAzLjY1NDQzIDkuOTQxMzcgMy4yMzM4NSA4LjU0NjI3IDIuODEzMjcgNy4wNDEyMyAyLjk2NDExIDUuNzU3MzUgMy42NTMyIDQuNDczNDcgNC4zNDIzIDMuNTE1OTIgNS41MTMxOCAzLjA5NTM0IDYuOTA4MjhNNi40Mjk0MyAxMy41ODc2QzUuODQ4OCAxMy4zNjgzIDUuMzA5NjIgMTMuMDUyIDQuODM0ODQgMTIuNjUyMk0xMC4wMzk4IDEzLjY4MTZDOS40OTk1MyAxMy44NTM0IDguOTM1OTUgMTMuOTQwNyA4LjM2OTAzIDEzLjk0MDJIOC4yNDMwNE0xMi44ODY4IDExLjU3MjhDMTIuNTQ4MiAxMi4wNjA4IDEyLjEzMzIgMTIuNDkxMiAxMS42NTc3IDEyLjg0NzNNMy42NjIxNSAxMS4yODA0QzMuMzUxNDggMTAuNzY2NiAzLjEyNzc2IDEwLjIwNTEgMyA5LjYxODQxIiBzdHJva2U9IiMxNkIzNzgiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjwvc3ZnPg==');
|
--icon-Revert: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMuMTIzMTcgMy4wMDMzTDMuMDg3MjggNi45MTEwM0w2LjgxMzM2IDUuNjY0MzYiIHN0cm9rZT0iIzE2QjM3OCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PHBhdGggZD0iTTEzLjYxNTggMTAuMDc5OUMxNC4wMzY0IDguNjg0NzggMTMuODg1NSA3LjE3OTc0IDEzLjE5NjUgNS44OTU4NiAxMi41MDc0IDQuNjExOTggMTEuMzM2NSAzLjY1NDQzIDkuOTQxMzcgMy4yMzM4NSA4LjU0NjI3IDIuODEzMjcgNy4wNDEyMyAyLjk2NDExIDUuNzU3MzUgMy42NTMyIDQuNDczNDcgNC4zNDIzIDMuNTE1OTIgNS41MTMxOCAzLjA5NTM0IDYuOTA4MjhNNi40Mjk0MyAxMy41ODc2QzUuODQ4OCAxMy4zNjgzIDUuMzA5NjIgMTMuMDUyIDQuODM0ODQgMTIuNjUyMk0xMC4wMzk4IDEzLjY4MTZDOS40OTk1MyAxMy44NTM0IDguOTM1OTUgMTMuOTQwNyA4LjM2OTAzIDEzLjk0MDJIOC4yNDMwNE0xMi44ODY4IDExLjU3MjhDMTIuNTQ4MiAxMi4wNjA4IDEyLjEzMzIgMTIuNDkxMiAxMS42NTc3IDEyLjg0NzNNMy42NjIxNSAxMS4yODA0QzMuMzUxNDggMTAuNzY2NiAzLjEyNzc2IDEwLjIwNTEgMyA5LjYxODQxIiBzdHJva2U9IiMxNkIzNzgiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjwvc3ZnPg==');
|
||||||
--icon-RightAlign: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIuNSw4LjUgQzIuMjIzODU3NjMsOC41IDIsOC4yNzYxNDIzNyAyLDggQzIsNy43MjM4NTc2MyAyLjIyMzg1NzYzLDcuNSAyLjUsNy41IEwxMy41LDcuNSBDMTMuNzc2MTQyNCw3LjUgMTQsNy43MjM4NTc2MyAxNCw4IEMxNCw4LjI3NjE0MjM3IDEzLjc3NjE0MjQsOC41IDEzLjUsOC41IEwyLjUsOC41IFogTTIuNSw0IEMyLjIyMzg1NzYzLDQgMiwzLjc3NjE0MjM3IDIsMy41IEMyLDMuMjIzODU3NjMgMi4yMjM4NTc2MywzIDIuNSwzIEwxMy41LDMgQzEzLjc3NjE0MjQsMyAxNCwzLjIyMzg1NzYzIDE0LDMuNSBDMTQsMy43NzYxNDIzNyAxMy43NzYxNDI0LDQgMTMuNSw0IEwyLjUsNCBaIE04LjUsMTMgQzguMjIzODU3NjMsMTMgOCwxMi43NzYxNDI0IDgsMTIuNSBDOCwxMi4yMjM4NTc2IDguMjIzODU3NjMsMTIgOC41LDEyIEwxMy41LDEyIEMxMy43NzYxNDI0LDEyIDE0LDEyLjIyMzg1NzYgMTQsMTIuNSBDMTQsMTIuNzc2MTQyNCAxMy43NzYxNDI0LDEzIDEzLjUsMTMgTDguNSwxMyBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4=');
|
--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-Script: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJjdXJyZW50Q29sb3IiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBjbGFzcz0iZmVhdGhlciBmZWF0aGVyLWRhdGFiYXNlIj48cmVjdCB3aWR0aD0iMTkuMzI4IiBoZWlnaHQ9IjE5LjMyOCIgeD0iMi4zMzYiIHk9IjIuMzM2IiByeT0iNC4wMjYiIHJ4PSIzLjc0NSIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJidXR0Ii8+PGcgc3Ryb2tlLXdpZHRoPSIxLjU0MyIgc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWNhcD0iYnV0dCIgc3Ryb2tlLWxpbmVqb2luPSJtaXRlciI+PHBhdGggZD0iTTE4LjczNDQ0OSA5LjAyMTI1NTZMNS4yNjU1NTEyIDkuMDA5NTM2OE0xOC43MzQ0NDkgMTQuNzM4NTg2TDUuMjY1NTUxMiAxNC43MjY4NjciIHRyYW5zZm9ybT0ibWF0cml4KC45Mzk2OSAwIDAgMS4wMDUzMSAuNzI0IC0uMDYzKSIvPjwvZz48L3N2Zz4=');
|
||||||
--icon-Search: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLjQzNjIxMjcsMTAuNzI5MTA1OSBMMTQuODUzNTUzNCwxNC4xNDY0NDY2IEMxNS4wNDg4MTU1LDE0LjM0MTcwODggMTUuMDQ4ODE1NSwxNC42NTgyOTEyIDE0Ljg1MzU1MzQsMTQuODUzNTUzNCBDMTQuNjU4MjkxMiwxNS4wNDg4MTU1IDE0LjM0MTcwODgsMTUuMDQ4ODE1NSAxNC4xNDY0NDY2LDE0Ljg1MzU1MzQgTDEwLjcyOTEwNTksMTEuNDM2MjEyNyBDOS41OTIzMzg0OCwxMi40MTEwNDg3IDguMTE0OTQ3NzEsMTMgNi41LDEzIEMyLjkxMDE0OTEzLDEzIDAsMTAuMDg5ODUwOSAwLDYuNSBDMCwyLjkxMDE0OTEzIDIuOTEwMTQ5MTMsMCA2LjUsMCBDMTAuMDg5ODUwOSwwIDEzLDIuOTEwMTQ5MTMgMTMsNi41IEMxMyw4LjExNDk0NzcxIDEyLjQxMTA0ODcsOS41OTIzMzg0OCAxMS40MzYyMTI3LDEwLjcyOTEwNTkgWiBNMTAuNDA5NTc0NywxMC4zNjg0OTIxIEMxMS4zOTI4MzI1LDkuMzc0ODU3OCAxMiw4LjAwODMzNDY4IDEyLDYuNSBDMTIsMy40NjI0MzM4OCA5LjUzNzU2NjEyLDEgNi41LDEgQzMuNDYyNDMzODgsMSAxLDMuNDYyNDMzODggMSw2LjUgQzEsOS41Mzc1NjYxMiAzLjQ2MjQzMzg4LDEyIDYuNSwxMiBDOC4wMDgzMzQ2OCwxMiA5LjM3NDg1NzgsMTEuMzkyODMyNSAxMC4zNjg0OTIxLDEwLjQwOTU3NDcgQzEwLjM3NDkwMDEsMTAuNDAyMzg3OSAxMC4zODE1NTE2LDEwLjM5NTM0MTYgMTAuMzg4NDQ2NiwxMC4zODg0NDY2IEMxMC4zOTUzNDE2LDEwLjM4MTU1MTYgMTAuNDAyMzg3OSwxMC4zNzQ5MDAxIDEwLjQwOTU3NDcsMTAuMzY4NDkyMSBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4=');
|
--icon-Search: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLjQzNjIxMjcsMTAuNzI5MTA1OSBMMTQuODUzNTUzNCwxNC4xNDY0NDY2IEMxNS4wNDg4MTU1LDE0LjM0MTcwODggMTUuMDQ4ODE1NSwxNC42NTgyOTEyIDE0Ljg1MzU1MzQsMTQuODUzNTUzNCBDMTQuNjU4MjkxMiwxNS4wNDg4MTU1IDE0LjM0MTcwODgsMTUuMDQ4ODE1NSAxNC4xNDY0NDY2LDE0Ljg1MzU1MzQgTDEwLjcyOTEwNTksMTEuNDM2MjEyNyBDOS41OTIzMzg0OCwxMi40MTEwNDg3IDguMTE0OTQ3NzEsMTMgNi41LDEzIEMyLjkxMDE0OTEzLDEzIDAsMTAuMDg5ODUwOSAwLDYuNSBDMCwyLjkxMDE0OTEzIDIuOTEwMTQ5MTMsMCA2LjUsMCBDMTAuMDg5ODUwOSwwIDEzLDIuOTEwMTQ5MTMgMTMsNi41IEMxMyw4LjExNDk0NzcxIDEyLjQxMTA0ODcsOS41OTIzMzg0OCAxMS40MzYyMTI3LDEwLjcyOTEwNTkgWiBNMTAuNDA5NTc0NywxMC4zNjg0OTIxIEMxMS4zOTI4MzI1LDkuMzc0ODU3OCAxMiw4LjAwODMzNDY4IDEyLDYuNSBDMTIsMy40NjI0MzM4OCA5LjUzNzU2NjEyLDEgNi41LDEgQzMuNDYyNDMzODgsMSAxLDMuNDYyNDMzODggMSw2LjUgQzEsOS41Mzc1NjYxMiAzLjQ2MjQzMzg4LDEyIDYuNSwxMiBDOC4wMDgzMzQ2OCwxMiA5LjM3NDg1NzgsMTEuMzkyODMyNSAxMC4zNjg0OTIxLDEwLjQwOTU3NDcgQzEwLjM3NDkwMDEsMTAuNDAyMzg3OSAxMC4zODE1NTE2LDEwLjM5NTM0MTYgMTAuMzg4NDQ2NiwxMC4zODg0NDY2IEMxMC4zOTUzNDE2LDEwLjM4MTU1MTYgMTAuNDAyMzg3OSwxMC4zNzQ5MDAxIDEwLjQwOTU3NDcsMTAuMzY4NDkyMSBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4=');
|
||||||
--icon-Settings: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMuOTgwNDcxOSwzLjI3MzM2NTEyIEM0LjgxNDE0NDY1LDIuNTc4NDA2ODQgNS44NTY2MjI5MSwyLjEyNTQ1Njc0IDcsMi4wMjI0MjE1MSBMNywwLjUgQzcsMC4yMjM4NTc2MjUgNy4yMjM4NTc2MywwIDcuNSwwIEM3Ljc3NjE0MjM3LDAgOCwwLjIyMzg1NzYyNSA4LDAuNSBMOCwyLjAyMjQyMTUxIEM5LjE0MzM3NzA5LDIuMTI1NDU2NzQgMTAuMTg1ODU1NCwyLjU3ODQwNjg0IDExLjAxOTUyODEsMy4yNzMzNjUxMiBMMTIuMDk2NDQ2NiwyLjE5NjQ0NjYxIEMxMi4yOTE3MDg4LDIuMDAxMTg0NDYgMTIuNjA4MjkxMiwyLjAwMTE4NDQ2IDEyLjgwMzU1MzQsMi4xOTY0NDY2MSBDMTIuOTk4ODE1NSwyLjM5MTcwODc2IDEyLjk5ODgxNTUsMi43MDgyOTEyNCAxMi44MDM1NTM0LDIuOTAzNTUzMzkgTDExLjcyNjYzNDksMy45ODA0NzE5IEMxMi40MjE1OTMyLDQuODE0MTQ0NjUgMTIuODc0NTQzMyw1Ljg1NjYyMjkxIDEyLjk3NzU3ODUsNyBMMTQuNSw3IEMxNC43NzYxNDI0LDcgMTUsNy4yMjM4NTc2MyAxNSw3LjUgQzE1LDcuNzc2MTQyMzcgMTQuNzc2MTQyNCw4IDE0LjUsOCBMMTIuOTc3NTc4NSw4IEMxMi44NzQ1NDMzLDkuMTQzMzc3MDkgMTIuNDIxNTkzMiwxMC4xODU4NTU0IDExLjcyNjYzNDksMTEuMDE5NTI4MSBMMTIuODAzNTUzNCwxMi4wOTY0NDY2IEMxMi45OTg4MTU1LDEyLjI5MTcwODggMTIuOTk4ODE1NSwxMi42MDgyOTEyIDEyLjgwMzU1MzQsMTIuODAzNTUzNCBDMTIuNjA4MjkxMiwxMi45OTg4MTU1IDEyLjI5MTcwODgsMTIuOTk4ODE1NSAxMi4wOTY0NDY2LDEyLjgwMzU1MzQgTDExLjAxOTUyODEsMTEuNzI2NjM0OSBDMTAuMTg1ODU1NCwxMi40MjE1OTMyIDkuMTQzMzc3MDksMTIuODc0NTQzMyA4LDEyLjk3NzU3ODUgTDgsMTQuNSBDOCwxNC43NzYxNDI0IDcuNzc2MTQyMzcsMTUgNy41LDE1IEM3LjIyMzg1NzYzLDE1IDcsMTQuNzc2MTQyNCA3LDE0LjUgTDcsMTIuOTc3NTc4NSBDNS44NTY2MjI5MSwxMi44NzQ1NDMzIDQuODE0MTQ0NjUsMTIuNDIxNTkzMiAzLjk4MDQ3MTksMTEuNzI2NjM0OSBMMi45MDM1NTMzOSwxMi44MDM1NTM0IEMyLjcwODI5MTI0LDEyLjk5ODgxNTUgMi4zOTE3MDg3NiwxMi45OTg4MTU1IDIuMTk2NDQ2NjEsMTIuODAzNTUzNCBDMi4wMDExODQ0NiwxMi42MDgyOTEyIDIuMDAxMTg0NDYsMTIuMjkxNzA4OCAyLjE5NjQ0NjYxLDEyLjA5NjQ0NjYgTDMuMjczMzY1MTIsMTEuMDE5NTI4MSBDMi41Nzg0MDY4NCwxMC4xODU4NTU0IDIuMTI1NDU2NzQsOS4xNDMzNzcwOSAyLjAyMjQyMTUxLDggTDAuNSw4IEMwLjIyMzg1NzYyNSw4IDAsNy43NzYxNDIzNyAwLDcuNSBDMCw3LjIyMzg1NzYzIDAuMjIzODU3NjI1LDcgMC41LDcgTDIuMDIyNDIxNTEsNyBDMi4xMjU0NTY3NCw1Ljg1NjYyMjkxIDIuNTc4NDA2ODQsNC44MTQxNDQ2NSAzLjI3MzM2NTEyLDMuOTgwNDcxOSBMMi4xOTY0NDY2MSwyLjkwMzU1MzM5IEMyLjAwMTE4NDQ2LDIuNzA4MjkxMjQgMi4wMDExODQ0NiwyLjM5MTcwODc2IDIuMTk2NDQ2NjEsMi4xOTY0NDY2MSBDMi4zOTE3MDg3NiwyLjAwMTE4NDQ2IDIuNzA4MjkxMjQsMi4wMDExODQ0NiAyLjkwMzU1MzM5LDIuMTk2NDQ2NjEgTDMuOTgwNDcxOSwzLjI3MzM2NTEyIFogTTcuNSwxMCBDNi4xMTkyODgxMywxMCA1LDguODgwNzExODcgNSw3LjUgQzUsNi4xMTkyODgxMyA2LjExOTI4ODEzLDUgNy41LDUgQzguODgwNzExODcsNSAxMCw2LjExOTI4ODEzIDEwLDcuNSBDMTAsOC44ODA3MTE4NyA4Ljg4MDcxMTg3LDEwIDcuNSwxMCBaIE03LjUsOSBDOC4zMjg0MjcxMiw5IDksOC4zMjg0MjcxMiA5LDcuNSBDOSw2LjY3MTU3Mjg4IDguMzI4NDI3MTIsNiA3LjUsNiBDNi42NzE1NzI4OCw2IDYsNi42NzE1NzI4OCA2LDcuNSBDNiw4LjMyODQyNzEyIDYuNjcxNTcyODgsOSA3LjUsOSBaIE03LjUsMTIgQzkuOTg1MjgxMzcsMTIgMTIsOS45ODUyODEzNyAxMiw3LjUgQzEyLDUuMDE0NzE4NjMgOS45ODUyODEzNywzIDcuNSwzIEM1LjAxNDcxODYzLDMgMyw1LjAxNDcxODYzIDMsNy41IEMzLDkuOTg1MjgxMzcgNS4wMTQ3MTg2MywxMiA3LjUsMTIgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+');
|
--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();
|
await gu.skipWelcomeQuestions();
|
||||||
|
|
||||||
// Make sure we have clean start.
|
// Make sure we have clean start.
|
||||||
await driver.executeScript('resetSeenPopups();');
|
await driver.executeScript('resetDismissedPopups();');
|
||||||
await gu.waitForServer();
|
await gu.waitForServer();
|
||||||
await driver.navigate().refresh();
|
await driver.navigate().refresh();
|
||||||
await gu.waitForDocMenuToLoad();
|
await gu.waitForDocMenuToLoad();
|
||||||
|
@ -13,7 +13,7 @@ describe('RightPanel', function() {
|
|||||||
await mainSession.tempNewDoc(cleanup);
|
await mainSession.tempNewDoc(cleanup);
|
||||||
|
|
||||||
// Reset prefs.
|
// Reset prefs.
|
||||||
await driver.executeScript('resetSeenPopups();');
|
await driver.executeScript('resetDismissedPopups();');
|
||||||
await gu.waitForServer();
|
await gu.waitForServer();
|
||||||
|
|
||||||
// Refresh for a clean start.
|
// Refresh for a clean start.
|
||||||
|
@ -960,9 +960,15 @@ export async function waitForServer(optTimeout: number = 2000) {
|
|||||||
* Sends UserActions using client api from the browser.
|
* Sends UserActions using client api from the browser.
|
||||||
*/
|
*/
|
||||||
export async function sendActions(actions: UserAction[]) {
|
export async function sendActions(actions: UserAction[]) {
|
||||||
await driver.executeScript(`
|
const result = await driver.executeAsyncScript(`
|
||||||
gristDocPageModel.gristDoc.get().docModel.docData.sendActions(${JSON.stringify(actions)});
|
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();
|
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> {
|
export async function getBuildFile(relativePath: string): Promise<string> {
|
||||||
|
Loading…
Reference in New Issue
Block a user