mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
467 lines
14 KiB
TypeScript
467 lines
14 KiB
TypeScript
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 created
|
|
// 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.createUserMessage("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 = Holder.create<MultiHolder>(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 the previous MultiHolder along with all the previous listeners, and create a
|
|
// new MultiHolder for the new ones.
|
|
const mholder = MultiHolder.create(this._holder);
|
|
|
|
mholder.autoDispose(editor.changeEmitter.addListener((e: FieldEditorStateEvent) => {
|
|
this.cellModified.emit({
|
|
position: e.position,
|
|
state: e.currentState,
|
|
modified: e.wasModified
|
|
});
|
|
}));
|
|
|
|
// when user presses escape
|
|
mholder.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
|
|
mholder.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;
|