(core) Add support for editing on mobile.

Summary:
- Add custom handling for dblclick on mobile, to allow focusing editor.
- In place of Clipboard.js, use a FocusLayer with document.body as the default focus element.
- Set maximum-scale on iOS viewport to prevent auto-zoom.
- Reposition the editor on window resize when editing a cell, which is a normal
  occurrence on Android when virtual keyboard is shown.
- Add Save/Cancel icon-buttons next to cell editor on mobile.

Test Plan: Tested manually on Safari / FF on iPhone, and on Chrome on Android emulator.

Reviewers: paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D2721
This commit is contained in:
Dmitry S
2021-02-03 22:17:17 -05:00
parent 7c81cf2368
commit 7284644313
18 changed files with 271 additions and 75 deletions

View File

@@ -91,7 +91,7 @@ export class AttachmentsEditor extends NewBaseEditor {
}
// This "attach" is not about "attachments", but about attaching this widget to the page DOM.
public attach(cellRect: ClientRect|DOMRect) {
public attach(cellElem: Element) {
modal((ctl, owner) => {
// If FieldEditor is disposed externally (e.g. on navigation), be sure to close the modal.
this.onDispose(ctl.close);

View File

@@ -12,10 +12,10 @@ function BaseEditor(options) {
/**
* Called after the editor is instantiated to attach its DOM to the page.
* - cellRect: Bounding box of the element representing the cell that this editor should match
* - cellElem: The element representing the cell that this editor should match
* in size and position. Used by derived classes, e.g. to construct an EditorPlacement object.
*/
BaseEditor.prototype.attach = function(cellRect) {
BaseEditor.prototype.attach = function(cellElem) {
// No-op by default.
};

View File

@@ -0,0 +1,51 @@
import {isDesktop} from 'app/client/lib/browserInfo';
import {colors} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {IEditorCommandGroup} from 'app/client/widgets/NewBaseEditor';
import {dom, styled} from 'grainjs';
/**
* Creates Save/Cancel icon buttons to show next to the cell editor.
*/
export function createMobileButtons(commands: IEditorCommandGroup) {
// TODO A better check may be to detect a physical keyboard or touch support.
return isDesktop() ? null : [
cssCancelBtn(cssIconWrap(cssFinishIcon('CrossSmall')), dom.on('click', commands.fieldEditCancel)),
cssSaveBtn(cssIconWrap(cssFinishIcon('Tick')), dom.on('click', commands.fieldEditSaveHere)),
];
}
export function getButtonMargins() {
return isDesktop() ? undefined : {left: 20, right: 20, top: 0, bottom: 0};
}
const cssFinishBtn = styled('div', `
height: 40px;
width: 40px;
padding: 8px;
position: absolute;
top: -8px;
--icon-color: white;
`);
const cssCancelBtn = styled(cssFinishBtn, `
--icon-background-color: ${colors.error};
left: -40px;
`);
const cssSaveBtn = styled(cssFinishBtn, `
--icon-background-color: ${colors.lightGreen};
right: -40px;
`);
const cssIconWrap = styled('div', `
border-radius: 20px;
background-color: var(--icon-background-color);
height: 24px;
width: 24px;
`);
const cssFinishIcon = styled(icon, `
height: 24px;
width: 24px;
`);

View File

@@ -1,4 +1,4 @@
import {Disposable, dom} from 'grainjs';
import {Disposable, dom, Emitter} from 'grainjs';
export interface ISize {
width: number;
@@ -10,7 +10,15 @@ interface ISizeOpts {
calcOnly?: boolean;
}
// edgeMargin is how many pixels to leave before the edge of the browser window.
export interface IMargins {
top: number;
bottom: number;
left: number;
right: number;
}
// edgeMargin is how many pixels to leave before the edge of the browser window by default.
// This is added to margins that may be passed into the constructor.
const edgeMargin = 12;
// How large the editor can get when it needs to shift to the left or upwards.
@@ -26,14 +34,39 @@ const maxShiftHeight = 400;
* This class also takes care of attaching the editor DOM and destroying it on disposal.
*/
export class EditorPlacement extends Disposable {
public readonly onReposition = this.autoDispose(new Emitter());
private _editorRoot: HTMLElement;
private _maxRect: ClientRect|DOMRect;
private _cellRect: ClientRect|DOMRect;
private _margins: IMargins;
// - editorDom is the DOM to attach. It gets destroyed when EditorPlacement is disposed.
// - cellRect is the bounding box of the cell being mirrored by the editor; the editor generally
// expands to match the size of the cell.
constructor(editorDom: HTMLElement, private _cellRect: ClientRect|DOMRect) {
// - cellElem is the cell being mirrored by the editor; the editor generally expands to match
// the size of the cell.
// - margins may be given to add to the default edgeMargin, to increase distance to edges of the window.
constructor(editorDom: HTMLElement, private _cellElem: Element, options: {margins?: IMargins} = {}) {
super();
this._margins = {
top: (options.margins?.top || 0) + edgeMargin,
bottom: (options.margins?.bottom || 0) + edgeMargin,
left: (options.margins?.left || 0) + edgeMargin,
right: (options.margins?.right || 0) + edgeMargin,
};
// Initialize _maxRect and _cellRect used for sizing the editor. We don't re-measure them
// while typing (e.g. OK to scroll the view away from the editor), but we re-measure them on
// window resize, which is only a normal occurrence on Android when virtual keyboard is shown.
this._maxRect = document.body.getBoundingClientRect();
this._cellRect = rectWithoutBorders(this._cellElem);
this.autoDispose(dom.onElem(window, 'resize', () => {
this._maxRect = document.body.getBoundingClientRect();
this._cellRect = rectWithoutBorders(this._cellElem);
this.onReposition.emit();
}));
const editorRoot = this._editorRoot = dom('div.cell_editor', editorDom);
// To hide from the user the incorrectly-sized element, we set visibility to hidden, and
// reset it in _calcEditorSize() as soon as we have the sizes.
@@ -52,17 +85,20 @@ export class EditorPlacement extends Disposable {
* The position and size are applied to the editor unless {calcOnly: true} option is given.
*/
public calcSize(desiredSize: ISize, options: ISizeOpts = {}): ISize {
const maxRect = document.body.getBoundingClientRect();
const maxRect = this._maxRect;
const margin = this._margins;
const noShiftMaxWidth = maxRect.right - edgeMargin - this._cellRect.left;
const maxWidth = Math.min(maxRect.width - 2 * edgeMargin, Math.max(maxShiftWidth, noShiftMaxWidth));
const noShiftMaxWidth = maxRect.right - margin.right - this._cellRect.left;
const maxWidth = Math.min(maxRect.width - margin.left - margin.right, Math.max(maxShiftWidth, noShiftMaxWidth));
const width = Math.min(maxWidth, Math.max(this._cellRect.width, desiredSize.width));
const left = Math.max(edgeMargin, Math.min(this._cellRect.left - maxRect.left, maxRect.width - edgeMargin - width));
const left = Math.max(margin.left,
Math.min(this._cellRect.left - maxRect.left, maxRect.width - margin.right - width));
const noShiftMaxHeight = maxRect.bottom - edgeMargin - this._cellRect.top;
const maxHeight = Math.min(maxRect.height - 2 * edgeMargin, Math.max(maxShiftHeight, noShiftMaxHeight));
const noShiftMaxHeight = maxRect.bottom - margin.bottom - this._cellRect.top;
const maxHeight = Math.min(maxRect.height - margin.top - margin.bottom, Math.max(maxShiftHeight, noShiftMaxHeight));
const height = Math.min(maxHeight, Math.max(this._cellRect.height, desiredSize.height));
const top = Math.max(edgeMargin, Math.min(this._cellRect.top - maxRect.top, maxRect.height - edgeMargin - height));
const top = Math.max(margin.top,
Math.min(this._cellRect.top - maxRect.top, maxRect.height - margin.bottom - height));
// To hide from the user the split second before things are sized correctly, we set visibility
// to hidden until we can get the sizes. As soon as sizes are available, restore visibility.
@@ -102,3 +138,22 @@ export class EditorPlacement extends Disposable {
};
}
}
// Get the bounding rect of elem excluding borders. This allows the editor to match cellElem more
// closely which is more visible in case of DetailView.
function rectWithoutBorders(elem: Element): ClientRect {
const rect = elem.getBoundingClientRect();
const style = getComputedStyle(elem, null);
const bTop = parseFloat(style.getPropertyValue('border-top-width'));
const bRight = parseFloat(style.getPropertyValue('border-right-width'));
const bBottom = parseFloat(style.getPropertyValue('border-bottom-width'));
const bLeft = parseFloat(style.getPropertyValue('border-left-width'));
return {
width: rect.width - bLeft - bRight,
height: rect.height - bTop - bBottom,
top: rect.top + bTop,
bottom: rect.bottom - bBottom,
left: rect.left + bLeft,
right: rect.right - bRight,
};
}

View File

@@ -6,7 +6,7 @@ import {DataRowModel} from 'app/client/models/DataRowModel';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {reportError} from 'app/client/models/errors';
import {FormulaEditor} from 'app/client/widgets/FormulaEditor';
import {NewBaseEditor} from 'app/client/widgets/NewBaseEditor';
import {IEditorCommandGroup, NewBaseEditor} from 'app/client/widgets/NewBaseEditor';
import {CellValue} from "app/common/DocActions";
import {isRaisedException} from 'app/common/gristTypes';
import * as gutil from 'app/common/gutil';
@@ -14,7 +14,6 @@ import {Disposable, Holder, Observable} from 'grainjs';
import isEqual = require('lodash/isEqual');
type IEditorConstructor = typeof NewBaseEditor;
interface ICommandGroup { [cmd: string]: () => void; }
/**
* Check if the typed-in value should change the cell without opening the cell editor, and if so,
@@ -49,8 +48,8 @@ export class FieldEditor extends Disposable {
private _field: ViewFieldRec;
private _cursor: Cursor;
private _editRow: DataRowModel;
private _cellRect: ClientRect|DOMRect;
private _editCommands: ICommandGroup;
private _cellElem: Element;
private _editCommands: IEditorCommandGroup;
private _editorCtor: IEditorConstructor;
private _editorHolder: Holder<NewBaseEditor> = Holder.create(this);
private _saveEditPromise: Promise<boolean>|null = null;
@@ -70,7 +69,7 @@ export class FieldEditor extends Disposable {
this._cursor = options.cursor;
this._editRow = options.editRow;
this._editorCtor = options.editorCtor;
this._cellRect = rectWithoutBorders(options.cellElem);
this._cellElem = options.cellElem;
const startVal = options.startVal;
@@ -157,7 +156,7 @@ export class FieldEditor extends Disposable {
cursorPos,
commands: this._editCommands,
}));
editor.attach(this._cellRect);
editor.attach(this._cellElem);
}
private _makeFormula() {
@@ -247,22 +246,3 @@ export class FieldEditor extends Disposable {
return (saveIndex !== cursor.rowIndex());
}
}
// Get the bounding rect of elem excluding borders. This allows the editor to match cellElem more
// closely which is more visible in case of DetailView.
function rectWithoutBorders(elem: Element): ClientRect {
const rect = elem.getBoundingClientRect();
const style = getComputedStyle(elem, null);
const bTop = parseFloat(style.getPropertyValue('border-top-width'));
const bRight = parseFloat(style.getPropertyValue('border-right-width'));
const bBottom = parseFloat(style.getPropertyValue('border-bottom-width'));
const bLeft = parseFloat(style.getPropertyValue('border-left-width'));
return {
width: rect.width - bLeft - bRight,
height: rect.height - bTop - bBottom,
top: rect.top + bTop,
bottom: rect.bottom - bBottom,
left: rect.left + bLeft,
right: rect.right - bRight,
};
}

View File

@@ -4,6 +4,7 @@ import {DataRowModel} from 'app/client/models/DataRowModel';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {colors, testId} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {createMobileButtons, getButtonMargins} from 'app/client/widgets/EditorButtons';
import {EditorPlacement, ISize} from 'app/client/widgets/EditorPlacement';
import {NewBaseEditor, Options} from 'app/client/widgets/NewBaseEditor';
import {undefDefault} from 'app/common/gutil';
@@ -47,6 +48,8 @@ export class FormulaEditor extends NewBaseEditor {
this.autoDispose(this._formulaEditor);
this._dom = dom('div.default_editor',
createMobileButtons(options.commands),
// This shouldn't be needed, but needed for tests.
dom.on('mousedown', (ev) => {
ev.preventDefault();
@@ -90,8 +93,11 @@ export class FormulaEditor extends NewBaseEditor {
);
}
public attach(cellRect: ClientRect|DOMRect): void {
this._editorPlacement = EditorPlacement.create(this, this._dom, cellRect);
public attach(cellElem: Element): void {
this._editorPlacement = EditorPlacement.create(this, this._dom, cellElem, {margins: getButtonMargins()});
// Reposition the editor if needed for external reasons (in practice, window resize).
this.autoDispose(this._editorPlacement.onReposition.addListener(
this._formulaEditor.resize, this._formulaEditor));
this._formulaEditor.onAttach();
this._formulaEditor.editor.focus();
}

View File

@@ -3,6 +3,7 @@
*/
import {createGroup} from 'app/client/components/commands';
import {testId} from 'app/client/ui2018/cssVars';
import {createMobileButtons, getButtonMargins} from 'app/client/widgets/EditorButtons';
import {EditorPlacement} from 'app/client/widgets/EditorPlacement';
import {NewBaseEditor, Options} from 'app/client/widgets/NewBaseEditor';
import {CellValue} from "app/common/DocActions";
@@ -37,13 +38,18 @@ export class NTextEditor extends NewBaseEditor {
// Resize the textbox whenever user types in it.
dom.on('input', () => this.resizeInput())
)
)
),
createMobileButtons(options.commands),
);
}
public attach(cellRect: ClientRect|DOMRect): void {
public attach(cellElem: Element): void {
// Attach the editor dom to page DOM.
this._editorPlacement = EditorPlacement.create(this, this._dom, cellRect);
this._editorPlacement = EditorPlacement.create(this, this._dom, cellElem, {margins: getButtonMargins()});
// Reposition the editor if needed for external reasons (in practice, window resize).
this.autoDispose(this._editorPlacement.onReposition.addListener(this.resizeInput, this));
this.setSizerLimits();
// Once the editor is attached to DOM, resize it to content, focus, and set cursor.

View File

@@ -7,6 +7,12 @@ import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {CellValue} from "app/common/DocActions";
import {Disposable, IDisposableOwner, Observable} from 'grainjs';
export interface IEditorCommandGroup {
fieldEditCancel: () => void;
fieldEditSaveHere: () => void;
[cmd: string]: () => void;
}
export interface Options {
gristDoc: GristDoc;
field: ViewFieldRec;
@@ -14,7 +20,7 @@ export interface Options {
formulaError?: Observable<CellValue>;
editValue?: string;
cursorPos: number;
commands: {[cmd: string]: () => void};
commands: IEditorCommandGroup;
}
/**
@@ -23,8 +29,6 @@ export interface Options {
* @param {String} options.cellValue: The value in the underlying cell being edited.
* @param {String} options.editValue: String to be edited, or undefined to use cellValue.
* @param {Number} options.cursorPos: The initial position where to place the cursor.
* @param {Element} option.cellRect: Bounding box of the element representing the cell that this
* editor should match in size and position.
* @param {Object} options.commands: Object mapping command names to functions, to enable as part
* of the command group that should be activated while the editor exists.
*/
@@ -56,10 +60,10 @@ export abstract class NewBaseEditor extends Disposable {
/**
* Called after the editor is instantiated to attach its DOM to the page.
* - cellRect: Bounding box of the element representing the cell that this editor should match
* - cellElem: The element representing the cell that this editor should match
* in size and position. Used by derived classes, e.g. to construct an EditorPlacement object.
*/
public abstract attach(cellRect: ClientRect|DOMRect): void;
public abstract attach(cellElem: Element): void;
/**
* Called to get the value to save back to the cell.

View File

@@ -78,8 +78,8 @@ export class ReferenceEditor extends NTextEditor {
.catch(reportError);
}
public attach(cellRect: ClientRect|DOMRect): void {
super.attach(cellRect);
public attach(cellElem: Element): void {
super.attach(cellElem);
this._autocomplete = this.autoDispose(new Autocomplete<ICellItem>(this.textInput, {
menuCssClass: menuCssClass + ' ' + cssRefList.className,
search: this._doSearch.bind(this),
@@ -168,7 +168,7 @@ function nocaseEqual(a: string, b: string) {
const cssRefEditor = styled('div', `
& > .celleditor_text_editor, & > .celleditor_content_measure {
padding-left: 21px;
padding-left: 18px;
}
`);
@@ -236,7 +236,7 @@ const cssRefEditIcon = styled(icon, `
position: absolute;
top: 0;
left: 0;
margin: 2px 3px 0 3px;
margin: 3px 3px 0 3px;
`);
const cssMatchText = styled('span', `

View File

@@ -6,6 +6,7 @@ var dispose = require('../lib/dispose');
var BaseEditor = require('./BaseEditor');
var commands = require('../components/commands');
const {testId} = require('app/client/ui2018/cssVars');
const {createMobileButtons, getButtonMargins} = require('app/client/widgets/EditorButtons');
const {EditorPlacement} = require('app/client/widgets/EditorPlacement');
/**
@@ -44,16 +45,21 @@ function TextEditor(options) {
// Resize the textbox whenever user types in it.
dom.on('input', () => this._resizeInput())
)
)
),
createMobileButtons(options.commands),
);
}
dispose.makeDisposable(TextEditor);
_.extend(TextEditor.prototype, BaseEditor.prototype);
TextEditor.prototype.attach = function(cellRect) {
TextEditor.prototype.attach = function(cellElem) {
// Attach the editor dom to page DOM.
this.editorPlacement = EditorPlacement.create(this, this.dom, cellRect);
this.editorPlacement = EditorPlacement.create(this, this.dom, cellElem, {margins: getButtonMargins()});
// Reposition the editor if needed for external reasons (in practice, window resize).
this.autoDispose(this.editorPlacement.onReposition.addListener(this._resizeInput, this));
this.setSizerLimits();
// Once the editor is attached to DOM, resize it to content, focus, and set cursor.