mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +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:
@@ -114,9 +114,9 @@ AceEditor.prototype.enable = function(bool) {
|
||||
* Note: Ace defers to standard behavior when false is returned.
|
||||
*/
|
||||
AceEditor.prototype.attachCommandGroup = function(commandGroup) {
|
||||
_.each(commandGroup.knownKeys, (command, key) => {
|
||||
_.each(commandGroup.knownKeys, (commandName, key) => {
|
||||
this.editor.commands.addCommand({
|
||||
name: command,
|
||||
name: commandName,
|
||||
// We are setting readonly as true to enable all commands
|
||||
// in a readonly mode.
|
||||
// Because FieldEditor in readonly mode will rewire all commands that
|
||||
@@ -129,7 +129,7 @@ AceEditor.prototype.attachCommandGroup = function(commandGroup) {
|
||||
},
|
||||
// AceEditor wants a command to return true if it got handled, whereas our command returns
|
||||
// true to avoid stopPropagation/preventDefault, i.e. if it hasn't been handled.
|
||||
exec: () => !commandGroup.commands[command]()
|
||||
exec: () => !commandGroup.commands[commandName]()
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -270,7 +270,7 @@ AceEditor.prototype.resize = function() {
|
||||
// This won't help for zooming (where the same problem occurs but in many more places), but will
|
||||
// help for Windows users who have different pixel ratio.
|
||||
this.editorDom.style.width = size.width ? Math.ceil(size.width) + 'px' : 'auto';
|
||||
this.editorDom.style.height = Math.ceil(size.height) + 'px';
|
||||
this.editorDom.style.height = size.height ? Math.ceil(size.height) + 'px' : 'auto';
|
||||
this.editor.resize();
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {ISuggestionWithValue} from 'app/common/ActiveDocAPI';
|
||||
import {commonUrls} from 'app/common/gristUrls';
|
||||
import * as ace from 'brace';
|
||||
|
||||
export interface ICompletionOptions {
|
||||
@@ -265,7 +266,7 @@ function retokenizeAceCompleterRow(rowData: AceSuggestion, tokens: TokenInfo[]):
|
||||
|
||||
// Include into new tokens a special token that will be hidden, but include the link URL. On
|
||||
// click, we find it to know what URL to open.
|
||||
const href = 'https://support.getgrist.com/functions/#' +
|
||||
const href = `${commonUrls.functions}/#` +
|
||||
rowData.funcname.slice(linkStart, linkEnd).toLowerCase();
|
||||
newTokens.push({value: href, type: 'grist_link_hidden'});
|
||||
|
||||
|
||||
@@ -49,6 +49,9 @@ const {parsePasteForView} = require("./BaseView2");
|
||||
const {NEW_FILTER_JSON} = require('app/client/models/ColumnFilter');
|
||||
const {CombinedStyle} = require("app/client/models/Styles");
|
||||
const {buildRenameColumn} = require('app/client/ui/ColumnTitle');
|
||||
const {makeT} = require('app/client/lib/localization');
|
||||
|
||||
const t = makeT('GridView');
|
||||
|
||||
// A threshold for interpreting a motionless click as a click rather than a drag.
|
||||
// Anything longer than this time (in milliseconds) should be interpreted as a drag
|
||||
@@ -219,6 +222,14 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) {
|
||||
|
||||
// Holds column index that is hovered, works only in full-edit formula mode.
|
||||
this.hoverColumn = ko.observable(-1);
|
||||
|
||||
// Checks if there is active formula editor for a column in this table.
|
||||
this.editingFormula = ko.pureComputed(() => {
|
||||
const isEditing = this.gristDoc.docModel.editingFormula();
|
||||
if (!isEditing) { return false; }
|
||||
return this.viewSection.viewFields().all().some(field => field.editingFormula());
|
||||
});
|
||||
|
||||
// Debounced method to change current hover column, this is needed
|
||||
// as mouse when moved from field to field will switch the hover-column
|
||||
// observable from current index to -1 and then immediately back to current index.
|
||||
@@ -226,7 +237,7 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) {
|
||||
// will be discarded.
|
||||
this.changeHover = debounce((index) => {
|
||||
if (this.isDisposed()) { return; }
|
||||
if (this.gristDoc.docModel.editingFormula()) {
|
||||
if (this.editingFormula()) {
|
||||
this.hoverColumn(index);
|
||||
}
|
||||
}, 0);
|
||||
@@ -1054,8 +1065,9 @@ GridView.prototype.buildDom = function() {
|
||||
|
||||
let filterTriggerCtl;
|
||||
const isTooltip = ko.pureComputed(() =>
|
||||
self.gristDoc.docModel.editingFormula() &&
|
||||
ko.unwrap(self.hoverColumn) === field._index());
|
||||
self.editingFormula() &&
|
||||
ko.unwrap(self.hoverColumn) === field._index()
|
||||
);
|
||||
return dom(
|
||||
'div.column_name.field',
|
||||
kd.style('--frozen-position', () => ko.unwrap(this.frozenPositions.at(field._index()))),
|
||||
@@ -1070,7 +1082,7 @@ GridView.prototype.buildDom = function() {
|
||||
dom.autoDispose(tooltip),
|
||||
dom.autoDispose(isTooltip.subscribe((show) => {
|
||||
if (show) {
|
||||
tooltip.show(`Click to insert $${field.colId.peek()}`);
|
||||
tooltip.show(t(`Click to insert`) + ` $${field.origCol.peek().colId.peek()}`);
|
||||
} else {
|
||||
tooltip.hide();
|
||||
}
|
||||
@@ -1316,7 +1328,7 @@ GridView.prototype.buildDom = function() {
|
||||
dom.autoDispose(isSelected),
|
||||
dom.on("mouseenter", () => self.changeHover(field._index())),
|
||||
kd.toggleClass("hover-column", () =>
|
||||
self.gristDoc.docModel.editingFormula() &&
|
||||
self.editingFormula() &&
|
||||
ko.unwrap(self.hoverColumn) === (field._index())),
|
||||
kd.style('width', field.widthPx),
|
||||
//TODO: Ensure that fields in a row resize when
|
||||
@@ -1624,7 +1636,7 @@ GridView.prototype.dropCols = function() {
|
||||
// column movement, propose renaming the column.
|
||||
if (Date.now() - this._colClickTime < SHORT_CLICK_IN_MS && oldIndices.length === 1 &&
|
||||
idx === oldIndices[0]) {
|
||||
this.currentEditingColumnIndex(idx);
|
||||
commands.allCommands.renameField.run();
|
||||
}
|
||||
this._colClickTime = 0;
|
||||
this.cellSelector.currentDragType(selector.NONE);
|
||||
|
||||
@@ -28,7 +28,7 @@ import {makeT} from 'app/client/lib/localization';
|
||||
import {createSessionObs} from 'app/client/lib/sessionObs';
|
||||
import {setTestState} from 'app/client/lib/testState';
|
||||
import {selectFiles} from 'app/client/lib/uploads';
|
||||
import {reportError} from 'app/client/models/AppModel';
|
||||
import {AppModel, reportError} from 'app/client/models/AppModel';
|
||||
import BaseRowModel from 'app/client/models/BaseRowModel';
|
||||
import DataTableModel from 'app/client/models/DataTableModel';
|
||||
import {DataTableModelWithDiff} from 'app/client/models/DataTableModelWithDiff';
|
||||
@@ -205,6 +205,7 @@ export class GristDoc extends DisposableWithEvents {
|
||||
|
||||
constructor(
|
||||
public readonly app: App,
|
||||
public readonly appModel: AppModel,
|
||||
public readonly docComm: DocComm,
|
||||
public readonly docPageModel: DocPageModel,
|
||||
openDocResponse: OpenLocalDocResult,
|
||||
@@ -440,13 +441,7 @@ export class GristDoc extends DisposableWithEvents {
|
||||
|
||||
// Command to be manually triggered on cell selection. Moves the cursor to the selected cell.
|
||||
// This is overridden by the formula editor to insert "$col" variables when clicking cells.
|
||||
setCursor(rowModel: BaseRowModel, fieldModel?: ViewFieldRec) {
|
||||
return this.setCursorPos({
|
||||
rowIndex: rowModel?._index() || 0,
|
||||
fieldIndex: fieldModel?._index() || 0,
|
||||
sectionId: fieldModel?.viewSection().getRowId(),
|
||||
});
|
||||
},
|
||||
setCursor: this.onSetCursorPos.bind(this),
|
||||
}, this, true));
|
||||
|
||||
this.listenTo(app.comm, 'docUserAction', this.onDocUserAction);
|
||||
@@ -614,6 +609,14 @@ export class GristDoc extends DisposableWithEvents {
|
||||
return Object.assign(pos, viewInstance ? viewInstance.cursor.getCursorPos() : {});
|
||||
}
|
||||
|
||||
public async onSetCursorPos(rowModel: BaseRowModel|undefined, fieldModel?: ViewFieldRec) {
|
||||
return this.setCursorPos({
|
||||
rowIndex: rowModel?._index() || 0,
|
||||
fieldIndex: fieldModel?._index() || 0,
|
||||
sectionId: fieldModel?.viewSection().getRowId(),
|
||||
});
|
||||
}
|
||||
|
||||
public async setCursorPos(cursorPos: CursorPos) {
|
||||
if (cursorPos.sectionId && cursorPos.sectionId !== this.externalSectionId.get()) {
|
||||
const desiredSection: ViewSectionRec = this.docModel.viewSections.getRowModel(cursorPos.sectionId);
|
||||
@@ -1149,7 +1152,7 @@ export class GristDoc extends DisposableWithEvents {
|
||||
* Opens up an editor at cursor position
|
||||
* @param input Optional. Cell's initial value
|
||||
*/
|
||||
public async activateEditorAtCursor(options: { init?: string, state?: any}) {
|
||||
public async activateEditorAtCursor(options?: { init?: string, state?: any}) {
|
||||
const view = await this._waitForView();
|
||||
view?.activateEditorAtCursor(options);
|
||||
}
|
||||
|
||||
@@ -31,8 +31,8 @@ import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
|
||||
import {byteString} from 'app/common/gutil';
|
||||
import {FetchUrlOptions, UploadResult} from 'app/common/uploads';
|
||||
import {ParseOptions, ParseOptionSchema} from 'app/plugin/FileParserAPI';
|
||||
import {Computed, dom, DomContents, fromKo, Holder, IDisposable, MultiHolder, MutableObsArray, obsArray, Observable,
|
||||
styled} from 'grainjs';
|
||||
import {Computed, Disposable, dom, DomContents, fromKo, Holder, IDisposable,
|
||||
MultiHolder, MutableObsArray, obsArray, Observable, styled} from 'grainjs';
|
||||
import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox';
|
||||
import {ACCESS_DENIED, AUTH_INTERRUPTED, canReadPrivateFiles, getGoogleCodeForReading} from 'app/client/ui/googleAuth';
|
||||
import debounce = require('lodash/debounce');
|
||||
@@ -824,6 +824,7 @@ export class Importer extends DisposableWithEvents {
|
||||
editingFormula: field.editingFormula,
|
||||
refElem,
|
||||
editRow,
|
||||
canDetach: false,
|
||||
setupCleanup: this._setupFormulaEditorCleanup.bind(this),
|
||||
onSave: async (column, formula) => {
|
||||
if (formula === column.formula.peek()) { return; }
|
||||
@@ -842,7 +843,7 @@ export class Importer extends DisposableWithEvents {
|
||||
* focus.
|
||||
*/
|
||||
private _setupFormulaEditorCleanup(
|
||||
owner: MultiHolder, _doc: GristDoc, editingFormula: ko.Computed<boolean>, _saveEdit: () => Promise<unknown>
|
||||
owner: Disposable, _doc: GristDoc, editingFormula: ko.Computed<boolean>, _saveEdit: () => Promise<unknown>
|
||||
) {
|
||||
const saveEdit = () => _saveEdit().catch(reportError);
|
||||
|
||||
|
||||
@@ -108,6 +108,8 @@ export type CommandName =
|
||||
| 'clearSectionLinks'
|
||||
| 'transformUpdate'
|
||||
| 'clearCopySelection'
|
||||
| 'detachEditor'
|
||||
| 'activateAssistant'
|
||||
;
|
||||
|
||||
|
||||
@@ -259,6 +261,11 @@ export const groups: CommendGroupDef[] = [{
|
||||
keys: [],
|
||||
desc: 'Shortcut to open video tour from home left panel',
|
||||
},
|
||||
{
|
||||
name: 'activateAssistant',
|
||||
keys: [],
|
||||
desc: 'Activate assistant',
|
||||
},
|
||||
]
|
||||
}, {
|
||||
group: 'Navigation',
|
||||
@@ -391,6 +398,10 @@ export const groups: CommendGroupDef[] = [{
|
||||
name: 'fieldEditSave',
|
||||
keys: ['Enter'],
|
||||
desc: 'Finish editing a cell, saving the value'
|
||||
}, {
|
||||
name: 'detachEditor',
|
||||
keys: [''],
|
||||
desc: 'Detach active editor'
|
||||
}, {
|
||||
name: 'fieldEditSaveHere',
|
||||
keys: [],
|
||||
|
||||
@@ -8,18 +8,30 @@
|
||||
*/
|
||||
|
||||
import * as Mousetrap from 'app/client/lib/Mousetrap';
|
||||
import { arrayRemove } from 'app/common/gutil';
|
||||
import {arrayRemove, unwrap} from 'app/common/gutil';
|
||||
import dom from 'app/client/lib/dom';
|
||||
import 'app/client/lib/koUtil'; // for subscribeInit
|
||||
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
|
||||
import {CommandDef, CommandName, CommendGroupDef, groups} from 'app/client/components/commandList';
|
||||
|
||||
import {Disposable} from 'grainjs';
|
||||
import {Disposable, Observable} from 'grainjs';
|
||||
import * as _ from 'underscore';
|
||||
import * as ko from 'knockout';
|
||||
|
||||
const G = getBrowserGlobals('window');
|
||||
type BoolLike = boolean|ko.Observable<boolean>|ko.Computed<boolean>;
|
||||
type BoolLike = boolean|ko.Observable<boolean>|ko.Computed<boolean>|Observable<boolean>;
|
||||
|
||||
/**
|
||||
* A helper method that can create a subscription to ko or grains observables.
|
||||
*/
|
||||
function subscribe(value: Exclude<BoolLike, boolean>, fn: (value: boolean) => void) {
|
||||
if (ko.isObservable(value)) {
|
||||
return value.subscribe(fn);
|
||||
} else if (value instanceof Observable) {
|
||||
return value.addListener(fn);
|
||||
} else {
|
||||
throw new Error('Expected an observable');
|
||||
}
|
||||
}
|
||||
|
||||
// Same logic as used by mousetrap to map 'Mod' key to platform-specific key.
|
||||
export const isMac = (typeof navigator !== 'undefined' && navigator &&
|
||||
@@ -291,10 +303,11 @@ export class CommandGroup extends Disposable {
|
||||
this.onDispose(this._removeGroup.bind(this));
|
||||
|
||||
// Finally, set the activation status of the command group, subscribing if an observable.
|
||||
if (ko.isObservable(activate)) {
|
||||
this.autoDispose((activate as any).subscribeInit(this.activate, this));
|
||||
} else {
|
||||
this.activate(activate as boolean);
|
||||
if (typeof activate === 'boolean' || activate === undefined) {
|
||||
this.activate(activate ?? false);
|
||||
} else if (activate) {
|
||||
this.autoDispose(subscribe(activate, (val) => this.activate(val)));
|
||||
this.activate(unwrap(activate));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,8 +356,8 @@ type BoundedMap<T> = { [key in CommandName]?: BoundedFunc<T> };
|
||||
/**
|
||||
* Just a shorthand for CommandGroup.create constructor.
|
||||
*/
|
||||
export function createGroup<T>(commands: BoundedMap<T>, context: T, activate?: BoolLike) {
|
||||
return CommandGroup.create(null, commands, context, activate);
|
||||
export function createGroup<T>(commands: BoundedMap<T>|null, context: T, activate?: BoolLike) {
|
||||
return CommandGroup.create(null, commands ?? {}, context, activate);
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user