mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Draft cells
Summary: Cells will remember their previous state when user pressed the escape key. Grist will offer a way to continue with the draft, by showing notification and a tooltip above the editor. Test Plan: Browser tests were created Reviewers: dsagal Reviewed By: dsagal Differential Revision: https://phab.getgrist.com/D2822
This commit is contained in:
parent
8c6148dd9f
commit
5c0494fe29
@ -1,24 +1,25 @@
|
||||
import { CursorPos } from "app/client/components/Cursor";
|
||||
import { DocModel } from "app/client/models/DocModel";
|
||||
import { DocModel, ViewFieldRec } from "app/client/models/DocModel";
|
||||
import BaseRowModel = require("app/client/models/BaseRowModel");
|
||||
|
||||
/**
|
||||
* Absolute position of a cell in a document
|
||||
*/
|
||||
export interface CellPosition {
|
||||
sectionId: number;
|
||||
rowId: number;
|
||||
colRef: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if two positions are equal.
|
||||
* @param a First position
|
||||
* @param b Second position
|
||||
*/
|
||||
export function samePosition(a: CellPosition, b: CellPosition) {
|
||||
return a && b && a.colRef == b.colRef &&
|
||||
a.sectionId == b.sectionId &&
|
||||
a.rowId == b.rowId;
|
||||
export abstract class CellPosition {
|
||||
public static equals(a: CellPosition, b: CellPosition) {
|
||||
return a && b && a.colRef == b.colRef &&
|
||||
a.sectionId == b.sectionId &&
|
||||
a.rowId == b.rowId;
|
||||
}
|
||||
public static create(row: BaseRowModel, field: ViewFieldRec): CellPosition {
|
||||
const rowId = row.id.peek();
|
||||
const colRef = field.colRef.peek();
|
||||
const sectionId = field.viewSection.peek().id.peek();
|
||||
return { rowId, colRef, sectionId };
|
||||
}
|
||||
public sectionId: number;
|
||||
public rowId: number | string;
|
||||
public colRef: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -36,7 +37,7 @@ export function fromCursor(position: CursorPos, docModel: DocModel): CellPositio
|
||||
const colRef = section.viewFields().peek()[position.fieldIndex]?.colRef.peek();
|
||||
|
||||
const cursorPosition = {
|
||||
rowId: position.rowId,
|
||||
rowId: position.rowId as (string | number), // TODO: cursor position is wrongly typed
|
||||
colRef,
|
||||
sectionId: position.sectionId,
|
||||
};
|
||||
@ -57,7 +58,7 @@ export function toCursor(position: CellPosition, docModel: DocModel): CursorPos
|
||||
.findIndex(x => x.colRef.peek() == position.colRef);
|
||||
|
||||
const cursorPosition = {
|
||||
rowId: position.rowId,
|
||||
rowId: position.rowId as number, // this is hack, as cursor position can accept string
|
||||
fieldIndex,
|
||||
sectionId: position.sectionId
|
||||
};
|
||||
|
466
app/client/components/Drafts.ts
Normal file
466
app/client/components/Drafts.ts
Normal file
@ -0,0 +1,466 @@
|
||||
import { CellPosition, toCursor } from "app/client/components/CellPosition";
|
||||
import {
|
||||
Disposable, dom, Emitter, Holder, IDisposable, IDisposableOwner,
|
||||
IDomArgs, MultiHolder, styled, TagElem
|
||||
} from "grainjs";
|
||||
import { GristDoc } from "app/client/components/GristDoc";
|
||||
import { ITooltipControl, showTooltip, tooltipCloseButton } from "app/client/ui/tooltips";
|
||||
import { FieldEditorStateEvent } from "app/client/widgets/FieldEditor";
|
||||
import { colors, testId } from "app/client/ui2018/cssVars";
|
||||
import { cssLink } from "app/client/ui2018/links";
|
||||
|
||||
/**
|
||||
* Component that keeps track of editor's state (draft value). If user hits an escape button
|
||||
* by accident, this component will provide a way to continue the work.
|
||||
* Each editor can report its current state, that will be remembered and restored
|
||||
* when user whishes to continue his work.
|
||||
* Each document can have only one draft at a particular time, that
|
||||
* is cleared when changes occur on any other cell or the cursor navigates await from a cell.
|
||||
*
|
||||
* This component is built as a plugin for GristDoc. GristDoc, FieldBuilder, FieldEditor were just
|
||||
* extended in order to provide some public interface that this objects plugs into.
|
||||
* To disable the drafts, just simple remove it from GristDoc.
|
||||
*/
|
||||
export class Drafts extends Disposable {
|
||||
constructor(
|
||||
doc: GristDoc
|
||||
) {
|
||||
super();
|
||||
|
||||
// Here are all the parts that play some role in this feature
|
||||
|
||||
// Cursor will navigate the cursor on a view to a proper cell
|
||||
const cursor: Cursor = CursorAdapter.create(this, doc);
|
||||
// Storage will remember last draft
|
||||
const storage: Storage = StorageAdapter.create(this);
|
||||
// Notification will show notification with button to undo discard
|
||||
const notification: Notification = NotificationAdapter.create(this, doc);
|
||||
// Tooltip will hover above the editor and offer to continue from last edit
|
||||
const tooltip: Tooltip = TooltipAdapter.create(this, doc);
|
||||
// Editor will restore its previous state and inform about keyboard events
|
||||
const editor: Editor = EditorAdapter.create(this, doc);
|
||||
|
||||
// Here is the main use case describing how parts are connected
|
||||
|
||||
const when = makeWhen(this);
|
||||
|
||||
// When user cancels the editor
|
||||
when(editor.cellCancelled, (ev: StateChanged) => {
|
||||
// if the state of the editor hasn't changed
|
||||
if (!ev.modified) {
|
||||
// close the tooltip and notification
|
||||
tooltip.close();
|
||||
notification.close();
|
||||
// don't store the draft - we assume that user
|
||||
// actually wanted to discard the draft by pressing
|
||||
// escape again
|
||||
return;
|
||||
}
|
||||
// Show notification
|
||||
notification.showUndoDiscard();
|
||||
// Save draft in memory
|
||||
storage.save(ev);
|
||||
// Make sure that tooltip is not visible
|
||||
tooltip.close();
|
||||
});
|
||||
|
||||
// When user clicks notification to continue with the draft
|
||||
when(notification.pressed, async () => {
|
||||
// if the draft is there
|
||||
const draft = storage.get();
|
||||
if (draft) {
|
||||
// restore the position of a cell
|
||||
await cursor.goToCell(draft.position);
|
||||
// activate the editor
|
||||
await editor.activate();
|
||||
// and restore last draft
|
||||
editor.setState(draft.state);
|
||||
}
|
||||
// We don't need the draft any more.
|
||||
// If user presses escape one more time it will be crated
|
||||
// once again
|
||||
storage.clear();
|
||||
// Close the notification
|
||||
notification.close();
|
||||
// tooltip is not visible here, and will be shown
|
||||
// when editor is activated
|
||||
});
|
||||
|
||||
// When user doesn't do anything while the notification is visible
|
||||
// remove the draft when it disappears
|
||||
when(notification.disappeared, () => {
|
||||
storage.clear();
|
||||
});
|
||||
|
||||
// When editor is activated (user typed something or double clicked a cell)
|
||||
when(editor.activated, (pos: CellPosition) => {
|
||||
// if there was a draft for a cell
|
||||
if (storage.hasDraftFor(pos)) {
|
||||
// show tooltip to continue with a draft
|
||||
tooltip.showContinueDraft();
|
||||
}
|
||||
// make sure that notification is not visible
|
||||
notification.close();
|
||||
});
|
||||
|
||||
// When editor is modified, close tooltip after some time
|
||||
when(editor.cellModified, (_: StateChanged) => {
|
||||
tooltip.scheduleClose();
|
||||
});
|
||||
|
||||
// When user saves a cell
|
||||
when(editor.cellSaved, (_: StateChanged) => {
|
||||
// just close everything and clear draft
|
||||
storage.clear();
|
||||
tooltip.close();
|
||||
notification.close();
|
||||
});
|
||||
|
||||
// When a user clicks a tooltip to continue with a draft
|
||||
when(tooltip.click, () => {
|
||||
const draft = storage.get();
|
||||
// if there was a draft
|
||||
if (draft) {
|
||||
// restore the draft
|
||||
editor.setState(draft.state);
|
||||
}
|
||||
// close the tooltip
|
||||
tooltip.close();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////
|
||||
// Roles definition that abstract the way this feature interacts with Grist
|
||||
|
||||
/**
|
||||
* Cursor role can navigate the cursor to a proper cell
|
||||
*/
|
||||
interface Cursor {
|
||||
goToCell(pos: CellPosition): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor role represents active editor that is attached to a cell.
|
||||
*/
|
||||
interface Editor {
|
||||
// Occurs when user triggers the save operation (by the enter key, clicking away)
|
||||
cellSaved: TypedEmitter<StateChanged>;
|
||||
// Occurs when user triggers the save operation (by the enter key, clicking away)
|
||||
cellModified: TypedEmitter<StateChanged>;
|
||||
// Occurs when user typed something on a cell or double clicked it
|
||||
activated: TypedEmitter<CellPosition>;
|
||||
// Occurs when user cancels the edit (mainly by the escape key or by icon on mobile)
|
||||
cellCancelled: TypedEmitter<StateChanged>;
|
||||
// Editor can restore its state
|
||||
setState(state: any): void;
|
||||
// Editor can be shown up to the user on active cell
|
||||
activate(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notification that is shown to the user on the right bottom corner
|
||||
*/
|
||||
interface Notification {
|
||||
// Occurs when user clicked the notification
|
||||
pressed: Signal;
|
||||
// Occurs when notification disappears with no action from a user
|
||||
disappeared: Signal;
|
||||
// Notification can be closed if it is visible
|
||||
close(): void;
|
||||
// Show notification to the user, to inform him that he can continue with the draft
|
||||
showUndoDiscard(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage abstraction. Is responsible for storing latest
|
||||
* draft (position and state)
|
||||
*/
|
||||
interface Storage {
|
||||
// Retrieves latest draft data
|
||||
get(): State | null;
|
||||
// Stores latest draft data
|
||||
save(ev: State): void;
|
||||
// Checks if there is draft data at the position
|
||||
hasDraftFor(position: CellPosition): boolean;
|
||||
// Removes draft data
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tooltip role is responsible for showing tooltip over active field editor with an information
|
||||
* that the drafts is available, and a button to continue with the draft
|
||||
*/
|
||||
interface Tooltip {
|
||||
// Occurs when user clicks the button on the tooltip - so he wants
|
||||
// to continue with the draft
|
||||
click: Signal;
|
||||
// Show tooltip over active cell editor
|
||||
showContinueDraft(): void;
|
||||
// Close tooltip
|
||||
close(): void;
|
||||
// Close tooltip after some time
|
||||
scheduleClose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema of the information that is stored in the storage.
|
||||
*/
|
||||
interface State {
|
||||
// State of the editor
|
||||
state: any;
|
||||
// Cell position where the draft was created
|
||||
position: CellPosition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event that is emitted when editor state has changed
|
||||
*/
|
||||
interface StateChanged extends State {
|
||||
modified: boolean;
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////
|
||||
// Here are all the adapters for the roles above. They
|
||||
// abstract the way this feature interacts with the GristDoc
|
||||
|
||||
class CursorAdapter extends Disposable implements Cursor {
|
||||
constructor(private _doc: GristDoc) {
|
||||
super();
|
||||
}
|
||||
public async goToCell(pos: CellPosition): Promise<void> {
|
||||
await this._doc.recursiveMoveToCursorPos(toCursor(pos, this._doc.docModel), true);
|
||||
}
|
||||
}
|
||||
|
||||
class StorageAdapter extends Disposable implements Storage {
|
||||
private _memory: State | null;
|
||||
public get(): State | null {
|
||||
return this._memory;
|
||||
}
|
||||
public save(ev: State) {
|
||||
this._memory = ev;
|
||||
}
|
||||
public hasDraftFor(position: CellPosition): boolean {
|
||||
const item = this._memory;
|
||||
if (item && CellPosition.equals(item.position, position)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
public clear(): void {
|
||||
this._memory = null;
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationAdapter extends Disposable implements Notification {
|
||||
public readonly pressed: Signal;
|
||||
public readonly disappeared: Signal;
|
||||
private _hadAction = false;
|
||||
private _holder = Holder.create(this);
|
||||
|
||||
constructor(private _doc: GristDoc) {
|
||||
super();
|
||||
this.pressed = this.autoDispose(new Emitter());
|
||||
this.disappeared = this.autoDispose(new Emitter());
|
||||
}
|
||||
public close(): void {
|
||||
this._hadAction = true;
|
||||
this._holder.clear();
|
||||
}
|
||||
public showUndoDiscard() {
|
||||
const notifier = this._doc.app.topAppModel.notifier;
|
||||
const notification = notifier.createUserError("Undo discard", {
|
||||
message: () =>
|
||||
discardNotification(
|
||||
dom.on("click", () => {
|
||||
this._hadAction = true;
|
||||
this.pressed.emit();
|
||||
})
|
||||
)
|
||||
});
|
||||
notification.onDispose(() => {
|
||||
if (!this._hadAction) {
|
||||
this.disappeared.emit();
|
||||
}
|
||||
});
|
||||
this._holder.autoDispose(notification);
|
||||
this._hadAction = false;
|
||||
}
|
||||
}
|
||||
|
||||
class TooltipAdapter extends Disposable implements Tooltip {
|
||||
public readonly click: Signal;
|
||||
|
||||
// there can be only one tooltip at a time
|
||||
private _tooltip: ITooltipControl | null = null;
|
||||
private _scheduled = false;
|
||||
|
||||
constructor(private _doc: GristDoc) {
|
||||
super();
|
||||
this.click = this.autoDispose(new Emitter());
|
||||
|
||||
// make sure that the tooltip is closed when this object gets disposed
|
||||
this.onDispose(() => {
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
|
||||
public scheduleClose(): void {
|
||||
if (this._tooltip && !this._scheduled) {
|
||||
this._scheduled = true;
|
||||
const origClose = this._tooltip.close;
|
||||
this._tooltip.close = () => { clearTimeout(timer); origClose(); };
|
||||
const timer = setTimeout(this._tooltip.close, 6000);
|
||||
}
|
||||
}
|
||||
|
||||
public showContinueDraft(): void {
|
||||
// close tooltip if there was a previous one
|
||||
this.close();
|
||||
|
||||
// get the editor dom
|
||||
const editorDom = this._doc.activeEditor.get()?.getDom();
|
||||
if (!editorDom) {
|
||||
return;
|
||||
}
|
||||
|
||||
// attach the tooltip
|
||||
this._tooltip = showTooltip(
|
||||
editorDom,
|
||||
cellTooltip(() => this.click.emit()));
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
this._scheduled = false;
|
||||
this._tooltip?.close();
|
||||
this._tooltip = null;
|
||||
}
|
||||
}
|
||||
|
||||
class EditorAdapter extends Disposable implements Editor {
|
||||
public readonly cellSaved: TypedEmitter<StateChanged> = this.autoDispose(new Emitter());
|
||||
public readonly cellModified: TypedEmitter<StateChanged> = this.autoDispose(new Emitter());
|
||||
public readonly activated: TypedEmitter<CellPosition> = this.autoDispose(new Emitter());
|
||||
public readonly cellCancelled: TypedEmitter<StateChanged> = this.autoDispose(new Emitter());
|
||||
|
||||
private _holder = MultiHolder.create(this);
|
||||
|
||||
constructor(private _doc: GristDoc) {
|
||||
super();
|
||||
|
||||
// observe active editor
|
||||
this.autoDispose(_doc.activeEditor.addListener((editor) => {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
// when the editor is created we assume that it is visible to the user
|
||||
this.activated.emit(editor.cellPosition());
|
||||
|
||||
// auto dispose all the previous listeners
|
||||
this._holder.dispose();
|
||||
this._holder = MultiHolder.create(this);
|
||||
|
||||
this._holder.autoDispose(editor.changeEmitter.addListener((e: FieldEditorStateEvent) => {
|
||||
this.cellModified.emit({
|
||||
position: e.position,
|
||||
state: e.currentState,
|
||||
modified: e.wasModified
|
||||
});
|
||||
}));
|
||||
|
||||
// when user presses escape
|
||||
this._holder.autoDispose(editor.cancelEmitter.addListener((e: FieldEditorStateEvent) => {
|
||||
this.cellCancelled.emit({
|
||||
position: e.position,
|
||||
state: e.currentState,
|
||||
modified: e.wasModified
|
||||
});
|
||||
}));
|
||||
|
||||
// when user presses enter to save the value
|
||||
this._holder.autoDispose(editor.saveEmitter.addListener((e: FieldEditorStateEvent) => {
|
||||
this.cellSaved.emit({
|
||||
position: e.position,
|
||||
state: e.currentState,
|
||||
modified: e.wasModified
|
||||
});
|
||||
}));
|
||||
}));
|
||||
}
|
||||
|
||||
public setState(state: any): void {
|
||||
// rebuild active editor with a state from a draft
|
||||
this._doc.activeEditor.get()?.rebuildEditor(undefined, Number.POSITIVE_INFINITY, state);
|
||||
}
|
||||
|
||||
public async activate() {
|
||||
// open up the editor at current position
|
||||
await this._doc.activateEditorAtCursor({});
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////
|
||||
// Ui components
|
||||
|
||||
// Cell tooltip to restore the draft - it is visible over active editor
|
||||
const styledTooltip = styled('div', `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
--icon-color: ${colors.lightGreen};
|
||||
|
||||
& > .${cssLink.className} {
|
||||
margin-left: 8px;
|
||||
}
|
||||
`);
|
||||
|
||||
function cellTooltip(clb: () => any) {
|
||||
return function (ctl: ITooltipControl) {
|
||||
return styledTooltip(
|
||||
cssLink('Restore last edit',
|
||||
dom.on('mousedown', (ev) => { ev.preventDefault(); ctl.close(); clb(); }),
|
||||
testId('draft-tooltip'),
|
||||
),
|
||||
tooltipCloseButton(ctl),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
// Discard notification dom
|
||||
const styledNotification = styled('div', `
|
||||
cursor: pointer;
|
||||
color: ${colors.lightGreen};
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`);
|
||||
function discardNotification(...args: IDomArgs<TagElem<"div">>) {
|
||||
return styledNotification(
|
||||
"Undo Discard",
|
||||
testId("draft-notification"),
|
||||
...args
|
||||
);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////
|
||||
// Internal implementations - not relevant to main use case
|
||||
|
||||
// helper method to listen to the Emitter and dispose the listener with a parent
|
||||
function makeWhen(owner: IDisposableOwner) {
|
||||
return function <T extends EmitterType<any>>(emitter: T, handler: EmitterHandler<T>) {
|
||||
owner.autoDispose(emitter.addListener(handler as any));
|
||||
};
|
||||
}
|
||||
|
||||
// Default emitter is not typed, this augments the Emitter interface
|
||||
interface TypedEmitter<T> {
|
||||
emit(item: T): void;
|
||||
addListener(clb: (e: T) => any): IDisposable;
|
||||
}
|
||||
interface Signal {
|
||||
emit(): void;
|
||||
addListener(clb: () => any): IDisposable;
|
||||
}
|
||||
type EmitterType<T> = T extends TypedEmitter<infer E> ? TypedEmitter<E> : Signal;
|
||||
type EmitterHandler<T> = T extends TypedEmitter<infer E> ? ((e: E) => any) : () => any;
|
@ -54,6 +54,8 @@ import isEqual = require('lodash/isEqual');
|
||||
import * as BaseView from 'app/client/components/BaseView';
|
||||
import { CursorMonitor, ViewCursorPos } from "app/client/components/CursorMonitor";
|
||||
import { EditorMonitor } from "app/client/components/EditorMonitor";
|
||||
import { FieldEditor } from "app/client/widgets/FieldEditor";
|
||||
import { Drafts } from "app/client/components/Drafts";
|
||||
|
||||
const G = getBrowserGlobals('document', 'window');
|
||||
|
||||
@ -101,6 +103,8 @@ export class GristDoc extends DisposableWithEvents {
|
||||
public cursorMonitor: CursorMonitor;
|
||||
// component for keeping track of a cell that is being edited
|
||||
public editorMonitor: EditorMonitor;
|
||||
// component for keeping track of a cell that is being edited
|
||||
public draftMonitor: Drafts;
|
||||
|
||||
// Emitter triggered when the main doc area is resized.
|
||||
public readonly resizeEmitter = this.autoDispose(new Emitter());
|
||||
@ -109,6 +113,8 @@ export class GristDoc extends DisposableWithEvents {
|
||||
// previous one if any. The holder is maintained by GristDoc, so that we are guaranteed at
|
||||
// most one instance of FieldEditor at any time.
|
||||
public readonly fieldEditorHolder = Holder.create(this);
|
||||
// active field editor
|
||||
public readonly activeEditor: Observable<FieldEditor | null> = Observable.create(this, null);
|
||||
|
||||
// Holds current view that is currently rendered
|
||||
public currentView: Observable<BaseView | null>;
|
||||
@ -269,6 +275,7 @@ export class GristDoc extends DisposableWithEvents {
|
||||
return undefined;
|
||||
});
|
||||
|
||||
this.draftMonitor = Drafts.create(this, this);
|
||||
this.cursorMonitor = CursorMonitor.create(this, this);
|
||||
this.editorMonitor = EditorMonitor.create(this, this);
|
||||
}
|
||||
|
@ -213,7 +213,7 @@ export class Notifier extends Disposable implements INotifier {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a basic toast user error. By default, expires in 5 seconds.
|
||||
* Creates a basic toast user error. By default, expires in 10 seconds.
|
||||
* Takes an options objects to configure `expireSec` and `canUserClose`.
|
||||
* Set `expireSec` to 0 to prevent expiration.
|
||||
*
|
||||
|
@ -56,12 +56,16 @@ const openTooltips = new Map<string, ITooltipControl>();
|
||||
* Show tipContent briefly (2s by default), in a tooltip next to refElem (on top of it, by default).
|
||||
* See also ITipOptions.
|
||||
*/
|
||||
export function showTransientTooltip(refElem: Element, tipContent: DomContents, options: ITransientTipOptions = {}) {
|
||||
const ctl = showTooltip(refElem, () => tipContent, options);
|
||||
export function showTransientTooltip(
|
||||
refElem: Element,
|
||||
tipContent: DomContents | ITooltipContentFunc,
|
||||
options: ITransientTipOptions = {}) {
|
||||
const ctl = showTooltip(refElem, typeof tipContent == 'function' ? tipContent : () => tipContent, options);
|
||||
const origClose = ctl.close;
|
||||
ctl.close = () => { clearTimeout(timer); origClose(); };
|
||||
|
||||
const timer = setTimeout(ctl.close, options.timeoutMs || 2000);
|
||||
return ctl;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -492,6 +492,10 @@ export class FieldBuilder extends Disposable {
|
||||
// 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
|
||||
fieldEditor.onDispose(() => this.gristDoc.activeEditor.set(null));
|
||||
this.gristDoc.activeEditor.set(fieldEditor);
|
||||
}
|
||||
|
||||
public isEditorActive() {
|
||||
|
@ -47,10 +47,14 @@ export async function setAndSave(editRow: DataRowModel, field: ViewFieldRec, val
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Event that is fired when editor stat has changed
|
||||
*/
|
||||
export interface FieldEditorStateEvent {
|
||||
position: CellPosition;
|
||||
currentState: any;
|
||||
type: string;
|
||||
position: CellPosition,
|
||||
wasModified: boolean,
|
||||
currentState: any,
|
||||
type: string
|
||||
}
|
||||
|
||||
export class FieldEditor extends Disposable {
|
||||
@ -68,6 +72,8 @@ export class FieldEditor extends Disposable {
|
||||
private _editorCtor: IEditorConstructor;
|
||||
private _editorHolder: Holder<NewBaseEditor> = Holder.create(this);
|
||||
private _saveEdit = asyncOnce(() => this._doSaveEdit());
|
||||
private _editorHasChanged = false;
|
||||
private _isFormula = false;
|
||||
|
||||
constructor(options: {
|
||||
gristDoc: GristDoc,
|
||||
@ -91,13 +97,13 @@ export class FieldEditor extends Disposable {
|
||||
let offerToMakeFormula = false;
|
||||
|
||||
const column = this._field.column();
|
||||
let isFormula: boolean = column.isRealFormula.peek();
|
||||
this._isFormula = column.isRealFormula.peek();
|
||||
let editValue: string|undefined = startVal;
|
||||
if (startVal && gutil.startsWith(startVal, '=')) {
|
||||
if (isFormula || this._field.column().isEmpty()) {
|
||||
if (this._isFormula || this._field.column().isEmpty()) {
|
||||
// If we typed '=' on an empty column, convert it to a formula. If on a formula column,
|
||||
// start editing ignoring the initial '='.
|
||||
isFormula = true;
|
||||
this._isFormula = true;
|
||||
editValue = gutil.removePrefix(startVal, '=') as string;
|
||||
} else {
|
||||
// If we typed '=' on a non-empty column, only suggest to convert it to a formula.
|
||||
@ -127,7 +133,7 @@ export class FieldEditor extends Disposable {
|
||||
|
||||
const state: any = options.state;
|
||||
|
||||
this.rebuildEditor(isFormula, editValue, Number.POSITIVE_INFINITY, state);
|
||||
this.rebuildEditor(editValue, Number.POSITIVE_INFINITY, state);
|
||||
|
||||
if (offerToMakeFormula) {
|
||||
this._offerToMakeFormula();
|
||||
@ -141,8 +147,8 @@ export class FieldEditor extends Disposable {
|
||||
}
|
||||
|
||||
// cursorPos refers to the position of the caret within the editor.
|
||||
public rebuildEditor(isFormula: boolean, editValue: string|undefined, cursorPos: number, state?: any) {
|
||||
const editorCtor: IEditorConstructor = isFormula ? FormulaEditor : this._editorCtor;
|
||||
public rebuildEditor(editValue: string|undefined, cursorPos: number, state?: any) {
|
||||
const editorCtor: IEditorConstructor = this._isFormula ? FormulaEditor : this._editorCtor;
|
||||
|
||||
const column = this._field.column();
|
||||
const cellCurrentValue = this._editRow.cells[this._field.colId()].peek();
|
||||
@ -151,8 +157,9 @@ export class FieldEditor extends Disposable {
|
||||
// Enter formula-editing mode (e.g. click-on-column inserts its ID) only if we are opening the
|
||||
// editor by typing into it (and overriding previous formula). In other cases (e.g. double-click),
|
||||
// we defer this mode until the user types something.
|
||||
this._field.editingFormula(isFormula && editValue !== undefined);
|
||||
this._field.editingFormula(this._isFormula && editValue !== undefined);
|
||||
|
||||
this._editorHasChanged = false;
|
||||
// Replace the item in the Holder with a new one, disposing the previous one.
|
||||
const editor = this._editorHolder.autoDispose(editorCtor.create({
|
||||
gristDoc: this._gristDoc,
|
||||
@ -168,8 +175,10 @@ export class FieldEditor extends Disposable {
|
||||
// if editor supports live changes, connect it to the change emitter
|
||||
if (editor.editorState) {
|
||||
editor.autoDispose(editor.editorState.addListener((currentState) => {
|
||||
this._editorHasChanged = true;
|
||||
const event: FieldEditorStateEvent = {
|
||||
position: this._cellPosition(),
|
||||
position : this.cellPosition(),
|
||||
wasModified : this._editorHasChanged,
|
||||
currentState,
|
||||
type: this._field.column.peek().pureType.peek()
|
||||
};
|
||||
@ -180,8 +189,12 @@ export class FieldEditor extends Disposable {
|
||||
editor.attach(this._cellElem);
|
||||
}
|
||||
|
||||
public getDom() {
|
||||
return this._editorHolder.get()?.getDom();
|
||||
}
|
||||
|
||||
// calculate current cell's absolute position
|
||||
private _cellPosition() {
|
||||
public cellPosition() {
|
||||
const rowId = this._editRow.getRowId();
|
||||
const colRef = this._field.colRef.peek();
|
||||
const sectionId = this._field.viewSection.peek().id.peek();
|
||||
@ -198,8 +211,9 @@ export class FieldEditor extends Disposable {
|
||||
// On keyPress of "=" on textInput, consider turning the column into a formula.
|
||||
if (editor && !this._field.editingFormula.peek() && editor.getCursorPos() === 0) {
|
||||
if (this._field.column().isEmpty()) {
|
||||
this._isFormula = true;
|
||||
// If we typed '=' an empty column, convert it to a formula.
|
||||
this.rebuildEditor(true, editor.getTextValue(), 0);
|
||||
this.rebuildEditor(editor.getTextValue(), 0);
|
||||
return false;
|
||||
} else {
|
||||
// If we typed '=' on a non-empty column, only suggest to convert it to a formula.
|
||||
@ -217,7 +231,8 @@ export class FieldEditor extends Disposable {
|
||||
!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.
|
||||
this.rebuildEditor(false, '=' + editor.getTextValue(), 1);
|
||||
this._isFormula = false;
|
||||
this.rebuildEditor('=' + editor.getTextValue(), 1);
|
||||
return false;
|
||||
}
|
||||
return true; // don't stop propagation.
|
||||
@ -234,16 +249,18 @@ export class FieldEditor extends Disposable {
|
||||
if (editor) {
|
||||
const editValue = editor.getTextValue();
|
||||
const formulaValue = editValue.startsWith('=') ? editValue.slice(1) : editValue;
|
||||
this.rebuildEditor(true, formulaValue, 0);
|
||||
this._isFormula = true;
|
||||
this.rebuildEditor(formulaValue, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Cancels the edit
|
||||
private _cancelEdit() {
|
||||
const event: FieldEditorStateEvent = {
|
||||
position: this._cellPosition(),
|
||||
currentState: this._editorHolder.get()?.editorState?.get(),
|
||||
type: this._field.column.peek().pureType.peek()
|
||||
position : this.cellPosition(),
|
||||
wasModified : this._editorHasChanged,
|
||||
currentState : this._editorHolder.get()?.editorState?.get(),
|
||||
type : this._field.column.peek().pureType.peek()
|
||||
};
|
||||
this.cancelEmitter.emit(event);
|
||||
this.dispose();
|
||||
@ -297,9 +314,10 @@ export class FieldEditor extends Disposable {
|
||||
}
|
||||
|
||||
const event: FieldEditorStateEvent = {
|
||||
position: this._cellPosition(),
|
||||
currentState: this._editorHolder.get()?.editorState?.get(),
|
||||
type: this._field.column.peek().pureType.peek()
|
||||
position : this.cellPosition(),
|
||||
wasModified : this._editorHasChanged,
|
||||
currentState : this._editorHolder.get()?.editorState?.get(),
|
||||
type : this._field.column.peek().pureType.peek()
|
||||
};
|
||||
this.saveEmitter.emit(event);
|
||||
|
||||
|
@ -119,6 +119,10 @@ export class FormulaEditor extends NewBaseEditor {
|
||||
this._formulaEditor.editor.focus();
|
||||
}
|
||||
|
||||
public getDom(): HTMLElement {
|
||||
return this._dom;
|
||||
}
|
||||
|
||||
public getCellValue() {
|
||||
return this._formulaEditor.getValue();
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user