(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:
Jarosław Sadziński
2023-06-02 13:25:14 +02:00
parent e10067ff78
commit da323fb741
36 changed files with 2022 additions and 823 deletions

View File

@@ -114,9 +114,9 @@ AceEditor.prototype.enable = function(bool) {
* Note: Ace defers to standard behavior when false is returned.
*/
AceEditor.prototype.attachCommandGroup = function(commandGroup) {
_.each(commandGroup.knownKeys, (command, key) => {
_.each(commandGroup.knownKeys, (commandName, key) => {
this.editor.commands.addCommand({
name: command,
name: commandName,
// We are setting readonly as true to enable all commands
// in a readonly mode.
// Because FieldEditor in readonly mode will rewire all commands that
@@ -129,7 +129,7 @@ AceEditor.prototype.attachCommandGroup = function(commandGroup) {
},
// AceEditor wants a command to return true if it got handled, whereas our command returns
// true to avoid stopPropagation/preventDefault, i.e. if it hasn't been handled.
exec: () => !commandGroup.commands[command]()
exec: () => !commandGroup.commands[commandName]()
});
});
};
@@ -270,7 +270,7 @@ AceEditor.prototype.resize = function() {
// This won't help for zooming (where the same problem occurs but in many more places), but will
// help for Windows users who have different pixel ratio.
this.editorDom.style.width = size.width ? Math.ceil(size.width) + 'px' : 'auto';
this.editorDom.style.height = Math.ceil(size.height) + 'px';
this.editorDom.style.height = size.height ? Math.ceil(size.height) + 'px' : 'auto';
this.editor.resize();
};

View File

@@ -1,4 +1,5 @@
import {ISuggestionWithValue} from 'app/common/ActiveDocAPI';
import {commonUrls} from 'app/common/gristUrls';
import * as ace from 'brace';
export interface ICompletionOptions {
@@ -265,7 +266,7 @@ function retokenizeAceCompleterRow(rowData: AceSuggestion, tokens: TokenInfo[]):
// Include into new tokens a special token that will be hidden, but include the link URL. On
// click, we find it to know what URL to open.
const href = 'https://support.getgrist.com/functions/#' +
const href = `${commonUrls.functions}/#` +
rowData.funcname.slice(linkStart, linkEnd).toLowerCase();
newTokens.push({value: href, type: 'grist_link_hidden'});

View File

@@ -49,6 +49,9 @@ const {parsePasteForView} = require("./BaseView2");
const {NEW_FILTER_JSON} = require('app/client/models/ColumnFilter');
const {CombinedStyle} = require("app/client/models/Styles");
const {buildRenameColumn} = require('app/client/ui/ColumnTitle');
const {makeT} = require('app/client/lib/localization');
const t = makeT('GridView');
// A threshold for interpreting a motionless click as a click rather than a drag.
// Anything longer than this time (in milliseconds) should be interpreted as a drag
@@ -219,6 +222,14 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) {
// Holds column index that is hovered, works only in full-edit formula mode.
this.hoverColumn = ko.observable(-1);
// Checks if there is active formula editor for a column in this table.
this.editingFormula = ko.pureComputed(() => {
const isEditing = this.gristDoc.docModel.editingFormula();
if (!isEditing) { return false; }
return this.viewSection.viewFields().all().some(field => field.editingFormula());
});
// Debounced method to change current hover column, this is needed
// as mouse when moved from field to field will switch the hover-column
// observable from current index to -1 and then immediately back to current index.
@@ -226,7 +237,7 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) {
// will be discarded.
this.changeHover = debounce((index) => {
if (this.isDisposed()) { return; }
if (this.gristDoc.docModel.editingFormula()) {
if (this.editingFormula()) {
this.hoverColumn(index);
}
}, 0);
@@ -1054,8 +1065,9 @@ GridView.prototype.buildDom = function() {
let filterTriggerCtl;
const isTooltip = ko.pureComputed(() =>
self.gristDoc.docModel.editingFormula() &&
ko.unwrap(self.hoverColumn) === field._index());
self.editingFormula() &&
ko.unwrap(self.hoverColumn) === field._index()
);
return dom(
'div.column_name.field',
kd.style('--frozen-position', () => ko.unwrap(this.frozenPositions.at(field._index()))),
@@ -1070,7 +1082,7 @@ GridView.prototype.buildDom = function() {
dom.autoDispose(tooltip),
dom.autoDispose(isTooltip.subscribe((show) => {
if (show) {
tooltip.show(`Click to insert $${field.colId.peek()}`);
tooltip.show(t(`Click to insert`) + ` $${field.origCol.peek().colId.peek()}`);
} else {
tooltip.hide();
}
@@ -1316,7 +1328,7 @@ GridView.prototype.buildDom = function() {
dom.autoDispose(isSelected),
dom.on("mouseenter", () => self.changeHover(field._index())),
kd.toggleClass("hover-column", () =>
self.gristDoc.docModel.editingFormula() &&
self.editingFormula() &&
ko.unwrap(self.hoverColumn) === (field._index())),
kd.style('width', field.widthPx),
//TODO: Ensure that fields in a row resize when
@@ -1624,7 +1636,7 @@ GridView.prototype.dropCols = function() {
// column movement, propose renaming the column.
if (Date.now() - this._colClickTime < SHORT_CLICK_IN_MS && oldIndices.length === 1 &&
idx === oldIndices[0]) {
this.currentEditingColumnIndex(idx);
commands.allCommands.renameField.run();
}
this._colClickTime = 0;
this.cellSelector.currentDragType(selector.NONE);

View File

@@ -28,7 +28,7 @@ import {makeT} from 'app/client/lib/localization';
import {createSessionObs} from 'app/client/lib/sessionObs';
import {setTestState} from 'app/client/lib/testState';
import {selectFiles} from 'app/client/lib/uploads';
import {reportError} from 'app/client/models/AppModel';
import {AppModel, reportError} from 'app/client/models/AppModel';
import BaseRowModel from 'app/client/models/BaseRowModel';
import DataTableModel from 'app/client/models/DataTableModel';
import {DataTableModelWithDiff} from 'app/client/models/DataTableModelWithDiff';
@@ -205,6 +205,7 @@ export class GristDoc extends DisposableWithEvents {
constructor(
public readonly app: App,
public readonly appModel: AppModel,
public readonly docComm: DocComm,
public readonly docPageModel: DocPageModel,
openDocResponse: OpenLocalDocResult,
@@ -440,13 +441,7 @@ export class GristDoc extends DisposableWithEvents {
// Command to be manually triggered on cell selection. Moves the cursor to the selected cell.
// This is overridden by the formula editor to insert "$col" variables when clicking cells.
setCursor(rowModel: BaseRowModel, fieldModel?: ViewFieldRec) {
return this.setCursorPos({
rowIndex: rowModel?._index() || 0,
fieldIndex: fieldModel?._index() || 0,
sectionId: fieldModel?.viewSection().getRowId(),
});
},
setCursor: this.onSetCursorPos.bind(this),
}, this, true));
this.listenTo(app.comm, 'docUserAction', this.onDocUserAction);
@@ -614,6 +609,14 @@ export class GristDoc extends DisposableWithEvents {
return Object.assign(pos, viewInstance ? viewInstance.cursor.getCursorPos() : {});
}
public async onSetCursorPos(rowModel: BaseRowModel|undefined, fieldModel?: ViewFieldRec) {
return this.setCursorPos({
rowIndex: rowModel?._index() || 0,
fieldIndex: fieldModel?._index() || 0,
sectionId: fieldModel?.viewSection().getRowId(),
});
}
public async setCursorPos(cursorPos: CursorPos) {
if (cursorPos.sectionId && cursorPos.sectionId !== this.externalSectionId.get()) {
const desiredSection: ViewSectionRec = this.docModel.viewSections.getRowModel(cursorPos.sectionId);
@@ -1149,7 +1152,7 @@ export class GristDoc extends DisposableWithEvents {
* Opens up an editor at cursor position
* @param input Optional. Cell's initial value
*/
public async activateEditorAtCursor(options: { init?: string, state?: any}) {
public async activateEditorAtCursor(options?: { init?: string, state?: any}) {
const view = await this._waitForView();
view?.activateEditorAtCursor(options);
}

View File

@@ -31,8 +31,8 @@ import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
import {byteString} from 'app/common/gutil';
import {FetchUrlOptions, UploadResult} from 'app/common/uploads';
import {ParseOptions, ParseOptionSchema} from 'app/plugin/FileParserAPI';
import {Computed, dom, DomContents, fromKo, Holder, IDisposable, MultiHolder, MutableObsArray, obsArray, Observable,
styled} from 'grainjs';
import {Computed, Disposable, dom, DomContents, fromKo, Holder, IDisposable,
MultiHolder, MutableObsArray, obsArray, Observable, styled} from 'grainjs';
import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox';
import {ACCESS_DENIED, AUTH_INTERRUPTED, canReadPrivateFiles, getGoogleCodeForReading} from 'app/client/ui/googleAuth';
import debounce = require('lodash/debounce');
@@ -824,6 +824,7 @@ export class Importer extends DisposableWithEvents {
editingFormula: field.editingFormula,
refElem,
editRow,
canDetach: false,
setupCleanup: this._setupFormulaEditorCleanup.bind(this),
onSave: async (column, formula) => {
if (formula === column.formula.peek()) { return; }
@@ -842,7 +843,7 @@ export class Importer extends DisposableWithEvents {
* focus.
*/
private _setupFormulaEditorCleanup(
owner: MultiHolder, _doc: GristDoc, editingFormula: ko.Computed<boolean>, _saveEdit: () => Promise<unknown>
owner: Disposable, _doc: GristDoc, editingFormula: ko.Computed<boolean>, _saveEdit: () => Promise<unknown>
) {
const saveEdit = () => _saveEdit().catch(reportError);

View File

@@ -108,6 +108,8 @@ export type CommandName =
| 'clearSectionLinks'
| 'transformUpdate'
| 'clearCopySelection'
| 'detachEditor'
| 'activateAssistant'
;
@@ -259,6 +261,11 @@ export const groups: CommendGroupDef[] = [{
keys: [],
desc: 'Shortcut to open video tour from home left panel',
},
{
name: 'activateAssistant',
keys: [],
desc: 'Activate assistant',
},
]
}, {
group: 'Navigation',
@@ -391,6 +398,10 @@ export const groups: CommendGroupDef[] = [{
name: 'fieldEditSave',
keys: ['Enter'],
desc: 'Finish editing a cell, saving the value'
}, {
name: 'detachEditor',
keys: [''],
desc: 'Detach active editor'
}, {
name: 'fieldEditSaveHere',
keys: [],

View File

@@ -8,18 +8,30 @@
*/
import * as Mousetrap from 'app/client/lib/Mousetrap';
import { arrayRemove } from 'app/common/gutil';
import {arrayRemove, unwrap} from 'app/common/gutil';
import dom from 'app/client/lib/dom';
import 'app/client/lib/koUtil'; // for subscribeInit
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
import {CommandDef, CommandName, CommendGroupDef, groups} from 'app/client/components/commandList';
import {Disposable} from 'grainjs';
import {Disposable, Observable} from 'grainjs';
import * as _ from 'underscore';
import * as ko from 'knockout';
const G = getBrowserGlobals('window');
type BoolLike = boolean|ko.Observable<boolean>|ko.Computed<boolean>;
type BoolLike = boolean|ko.Observable<boolean>|ko.Computed<boolean>|Observable<boolean>;
/**
* A helper method that can create a subscription to ko or grains observables.
*/
function subscribe(value: Exclude<BoolLike, boolean>, fn: (value: boolean) => void) {
if (ko.isObservable(value)) {
return value.subscribe(fn);
} else if (value instanceof Observable) {
return value.addListener(fn);
} else {
throw new Error('Expected an observable');
}
}
// Same logic as used by mousetrap to map 'Mod' key to platform-specific key.
export const isMac = (typeof navigator !== 'undefined' && navigator &&
@@ -291,10 +303,11 @@ export class CommandGroup extends Disposable {
this.onDispose(this._removeGroup.bind(this));
// Finally, set the activation status of the command group, subscribing if an observable.
if (ko.isObservable(activate)) {
this.autoDispose((activate as any).subscribeInit(this.activate, this));
} else {
this.activate(activate as boolean);
if (typeof activate === 'boolean' || activate === undefined) {
this.activate(activate ?? false);
} else if (activate) {
this.autoDispose(subscribe(activate, (val) => this.activate(val)));
this.activate(unwrap(activate));
}
}
@@ -343,8 +356,8 @@ type BoundedMap<T> = { [key in CommandName]?: BoundedFunc<T> };
/**
* Just a shorthand for CommandGroup.create constructor.
*/
export function createGroup<T>(commands: BoundedMap<T>, context: T, activate?: BoolLike) {
return CommandGroup.create(null, commands, context, activate);
export function createGroup<T>(commands: BoundedMap<T>|null, context: T, activate?: BoolLike) {
return CommandGroup.create(null, commands ?? {}, context, activate);
}
//----------------------------------------------------------------------