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 { 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
|
* Absolute position of a cell in a document
|
||||||
*/
|
*/
|
||||||
export interface CellPosition {
|
export abstract class CellPosition {
|
||||||
sectionId: number;
|
public static equals(a: CellPosition, b: CellPosition) {
|
||||||
rowId: number;
|
return a && b && a.colRef == b.colRef &&
|
||||||
colRef: number;
|
a.sectionId == b.sectionId &&
|
||||||
}
|
a.rowId == b.rowId;
|
||||||
|
}
|
||||||
/**
|
public static create(row: BaseRowModel, field: ViewFieldRec): CellPosition {
|
||||||
* Checks if two positions are equal.
|
const rowId = row.id.peek();
|
||||||
* @param a First position
|
const colRef = field.colRef.peek();
|
||||||
* @param b Second position
|
const sectionId = field.viewSection.peek().id.peek();
|
||||||
*/
|
return { rowId, colRef, sectionId };
|
||||||
export function samePosition(a: CellPosition, b: CellPosition) {
|
}
|
||||||
return a && b && a.colRef == b.colRef &&
|
public sectionId: number;
|
||||||
a.sectionId == b.sectionId &&
|
public rowId: number | string;
|
||||||
a.rowId == b.rowId;
|
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 colRef = section.viewFields().peek()[position.fieldIndex]?.colRef.peek();
|
||||||
|
|
||||||
const cursorPosition = {
|
const cursorPosition = {
|
||||||
rowId: position.rowId,
|
rowId: position.rowId as (string | number), // TODO: cursor position is wrongly typed
|
||||||
colRef,
|
colRef,
|
||||||
sectionId: position.sectionId,
|
sectionId: position.sectionId,
|
||||||
};
|
};
|
||||||
@ -57,7 +58,7 @@ export function toCursor(position: CellPosition, docModel: DocModel): CursorPos
|
|||||||
.findIndex(x => x.colRef.peek() == position.colRef);
|
.findIndex(x => x.colRef.peek() == position.colRef);
|
||||||
|
|
||||||
const cursorPosition = {
|
const cursorPosition = {
|
||||||
rowId: position.rowId,
|
rowId: position.rowId as number, // this is hack, as cursor position can accept string
|
||||||
fieldIndex,
|
fieldIndex,
|
||||||
sectionId: position.sectionId
|
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 * as BaseView from 'app/client/components/BaseView';
|
||||||
import { CursorMonitor, ViewCursorPos } from "app/client/components/CursorMonitor";
|
import { CursorMonitor, ViewCursorPos } from "app/client/components/CursorMonitor";
|
||||||
import { EditorMonitor } from "app/client/components/EditorMonitor";
|
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');
|
const G = getBrowserGlobals('document', 'window');
|
||||||
|
|
||||||
@ -101,6 +103,8 @@ export class GristDoc extends DisposableWithEvents {
|
|||||||
public cursorMonitor: CursorMonitor;
|
public cursorMonitor: CursorMonitor;
|
||||||
// component for keeping track of a cell that is being edited
|
// component for keeping track of a cell that is being edited
|
||||||
public editorMonitor: EditorMonitor;
|
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.
|
// Emitter triggered when the main doc area is resized.
|
||||||
public readonly resizeEmitter = this.autoDispose(new Emitter());
|
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
|
// previous one if any. The holder is maintained by GristDoc, so that we are guaranteed at
|
||||||
// most one instance of FieldEditor at any time.
|
// most one instance of FieldEditor at any time.
|
||||||
public readonly fieldEditorHolder = Holder.create(this);
|
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
|
// Holds current view that is currently rendered
|
||||||
public currentView: Observable<BaseView | null>;
|
public currentView: Observable<BaseView | null>;
|
||||||
@ -269,6 +275,7 @@ export class GristDoc extends DisposableWithEvents {
|
|||||||
return undefined;
|
return undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.draftMonitor = Drafts.create(this, this);
|
||||||
this.cursorMonitor = CursorMonitor.create(this, this);
|
this.cursorMonitor = CursorMonitor.create(this, this);
|
||||||
this.editorMonitor = EditorMonitor.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`.
|
* Takes an options objects to configure `expireSec` and `canUserClose`.
|
||||||
* Set `expireSec` to 0 to prevent expiration.
|
* 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).
|
* Show tipContent briefly (2s by default), in a tooltip next to refElem (on top of it, by default).
|
||||||
* See also ITipOptions.
|
* See also ITipOptions.
|
||||||
*/
|
*/
|
||||||
export function showTransientTooltip(refElem: Element, tipContent: DomContents, options: ITransientTipOptions = {}) {
|
export function showTransientTooltip(
|
||||||
const ctl = showTooltip(refElem, () => tipContent, options);
|
refElem: Element,
|
||||||
|
tipContent: DomContents | ITooltipContentFunc,
|
||||||
|
options: ITransientTipOptions = {}) {
|
||||||
|
const ctl = showTooltip(refElem, typeof tipContent == 'function' ? tipContent : () => tipContent, options);
|
||||||
const origClose = ctl.close;
|
const origClose = ctl.close;
|
||||||
ctl.close = () => { clearTimeout(timer); origClose(); };
|
ctl.close = () => { clearTimeout(timer); origClose(); };
|
||||||
|
|
||||||
const timer = setTimeout(ctl.close, options.timeoutMs || 2000);
|
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
|
// 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.
|
// will entail a number of other tweaks related to the order of creating and disposal.
|
||||||
this.gristDoc.fieldEditorHolder.autoDispose(fieldEditor);
|
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() {
|
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 {
|
export interface FieldEditorStateEvent {
|
||||||
position: CellPosition;
|
position: CellPosition,
|
||||||
currentState: any;
|
wasModified: boolean,
|
||||||
type: string;
|
currentState: any,
|
||||||
|
type: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export class FieldEditor extends Disposable {
|
export class FieldEditor extends Disposable {
|
||||||
@ -68,6 +72,8 @@ export class FieldEditor extends Disposable {
|
|||||||
private _editorCtor: IEditorConstructor;
|
private _editorCtor: IEditorConstructor;
|
||||||
private _editorHolder: Holder<NewBaseEditor> = Holder.create(this);
|
private _editorHolder: Holder<NewBaseEditor> = Holder.create(this);
|
||||||
private _saveEdit = asyncOnce(() => this._doSaveEdit());
|
private _saveEdit = asyncOnce(() => this._doSaveEdit());
|
||||||
|
private _editorHasChanged = false;
|
||||||
|
private _isFormula = false;
|
||||||
|
|
||||||
constructor(options: {
|
constructor(options: {
|
||||||
gristDoc: GristDoc,
|
gristDoc: GristDoc,
|
||||||
@ -91,13 +97,13 @@ export class FieldEditor extends Disposable {
|
|||||||
let offerToMakeFormula = false;
|
let offerToMakeFormula = false;
|
||||||
|
|
||||||
const column = this._field.column();
|
const column = this._field.column();
|
||||||
let isFormula: boolean = column.isRealFormula.peek();
|
this._isFormula = column.isRealFormula.peek();
|
||||||
let editValue: string|undefined = startVal;
|
let editValue: string|undefined = startVal;
|
||||||
if (startVal && gutil.startsWith(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,
|
// If we typed '=' on an empty column, convert it to a formula. If on a formula column,
|
||||||
// start editing ignoring the initial '='.
|
// start editing ignoring the initial '='.
|
||||||
isFormula = true;
|
this._isFormula = true;
|
||||||
editValue = gutil.removePrefix(startVal, '=') as string;
|
editValue = gutil.removePrefix(startVal, '=') as string;
|
||||||
} else {
|
} else {
|
||||||
// If we typed '=' on a non-empty column, only suggest to convert it to a formula.
|
// 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;
|
const state: any = options.state;
|
||||||
|
|
||||||
this.rebuildEditor(isFormula, editValue, Number.POSITIVE_INFINITY, state);
|
this.rebuildEditor(editValue, Number.POSITIVE_INFINITY, state);
|
||||||
|
|
||||||
if (offerToMakeFormula) {
|
if (offerToMakeFormula) {
|
||||||
this._offerToMakeFormula();
|
this._offerToMakeFormula();
|
||||||
@ -141,8 +147,8 @@ export class FieldEditor extends Disposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// cursorPos refers to the position of the caret within the editor.
|
// cursorPos refers to the position of the caret within the editor.
|
||||||
public rebuildEditor(isFormula: boolean, editValue: string|undefined, cursorPos: number, state?: any) {
|
public rebuildEditor(editValue: string|undefined, cursorPos: number, state?: any) {
|
||||||
const editorCtor: IEditorConstructor = isFormula ? FormulaEditor : this._editorCtor;
|
const editorCtor: IEditorConstructor = this._isFormula ? FormulaEditor : this._editorCtor;
|
||||||
|
|
||||||
const column = this._field.column();
|
const column = this._field.column();
|
||||||
const cellCurrentValue = this._editRow.cells[this._field.colId()].peek();
|
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
|
// 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(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.
|
// 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({
|
||||||
gristDoc: this._gristDoc,
|
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 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) => {
|
||||||
|
this._editorHasChanged = true;
|
||||||
const event: FieldEditorStateEvent = {
|
const event: FieldEditorStateEvent = {
|
||||||
position: this._cellPosition(),
|
position : this.cellPosition(),
|
||||||
|
wasModified : this._editorHasChanged,
|
||||||
currentState,
|
currentState,
|
||||||
type: this._field.column.peek().pureType.peek()
|
type: this._field.column.peek().pureType.peek()
|
||||||
};
|
};
|
||||||
@ -180,8 +189,12 @@ export class FieldEditor extends Disposable {
|
|||||||
editor.attach(this._cellElem);
|
editor.attach(this._cellElem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getDom() {
|
||||||
|
return this._editorHolder.get()?.getDom();
|
||||||
|
}
|
||||||
|
|
||||||
// calculate current cell's absolute position
|
// calculate current cell's absolute position
|
||||||
private _cellPosition() {
|
public cellPosition() {
|
||||||
const rowId = this._editRow.getRowId();
|
const rowId = this._editRow.getRowId();
|
||||||
const colRef = this._field.colRef.peek();
|
const colRef = this._field.colRef.peek();
|
||||||
const sectionId = this._field.viewSection.peek().id.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.
|
// On keyPress of "=" on textInput, consider turning the column into a formula.
|
||||||
if (editor && !this._field.editingFormula.peek() && editor.getCursorPos() === 0) {
|
if (editor && !this._field.editingFormula.peek() && editor.getCursorPos() === 0) {
|
||||||
if (this._field.column().isEmpty()) {
|
if (this._field.column().isEmpty()) {
|
||||||
|
this._isFormula = true;
|
||||||
// If we typed '=' an empty column, convert it to a formula.
|
// If we typed '=' an empty column, convert it to a formula.
|
||||||
this.rebuildEditor(true, editor.getTextValue(), 0);
|
this.rebuildEditor(editor.getTextValue(), 0);
|
||||||
return false;
|
return false;
|
||||||
} else {
|
} else {
|
||||||
// If we typed '=' on a non-empty column, only suggest to convert it to a formula.
|
// 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()) {
|
!this._field.column().isRealFormula()) {
|
||||||
// Restore a plain '=' character. This gives a way to enter "=" at the start if line. The
|
// Restore a plain '=' character. This gives a way to enter "=" at the start if line. The
|
||||||
// second backspace will delete it.
|
// second backspace will delete it.
|
||||||
this.rebuildEditor(false, '=' + editor.getTextValue(), 1);
|
this._isFormula = false;
|
||||||
|
this.rebuildEditor('=' + editor.getTextValue(), 1);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true; // don't stop propagation.
|
return true; // don't stop propagation.
|
||||||
@ -234,16 +249,18 @@ export class FieldEditor extends Disposable {
|
|||||||
if (editor) {
|
if (editor) {
|
||||||
const editValue = editor.getTextValue();
|
const editValue = editor.getTextValue();
|
||||||
const formulaValue = editValue.startsWith('=') ? editValue.slice(1) : editValue;
|
const formulaValue = editValue.startsWith('=') ? editValue.slice(1) : editValue;
|
||||||
this.rebuildEditor(true, formulaValue, 0);
|
this._isFormula = true;
|
||||||
|
this.rebuildEditor(formulaValue, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cancels the edit
|
// Cancels the edit
|
||||||
private _cancelEdit() {
|
private _cancelEdit() {
|
||||||
const event: FieldEditorStateEvent = {
|
const event: FieldEditorStateEvent = {
|
||||||
position: this._cellPosition(),
|
position : this.cellPosition(),
|
||||||
currentState: this._editorHolder.get()?.editorState?.get(),
|
wasModified : this._editorHasChanged,
|
||||||
type: this._field.column.peek().pureType.peek()
|
currentState : this._editorHolder.get()?.editorState?.get(),
|
||||||
|
type : this._field.column.peek().pureType.peek()
|
||||||
};
|
};
|
||||||
this.cancelEmitter.emit(event);
|
this.cancelEmitter.emit(event);
|
||||||
this.dispose();
|
this.dispose();
|
||||||
@ -297,9 +314,10 @@ export class FieldEditor extends Disposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const event: FieldEditorStateEvent = {
|
const event: FieldEditorStateEvent = {
|
||||||
position: this._cellPosition(),
|
position : this.cellPosition(),
|
||||||
currentState: this._editorHolder.get()?.editorState?.get(),
|
wasModified : this._editorHasChanged,
|
||||||
type: this._field.column.peek().pureType.peek()
|
currentState : this._editorHolder.get()?.editorState?.get(),
|
||||||
|
type : this._field.column.peek().pureType.peek()
|
||||||
};
|
};
|
||||||
this.saveEmitter.emit(event);
|
this.saveEmitter.emit(event);
|
||||||
|
|
||||||
|
@ -119,6 +119,10 @@ export class FormulaEditor extends NewBaseEditor {
|
|||||||
this._formulaEditor.editor.focus();
|
this._formulaEditor.editor.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getDom(): HTMLElement {
|
||||||
|
return this._dom;
|
||||||
|
}
|
||||||
|
|
||||||
public getCellValue() {
|
public getCellValue() {
|
||||||
return this._formulaEditor.getValue();
|
return this._formulaEditor.getValue();
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user