2020-10-02 15:10:00 +00:00
|
|
|
import * as commands from 'app/client/components/commands';
|
|
|
|
import {Cursor} from 'app/client/components/Cursor';
|
|
|
|
import {GristDoc} from 'app/client/components/GristDoc';
|
|
|
|
import {UnsavedChange} from 'app/client/components/UnsavedChanges';
|
|
|
|
import {DataRowModel} from 'app/client/models/DataRowModel';
|
2021-03-17 03:45:44 +00:00
|
|
|
import {ColumnRec} from 'app/client/models/entities/ColumnRec';
|
2020-10-02 15:10:00 +00:00
|
|
|
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
|
|
|
import {reportError} from 'app/client/models/errors';
|
2021-03-05 15:17:07 +00:00
|
|
|
import {showTooltipToCreateFormula} from 'app/client/widgets/EditorTooltip';
|
2020-10-02 15:10:00 +00:00
|
|
|
import {FormulaEditor} from 'app/client/widgets/FormulaEditor';
|
2021-02-04 03:17:17 +00:00
|
|
|
import {IEditorCommandGroup, NewBaseEditor} from 'app/client/widgets/NewBaseEditor';
|
2021-03-17 03:45:44 +00:00
|
|
|
import {asyncOnce} from "app/common/AsyncCreate";
|
2020-10-02 15:10:00 +00:00
|
|
|
import {CellValue} from "app/common/DocActions";
|
|
|
|
import {isRaisedException} from 'app/common/gristTypes';
|
|
|
|
import * as gutil from 'app/common/gutil';
|
2021-11-05 10:25:05 +00:00
|
|
|
import {Disposable, Emitter, Holder, MultiHolder, Observable} from 'grainjs';
|
(core) Revamp attachment editor + preview UI, and support more types, including PDFs.
Summary:
- New UI for the modal look mostly following the design prepared previously.
- Use <object> for rendering PDFs (on a Mac works on Firefox, Chrome, Safari; needs checking on Windows)
- While we are at it, use <video> and <audio> for relevant files (object would
render them too, but without the option to disable autoplay).
- Use <object> for unknown types, except for text/html (unsafe) and other text
types (need more work to render well).
- Fix skipping save on Escape or when attachments are unsaved (previously a
noop action was emitted, creating surprises with undo).
- Display extension for files without preview, both in-cell and in the modal.
- Replace tiny "eye" icon to preview particular attachment with double-clicking.
- As an accidental feature, a particular attachment can be previewed by typing 1, 2, 3, etc into cell.
- Renamed PreviewsWidget/PreviewModel to AttachmentsWidget/AttachmentsEditor.
Test Plan: Unified old and new tests for attachments, added new test cases.
Reviewers: paulfitz
Reviewed By: paulfitz
Differential Revision: https://phab.getgrist.com/D2667
2020-11-20 02:05:55 +00:00
|
|
|
import isEqual = require('lodash/isEqual');
|
2021-11-09 20:03:12 +00:00
|
|
|
import {CellPosition} from "app/client/components/CellPosition";
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
type IEditorConstructor = typeof NewBaseEditor;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Check if the typed-in value should change the cell without opening the cell editor, and if so,
|
|
|
|
* saves and returns true. E.g. on typing space, CheckBoxEditor toggles the cell without opening.
|
|
|
|
*/
|
|
|
|
export function saveWithoutEditor(
|
|
|
|
editorCtor: IEditorConstructor, editRow: DataRowModel, field: ViewFieldRec, typedVal: string|undefined
|
|
|
|
): boolean {
|
|
|
|
// Never skip the editor if editing a formula. Also, check that skipEditor static function
|
|
|
|
// exists (we don't bother adding it on old-style JS editors that don't need it).
|
|
|
|
if (!field.column.peek().isRealFormula.peek() && editorCtor.skipEditor) {
|
|
|
|
const origVal = editRow.cells[field.colId()].peek();
|
|
|
|
const skipEditorValue = editorCtor.skipEditor(typedVal, origVal);
|
|
|
|
if (skipEditorValue !== undefined) {
|
|
|
|
setAndSave(editRow, field, skipEditorValue).catch(reportError);
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Set the given field of editRow to value, only if different from the current value of the cell.
|
|
|
|
export async function setAndSave(editRow: DataRowModel, field: ViewFieldRec, value: CellValue): Promise<void> {
|
|
|
|
const obs = editRow.cells[field.colId()];
|
(core) Revamp attachment editor + preview UI, and support more types, including PDFs.
Summary:
- New UI for the modal look mostly following the design prepared previously.
- Use <object> for rendering PDFs (on a Mac works on Firefox, Chrome, Safari; needs checking on Windows)
- While we are at it, use <video> and <audio> for relevant files (object would
render them too, but without the option to disable autoplay).
- Use <object> for unknown types, except for text/html (unsafe) and other text
types (need more work to render well).
- Fix skipping save on Escape or when attachments are unsaved (previously a
noop action was emitted, creating surprises with undo).
- Display extension for files without preview, both in-cell and in the modal.
- Replace tiny "eye" icon to preview particular attachment with double-clicking.
- As an accidental feature, a particular attachment can be previewed by typing 1, 2, 3, etc into cell.
- Renamed PreviewsWidget/PreviewModel to AttachmentsWidget/AttachmentsEditor.
Test Plan: Unified old and new tests for attachments, added new test cases.
Reviewers: paulfitz
Reviewed By: paulfitz
Differential Revision: https://phab.getgrist.com/D2667
2020-11-20 02:05:55 +00:00
|
|
|
if (!isEqual(value, obs.peek())) {
|
2020-10-02 15:10:00 +00:00
|
|
|
return obs.setAndSave(value);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-05-25 09:24:00 +00:00
|
|
|
/**
|
|
|
|
* Event that is fired when editor stat has changed
|
|
|
|
*/
|
2021-05-23 17:43:11 +00:00
|
|
|
export interface FieldEditorStateEvent {
|
2021-05-25 09:24:00 +00:00
|
|
|
position: CellPosition,
|
|
|
|
wasModified: boolean,
|
|
|
|
currentState: any,
|
|
|
|
type: string
|
2021-05-17 14:05:49 +00:00
|
|
|
}
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
export class FieldEditor extends Disposable {
|
2021-05-17 14:05:49 +00:00
|
|
|
|
|
|
|
public readonly saveEmitter = this.autoDispose(new Emitter());
|
|
|
|
public readonly cancelEmitter = this.autoDispose(new Emitter());
|
|
|
|
public readonly changeEmitter = this.autoDispose(new Emitter());
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
private _gristDoc: GristDoc;
|
|
|
|
private _field: ViewFieldRec;
|
|
|
|
private _cursor: Cursor;
|
|
|
|
private _editRow: DataRowModel;
|
2021-02-04 03:17:17 +00:00
|
|
|
private _cellElem: Element;
|
|
|
|
private _editCommands: IEditorCommandGroup;
|
2020-10-02 15:10:00 +00:00
|
|
|
private _editorCtor: IEditorConstructor;
|
|
|
|
private _editorHolder: Holder<NewBaseEditor> = Holder.create(this);
|
2021-03-17 03:45:44 +00:00
|
|
|
private _saveEdit = asyncOnce(() => this._doSaveEdit());
|
2021-05-25 09:24:00 +00:00
|
|
|
private _editorHasChanged = false;
|
|
|
|
private _isFormula = false;
|
2021-06-17 16:41:07 +00:00
|
|
|
private _readonly = false;
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
constructor(options: {
|
|
|
|
gristDoc: GristDoc,
|
|
|
|
field: ViewFieldRec,
|
|
|
|
cursor: Cursor,
|
|
|
|
editRow: DataRowModel,
|
|
|
|
cellElem: Element,
|
|
|
|
editorCtor: IEditorConstructor,
|
|
|
|
startVal?: string,
|
2021-06-17 16:41:07 +00:00
|
|
|
state?: any,
|
|
|
|
readonly: boolean
|
2020-10-02 15:10:00 +00:00
|
|
|
}) {
|
|
|
|
super();
|
|
|
|
this._gristDoc = options.gristDoc;
|
|
|
|
this._field = options.field;
|
|
|
|
this._cursor = options.cursor;
|
|
|
|
this._editRow = options.editRow;
|
|
|
|
this._editorCtor = options.editorCtor;
|
2021-02-04 03:17:17 +00:00
|
|
|
this._cellElem = options.cellElem;
|
2021-06-17 16:41:07 +00:00
|
|
|
this._readonly = options.readonly;
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
const startVal = options.startVal;
|
2021-03-05 15:17:07 +00:00
|
|
|
let offerToMakeFormula = false;
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
const column = this._field.column();
|
2021-05-25 09:24:00 +00:00
|
|
|
this._isFormula = column.isRealFormula.peek();
|
2021-03-05 15:17:07 +00:00
|
|
|
let editValue: string|undefined = startVal;
|
2021-06-17 16:41:07 +00:00
|
|
|
if (!options.readonly && startVal && gutil.startsWith(startVal, '=')) {
|
2021-05-25 09:24:00 +00:00
|
|
|
if (this._isFormula || this._field.column().isEmpty()) {
|
2021-03-05 15:17:07 +00:00
|
|
|
// If we typed '=' on an empty column, convert it to a formula. If on a formula column,
|
|
|
|
// start editing ignoring the initial '='.
|
2021-05-25 09:24:00 +00:00
|
|
|
this._isFormula = true;
|
2021-03-05 15:17:07 +00:00
|
|
|
editValue = gutil.removePrefix(startVal, '=') as string;
|
|
|
|
} else {
|
|
|
|
// If we typed '=' on a non-empty column, only suggest to convert it to a formula.
|
|
|
|
offerToMakeFormula = true;
|
|
|
|
}
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// These are the commands for while the editor is active.
|
|
|
|
this._editCommands = {
|
|
|
|
// _saveEdit disables this command group, so when we run fieldEditSave again, it triggers
|
|
|
|
// another registered group, if any. E.g. GridView listens to it to move the cursor down.
|
|
|
|
fieldEditSave: () => {
|
|
|
|
this._saveEdit().then((jumped: boolean) => {
|
|
|
|
// To avoid confusing cursor movement, do not increment the rowIndex if the row
|
|
|
|
// was re-sorted after editing.
|
|
|
|
if (!jumped) { commands.allCommands.fieldEditSave.run(); }
|
|
|
|
})
|
|
|
|
.catch(reportError);
|
|
|
|
},
|
|
|
|
fieldEditSaveHere: () => { this._saveEdit().catch(reportError); },
|
2021-05-17 14:05:49 +00:00
|
|
|
fieldEditCancel: () => { this._cancelEdit(); },
|
2020-10-02 15:10:00 +00:00
|
|
|
prevField: () => { this._saveEdit().then(commands.allCommands.prevField.run).catch(reportError); },
|
|
|
|
nextField: () => { this._saveEdit().then(commands.allCommands.nextField.run).catch(reportError); },
|
|
|
|
makeFormula: () => this._makeFormula(),
|
|
|
|
unmakeFormula: () => this._unmakeFormula(),
|
|
|
|
};
|
|
|
|
|
2021-06-17 16:41:07 +00:00
|
|
|
// for readonly editor rewire commands, most of this also could be
|
|
|
|
// done by just overriding the saveEdit method, but this is more clearer
|
|
|
|
if (options.readonly) {
|
|
|
|
this._editCommands.fieldEditSave = () => {
|
|
|
|
// those two lines are tightly coupled - without disposing first
|
|
|
|
// it will run itself in a loop. But this is needed for a GridView
|
|
|
|
// which navigates to the next row on save.
|
|
|
|
this._editCommands.fieldEditCancel();
|
|
|
|
commands.allCommands.fieldEditSave.run();
|
|
|
|
};
|
|
|
|
this._editCommands.fieldEditSaveHere = this._editCommands.fieldEditCancel;
|
|
|
|
this._editCommands.prevField = () => { this._cancelEdit(); commands.allCommands.prevField.run(); };
|
|
|
|
this._editCommands.nextField = () => { this._cancelEdit(); commands.allCommands.nextField.run(); };
|
|
|
|
this._editCommands.makeFormula = () => true; /* don't stop propagation */
|
|
|
|
this._editCommands.unmakeFormula = () => true;
|
|
|
|
}
|
2021-05-17 14:05:49 +00:00
|
|
|
|
2021-06-17 16:41:07 +00:00
|
|
|
this.rebuildEditor(editValue, Number.POSITIVE_INFINITY, options.state);
|
2020-10-02 15:10:00 +00:00
|
|
|
|
2021-03-05 15:17:07 +00:00
|
|
|
if (offerToMakeFormula) {
|
|
|
|
this._offerToMakeFormula();
|
|
|
|
}
|
|
|
|
|
2021-05-17 14:05:49 +00:00
|
|
|
// connect this editor to editor monitor, it will restore this editor
|
|
|
|
// when user or server refreshes the browser
|
|
|
|
this._gristDoc.editorMonitor.monitorEditor(this);
|
|
|
|
|
2021-06-17 16:41:07 +00:00
|
|
|
// for readonly field we don't need to do anything special
|
|
|
|
if (!options.readonly) {
|
|
|
|
setupEditorCleanup(this, this._gristDoc, this._field, this._saveEdit);
|
|
|
|
} else {
|
|
|
|
setupReadonlyEditorCleanup(this, this._gristDoc, this._field, () => this._cancelEdit());
|
|
|
|
}
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// cursorPos refers to the position of the caret within the editor.
|
2021-05-25 09:24:00 +00:00
|
|
|
public rebuildEditor(editValue: string|undefined, cursorPos: number, state?: any) {
|
|
|
|
const editorCtor: IEditorConstructor = this._isFormula ? FormulaEditor : this._editorCtor;
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
const column = this._field.column();
|
|
|
|
const cellCurrentValue = this._editRow.cells[this._field.colId()].peek();
|
2021-06-03 15:01:31 +00:00
|
|
|
let cellValue: CellValue;
|
|
|
|
if (column.isFormula()) {
|
|
|
|
cellValue = column.formula();
|
|
|
|
} else if (Array.isArray(cellCurrentValue) && cellCurrentValue[0] === 'C') {
|
|
|
|
// This cell value is censored by access control rules
|
|
|
|
// Really the rules should also block editing, but in case they don't, show a blank value
|
|
|
|
// rather than a 'C'. However if the user tries to edit the cell and then clicks away
|
|
|
|
// without typing anything the empty string is saved, deleting what was there.
|
|
|
|
// We should probably just automatically block updates where reading is not allowed.
|
|
|
|
cellValue = '';
|
|
|
|
} else {
|
|
|
|
cellValue = cellCurrentValue;
|
|
|
|
}
|
2020-10-02 15:10:00 +00:00
|
|
|
|
2021-06-17 16:41:07 +00:00
|
|
|
const error = getFormulaError(this._gristDoc, this._editRow, column);
|
|
|
|
|
|
|
|
// For readonly mode use the default behavior of Formula Editor
|
|
|
|
// TODO: cleanup this flag - it gets modified in too many places
|
|
|
|
if (!this._readonly){
|
|
|
|
// Enter formula-editing mode (e.g. click-on-column inserts its ID) only if we are opening the
|
|
|
|
// editor by typing into it (and overriding previous formula). In other cases (e.g. double-click),
|
|
|
|
// we defer this mode until the user types something.
|
|
|
|
this._field.editingFormula(this._isFormula && editValue !== undefined);
|
|
|
|
}
|
2020-10-02 15:10:00 +00:00
|
|
|
|
2021-05-25 09:24:00 +00:00
|
|
|
this._editorHasChanged = false;
|
2020-10-02 15:10:00 +00:00
|
|
|
// Replace the item in the Holder with a new one, disposing the previous one.
|
|
|
|
const editor = this._editorHolder.autoDispose(editorCtor.create({
|
|
|
|
gristDoc: this._gristDoc,
|
|
|
|
field: this._field,
|
|
|
|
cellValue,
|
2021-06-17 16:41:07 +00:00
|
|
|
formulaError: error,
|
2020-10-02 15:10:00 +00:00
|
|
|
editValue,
|
|
|
|
cursorPos,
|
2021-05-17 14:05:49 +00:00
|
|
|
state,
|
2020-10-02 15:10:00 +00:00
|
|
|
commands: this._editCommands,
|
2021-06-17 16:41:07 +00:00
|
|
|
readonly : this._readonly
|
2020-10-02 15:10:00 +00:00
|
|
|
}));
|
2021-05-17 14:05:49 +00:00
|
|
|
|
|
|
|
// if editor supports live changes, connect it to the change emitter
|
|
|
|
if (editor.editorState) {
|
|
|
|
editor.autoDispose(editor.editorState.addListener((currentState) => {
|
2021-05-25 09:24:00 +00:00
|
|
|
this._editorHasChanged = true;
|
2021-05-23 17:43:11 +00:00
|
|
|
const event: FieldEditorStateEvent = {
|
2021-05-25 09:24:00 +00:00
|
|
|
position : this.cellPosition(),
|
|
|
|
wasModified : this._editorHasChanged,
|
2021-05-17 14:05:49 +00:00
|
|
|
currentState,
|
2021-05-23 17:43:11 +00:00
|
|
|
type: this._field.column.peek().pureType.peek()
|
|
|
|
};
|
2021-05-17 14:05:49 +00:00
|
|
|
this.changeEmitter.emit(event);
|
|
|
|
}));
|
|
|
|
}
|
|
|
|
|
2021-02-04 03:17:17 +00:00
|
|
|
editor.attach(this._cellElem);
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
|
|
|
|
2021-05-25 09:24:00 +00:00
|
|
|
public getDom() {
|
|
|
|
return this._editorHolder.get()?.getDom();
|
|
|
|
}
|
|
|
|
|
2021-05-17 14:05:49 +00:00
|
|
|
// calculate current cell's absolute position
|
2021-05-25 09:24:00 +00:00
|
|
|
public cellPosition() {
|
2021-05-17 14:05:49 +00:00
|
|
|
const rowId = this._editRow.getRowId();
|
|
|
|
const colRef = this._field.colRef.peek();
|
|
|
|
const sectionId = this._field.viewSection.peek().id.peek();
|
|
|
|
const position = {
|
|
|
|
rowId,
|
|
|
|
colRef,
|
|
|
|
sectionId
|
2021-05-23 17:43:11 +00:00
|
|
|
};
|
2021-05-17 14:05:49 +00:00
|
|
|
return position;
|
|
|
|
}
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
private _makeFormula() {
|
|
|
|
const editor = this._editorHolder.get();
|
2021-03-05 15:17:07 +00:00
|
|
|
// On keyPress of "=" on textInput, consider turning the column into a formula.
|
2020-10-02 15:10:00 +00:00
|
|
|
if (editor && !this._field.editingFormula.peek() && editor.getCursorPos() === 0) {
|
2021-03-05 15:17:07 +00:00
|
|
|
if (this._field.column().isEmpty()) {
|
2021-05-25 09:24:00 +00:00
|
|
|
this._isFormula = true;
|
2021-03-05 15:17:07 +00:00
|
|
|
// If we typed '=' an empty column, convert it to a formula.
|
2021-05-25 09:24:00 +00:00
|
|
|
this.rebuildEditor(editor.getTextValue(), 0);
|
2021-03-05 15:17:07 +00:00
|
|
|
return false;
|
|
|
|
} else {
|
|
|
|
// If we typed '=' on a non-empty column, only suggest to convert it to a formula.
|
|
|
|
this._offerToMakeFormula();
|
|
|
|
}
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
|
|
|
return true; // don't stop propagation.
|
|
|
|
}
|
|
|
|
|
|
|
|
private _unmakeFormula() {
|
|
|
|
const editor = this._editorHolder.get();
|
|
|
|
// Only convert to data if we are undoing a to-formula conversion. To convert formula to
|
2021-03-05 15:17:07 +00:00
|
|
|
// data, use column menu option, or delete the formula first (which makes the column "empty").
|
2020-10-02 15:10:00 +00:00
|
|
|
if (editor && this._field.editingFormula.peek() && editor.getCursorPos() === 0 &&
|
|
|
|
!this._field.column().isRealFormula()) {
|
|
|
|
// Restore a plain '=' character. This gives a way to enter "=" at the start if line. The
|
|
|
|
// second backspace will delete it.
|
2021-05-25 09:24:00 +00:00
|
|
|
this._isFormula = false;
|
|
|
|
this.rebuildEditor('=' + editor.getTextValue(), 1);
|
2020-10-02 15:10:00 +00:00
|
|
|
return false;
|
|
|
|
}
|
|
|
|
return true; // don't stop propagation.
|
|
|
|
}
|
|
|
|
|
2021-03-05 15:17:07 +00:00
|
|
|
private _offerToMakeFormula() {
|
|
|
|
const editorDom = this._editorHolder.get()?.getDom();
|
|
|
|
if (!editorDom) { return; }
|
|
|
|
showTooltipToCreateFormula(editorDom, () => this._convertEditorToFormula());
|
|
|
|
}
|
|
|
|
|
|
|
|
private _convertEditorToFormula() {
|
|
|
|
const editor = this._editorHolder.get();
|
|
|
|
if (editor) {
|
|
|
|
const editValue = editor.getTextValue();
|
|
|
|
const formulaValue = editValue.startsWith('=') ? editValue.slice(1) : editValue;
|
2021-05-25 09:24:00 +00:00
|
|
|
this._isFormula = true;
|
|
|
|
this.rebuildEditor(formulaValue, 0);
|
2021-03-05 15:17:07 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-05-17 14:05:49 +00:00
|
|
|
// Cancels the edit
|
|
|
|
private _cancelEdit() {
|
2021-06-17 16:41:07 +00:00
|
|
|
if (this.isDisposed()) { return; }
|
2021-05-23 17:43:11 +00:00
|
|
|
const event: FieldEditorStateEvent = {
|
2021-05-25 09:24:00 +00:00
|
|
|
position : this.cellPosition(),
|
|
|
|
wasModified : this._editorHasChanged,
|
|
|
|
currentState : this._editorHolder.get()?.editorState?.get(),
|
|
|
|
type : this._field.column.peek().pureType.peek()
|
2021-05-23 17:43:11 +00:00
|
|
|
};
|
2021-05-17 14:05:49 +00:00
|
|
|
this.cancelEmitter.emit(event);
|
|
|
|
this.dispose();
|
|
|
|
}
|
|
|
|
|
2021-03-05 15:17:07 +00:00
|
|
|
// Returns true if Enter/Shift+Enter should NOT move the cursor, for instance if the current
|
|
|
|
// record got reordered (i.e. the cursor jumped), or when editing a formula.
|
2020-10-02 15:10:00 +00:00
|
|
|
private async _doSaveEdit(): Promise<boolean> {
|
|
|
|
const editor = this._editorHolder.get();
|
|
|
|
if (!editor) { return false; }
|
|
|
|
// Make sure the editor is save ready
|
|
|
|
const saveIndex = this._cursor.rowIndex();
|
|
|
|
await editor.prepForSave();
|
|
|
|
if (this.isDisposed()) {
|
|
|
|
// We shouldn't normally get disposed here, but if we do, avoid confusing JS errors.
|
|
|
|
console.warn("Unable to finish saving edited cell"); // tslint:disable-line:no-console
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Then save the value the appropriate way
|
|
|
|
// TODO: this isFormula value doesn't actually reflect if editing the formula, since
|
|
|
|
// editingFormula() is used for toggling column headers, and this is deferred to start of
|
|
|
|
// typing (a double-click or Enter) does not immediately set it. (This can cause a
|
|
|
|
// console.warn below, although harmless.)
|
2021-03-05 15:17:07 +00:00
|
|
|
const isFormula = this._field.editingFormula();
|
2020-10-02 15:10:00 +00:00
|
|
|
const col = this._field.column();
|
|
|
|
let waitPromise: Promise<unknown>|null = null;
|
|
|
|
|
|
|
|
if (isFormula) {
|
|
|
|
const formula = editor.getCellValue();
|
|
|
|
// Bundle multiple changes so that we can undo them in one step.
|
|
|
|
if (isFormula !== col.isFormula.peek() || formula !== col.formula.peek()) {
|
|
|
|
waitPromise = this._gristDoc.docData.bundleActions(null, () => Promise.all([
|
|
|
|
col.updateColValues({isFormula, formula}),
|
|
|
|
// If we're saving a non-empty formula, then also add an empty record to the table
|
|
|
|
// so that the formula calculation is visible to the user.
|
|
|
|
(this._editRow._isAddRow.peek() && formula !== "" ?
|
|
|
|
this._editRow.updateColValues({}) : undefined),
|
|
|
|
]));
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
const value = editor.getCellValue();
|
|
|
|
if (col.isRealFormula()) {
|
|
|
|
// tslint:disable-next-line:no-console
|
|
|
|
console.warn("It should be impossible to save a plain data value into a formula column");
|
|
|
|
} else {
|
|
|
|
// This could still be an isFormula column if it's empty (isEmpty is true), but we don't
|
|
|
|
// need to toggle isFormula in that case, since the data engine takes care of that.
|
|
|
|
waitPromise = setAndSave(this._editRow, this._field, value);
|
|
|
|
}
|
|
|
|
}
|
2021-05-17 14:05:49 +00:00
|
|
|
|
2021-05-23 17:43:11 +00:00
|
|
|
const event: FieldEditorStateEvent = {
|
2021-05-25 09:24:00 +00:00
|
|
|
position : this.cellPosition(),
|
|
|
|
wasModified : this._editorHasChanged,
|
|
|
|
currentState : this._editorHolder.get()?.editorState?.get(),
|
|
|
|
type : this._field.column.peek().pureType.peek()
|
2021-05-23 17:43:11 +00:00
|
|
|
};
|
2021-05-17 14:05:49 +00:00
|
|
|
this.saveEmitter.emit(event);
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
const cursor = this._cursor;
|
|
|
|
// Deactivate the editor. We are careful to avoid using `this` afterwards.
|
|
|
|
this.dispose();
|
|
|
|
await waitPromise;
|
2021-03-05 15:17:07 +00:00
|
|
|
return isFormula || (saveIndex !== cursor.rowIndex());
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
|
|
|
}
|
2021-03-17 03:45:44 +00:00
|
|
|
|
|
|
|
/**
|
2021-11-09 20:03:12 +00:00
|
|
|
* Open a formula editor. Returns a Disposable that owns the editor.
|
2021-03-17 03:45:44 +00:00
|
|
|
*/
|
2021-11-09 20:03:12 +00:00
|
|
|
export function openFormulaEditor(options: {
|
2021-03-17 03:45:44 +00:00
|
|
|
gristDoc: GristDoc,
|
|
|
|
field: ViewFieldRec,
|
2021-11-09 20:03:12 +00:00
|
|
|
// Needed to get exception value, if any.
|
|
|
|
editRow?: DataRowModel,
|
|
|
|
// Element over which to position the editor.
|
|
|
|
refElem: Element,
|
2021-11-05 10:25:05 +00:00
|
|
|
editValue?: string,
|
2021-11-30 08:59:04 +00:00
|
|
|
onSave?: (column: ColumnRec, formula: string) => Promise<void>,
|
2021-11-05 10:25:05 +00:00
|
|
|
onCancel?: () => void,
|
2021-11-09 20:03:12 +00:00
|
|
|
// Called after editor is created to set up editor cleanup (e.g. saving on click-away).
|
|
|
|
setupCleanup: (
|
|
|
|
owner: MultiHolder,
|
|
|
|
doc: GristDoc,
|
|
|
|
field: ViewFieldRec,
|
|
|
|
save: () => Promise<void>
|
|
|
|
) => void,
|
2021-11-05 10:25:05 +00:00
|
|
|
}): Disposable {
|
2021-11-09 20:03:12 +00:00
|
|
|
const {gristDoc, field, editRow, refElem, setupCleanup} = options;
|
2021-03-17 03:45:44 +00:00
|
|
|
const holder = MultiHolder.create(null);
|
|
|
|
const column = field.column();
|
|
|
|
|
|
|
|
// AsyncOnce ensures it's called once even if triggered multiple times.
|
|
|
|
const saveEdit = asyncOnce(async () => {
|
|
|
|
const formula = editor.getCellValue();
|
2021-11-05 10:25:05 +00:00
|
|
|
if (options.onSave) {
|
2021-11-30 08:59:04 +00:00
|
|
|
await options.onSave(column, formula as string);
|
2021-11-05 10:25:05 +00:00
|
|
|
} else if (formula !== column.formula.peek()) {
|
2021-03-17 03:45:44 +00:00
|
|
|
await column.updateColValues({formula});
|
|
|
|
}
|
2021-11-05 10:25:05 +00:00
|
|
|
holder.dispose();
|
2021-03-17 03:45:44 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
// These are the commands for while the editor is active.
|
|
|
|
const editCommands = {
|
|
|
|
fieldEditSave: () => { saveEdit().catch(reportError); },
|
|
|
|
fieldEditSaveHere: () => { saveEdit().catch(reportError); },
|
2021-11-05 10:25:05 +00:00
|
|
|
fieldEditCancel: () => { holder.dispose(); options.onCancel?.(); },
|
2021-03-17 03:45:44 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
// Replace the item in the Holder with a new one, disposing the previous one.
|
|
|
|
const editor = FormulaEditor.create(holder, {
|
|
|
|
gristDoc,
|
|
|
|
field,
|
|
|
|
cellValue: column.formula(),
|
2021-11-09 20:03:12 +00:00
|
|
|
formulaError: editRow ? getFormulaError(gristDoc, editRow, column) : undefined,
|
2021-11-05 10:25:05 +00:00
|
|
|
editValue: options.editValue,
|
2021-03-17 03:45:44 +00:00
|
|
|
cursorPos: Number.POSITIVE_INFINITY, // Position of the caret within the editor.
|
|
|
|
commands: editCommands,
|
|
|
|
cssClass: 'formula_editor_sidepane',
|
2021-06-17 16:41:07 +00:00
|
|
|
readonly : false
|
2021-03-17 03:45:44 +00:00
|
|
|
});
|
|
|
|
editor.attach(refElem);
|
|
|
|
|
2021-12-22 14:28:27 +00:00
|
|
|
// When formula is empty enter formula-editing mode (highlight formula icons; click on a column inserts its ID).
|
2022-02-19 09:46:49 +00:00
|
|
|
// This function is used for primarily for switching between different column behaviors, so we want to enter full
|
2021-12-22 14:28:27 +00:00
|
|
|
// edit mode right away.
|
2022-02-19 09:46:49 +00:00
|
|
|
// TODO: consider converting it to parameter, when this will be used in different scenarios.
|
2021-12-22 14:28:27 +00:00
|
|
|
if (!column.formula()) {
|
|
|
|
field.editingFormula(true);
|
|
|
|
}
|
2021-11-09 20:03:12 +00:00
|
|
|
setupCleanup(holder, gristDoc, field, saveEdit);
|
2021-03-17 03:45:44 +00:00
|
|
|
return holder;
|
|
|
|
}
|
|
|
|
|
2021-06-17 16:41:07 +00:00
|
|
|
/**
|
|
|
|
* For an readonly editor, set up its cleanup:
|
|
|
|
* - canceling on click-away (when focus returns to Grist "clipboard" element)
|
|
|
|
*/
|
|
|
|
function setupReadonlyEditorCleanup(
|
|
|
|
owner: MultiHolder, gristDoc: GristDoc, field: ViewFieldRec, cancelEdit: () => any
|
|
|
|
) {
|
|
|
|
// Whenever focus returns to the Clipboard component, close the editor by saving the value.
|
|
|
|
gristDoc.app.on('clipboard_focus', cancelEdit);
|
|
|
|
owner.onDispose(() => {
|
|
|
|
field.editingFormula(false);
|
|
|
|
gristDoc.app.off('clipboard_focus', cancelEdit);
|
|
|
|
});
|
|
|
|
}
|
2021-03-17 03:45:44 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* For an active editor, set up its cleanup:
|
|
|
|
* - saving on click-away (when focus returns to Grist "clipboard" element)
|
|
|
|
* - unset field.editingFormula mode
|
|
|
|
* - Arrange for UnsavedChange protection against leaving the page with unsaved changes.
|
|
|
|
*/
|
2021-11-09 20:03:12 +00:00
|
|
|
export function setupEditorCleanup(
|
2021-06-22 22:19:28 +00:00
|
|
|
owner: MultiHolder, gristDoc: GristDoc, field: ViewFieldRec, _saveEdit: () => Promise<unknown>
|
2021-03-17 03:45:44 +00:00
|
|
|
) {
|
2021-06-22 22:19:28 +00:00
|
|
|
const saveEdit = () => _saveEdit().catch(reportError);
|
|
|
|
|
2021-03-17 03:45:44 +00:00
|
|
|
// Whenever focus returns to the Clipboard component, close the editor by saving the value.
|
|
|
|
gristDoc.app.on('clipboard_focus', saveEdit);
|
|
|
|
|
|
|
|
// TODO: This should ideally include a callback that returns true only when the editor value
|
|
|
|
// has changed. Currently an open editor is considered unsaved even when unchanged.
|
|
|
|
UnsavedChange.create(owner, async () => { await saveEdit(); });
|
|
|
|
|
|
|
|
owner.onDispose(() => {
|
|
|
|
gristDoc.app.off('clipboard_focus', saveEdit);
|
|
|
|
// Unset field.editingFormula flag when the editor closes.
|
|
|
|
field.editingFormula(false);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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.
|
|
|
|
*/
|
|
|
|
function getFormulaError(
|
|
|
|
gristDoc: GristDoc, editRow: DataRowModel, column: ColumnRec
|
|
|
|
): Observable<CellValue>|undefined {
|
|
|
|
const colId = column.colId.peek();
|
|
|
|
const cellCurrentValue = editRow.cells[colId].peek();
|
2021-09-25 19:14:19 +00:00
|
|
|
const isFormula = column.isFormula() || column.hasTriggerFormula();
|
|
|
|
if (isFormula && isRaisedException(cellCurrentValue)) {
|
|
|
|
const formulaError = Observable.create(null, cellCurrentValue);
|
2021-03-17 03:45:44 +00:00
|
|
|
gristDoc.docData.getFormulaError(column.table().tableId(), colId, editRow.getRowId())
|
2021-09-25 19:14:19 +00:00
|
|
|
.then(value => {
|
|
|
|
formulaError.set(value);
|
|
|
|
})
|
2021-03-17 03:45:44 +00:00
|
|
|
.catch(reportError);
|
2021-09-25 19:14:19 +00:00
|
|
|
return formulaError;
|
2021-03-17 03:45:44 +00:00
|
|
|
}
|
2021-09-25 19:14:19 +00:00
|
|
|
return undefined;
|
2021-03-17 03:45:44 +00:00
|
|
|
}
|