gristlabs_grist-core/app/client/widgets/EditorPlacement.ts
Dmitry S 7284644313 (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
2021-02-03 23:10:51 -05:00

160 lines
6.5 KiB
TypeScript

import {Disposable, dom, Emitter} from 'grainjs';
export interface ISize {
width: number;
height: number;
}
interface ISizeOpts {
// Don't reposition the editor as part of the size calculation.
calcOnly?: boolean;
}
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.
const maxShiftWidth = 560;
const maxShiftHeight = 400;
/**
* This class implements the placement and sizing of the cell editor, such as TextEditor and
* FormulaEditor. These try to match the size and position of the cell being edited, expanding
* when needed.
*
* 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.
// - 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.
editorRoot.style.visibility = 'hidden';
document.body.appendChild(editorRoot);
this.onDispose(() => {
// When the editor is destroyed, destroy and remove its DOM.
dom.domDispose(editorRoot);
editorRoot.remove();
});
}
/**
* Calculate the size of the full editor and shift the editor if needed to give it more space.
* The position and size are applied to the editor unless {calcOnly: true} option is given.
*/
public calcSize(desiredSize: ISize, options: ISizeOpts = {}): ISize {
const maxRect = this._maxRect;
const margin = this._margins;
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(margin.left,
Math.min(this._cellRect.left - maxRect.left, maxRect.width - margin.right - width));
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(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.
if (!options.calcOnly) {
Object.assign(this._editorRoot.style, {
visibility: 'visible',
left: left + 'px',
top: top + 'px',
// Set the width (but not the height) of the outer container explicitly to accommodate the
// particular setup where a formula may include error details below -- these should
// stretch to the calculated width (so need an explicit value), but may be dynamic in
// height. (This feels hacky, but solves the problem.)
width: width + 'px',
});
}
return {width, height};
}
/**
* Calculate the size for the editable part of the editor, given in elem. This assumes that the
* size of the full editor differs from the editable part only in constant padding. The full
* editor may be shifted as part of this call.
*/
public calcSizeWithPadding(elem: HTMLElement, desiredElemSize: ISize, options: ISizeOpts = {}): ISize {
const rootRect = this._editorRoot.getBoundingClientRect();
const elemRect = elem.getBoundingClientRect();
const heightDelta = rootRect.height - elemRect.height;
const widthDelta = rootRect.width - elemRect.width;
const {width, height} = this.calcSize({
width: desiredElemSize.width + widthDelta,
height: desiredElemSize.height + heightDelta,
}, options);
return {
width: width - widthDelta,
height: height - heightDelta,
};
}
}
// 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,
};
}