From 72846443132b7ef1ea3ccd718202b8d75d1be778 Mon Sep 17 00:00:00 2001 From: Dmitry S Date: Wed, 3 Feb 2021 22:17:17 -0500 Subject: [PATCH] (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 --- app/client/components/Clipboard.js | 6 +- app/client/components/GridView.js | 18 +++--- app/client/components/GristDoc.ts | 3 + app/client/lib/FocusLayer.ts | 29 +++++++-- app/client/lib/browserInfo.ts | 7 +++ app/client/lib/dblclick.ts | 37 ++++++++++++ app/client/ui/App.ts | 13 ++++ app/client/ui/viewport.ts | 11 +++- app/client/widgets/AttachmentsEditor.ts | 2 +- app/client/widgets/BaseEditor.js | 4 +- app/client/widgets/EditorButtons.ts | 51 ++++++++++++++++ app/client/widgets/EditorPlacement.ts | 79 +++++++++++++++++++++---- app/client/widgets/FieldEditor.ts | 30 ++-------- app/client/widgets/FormulaEditor.ts | 10 +++- app/client/widgets/NTextEditor.ts | 12 +++- app/client/widgets/NewBaseEditor.ts | 14 +++-- app/client/widgets/ReferenceEditor.ts | 8 +-- app/client/widgets/TextEditor.js | 12 +++- 18 files changed, 271 insertions(+), 75 deletions(-) create mode 100644 app/client/lib/dblclick.ts create mode 100644 app/client/widgets/EditorButtons.ts diff --git a/app/client/components/Clipboard.js b/app/client/components/Clipboard.js index 868cfdbc..d4c300e1 100644 --- a/app/client/components/Clipboard.js +++ b/app/client/components/Clipboard.js @@ -62,7 +62,7 @@ function Clipboard(app) { FocusLayer.create(this, { defaultFocusElem: this.copypasteField, - allowFocus: isCopyPasteTarget, + allowFocus: allowFocus, onDefaultFocus: () => { this.copypasteField.value = ' '; this.copypasteField.select(); @@ -184,10 +184,12 @@ var FOCUS_TARGET_TAGS = { * copy-paste events. Besides inputs and textareas, any element can be marked to be a valid * copy-paste target by adding 'clipboard_focus' class to it. */ -function isCopyPasteTarget(elem) { +function allowFocus(elem) { return elem && (FOCUS_TARGET_TAGS.hasOwnProperty(elem.tagName) || elem.hasAttribute("tabindex") || elem.classList.contains('clipboard_focus')); } +Clipboard.allowFocus = allowFocus; + module.exports = Clipboard; diff --git a/app/client/components/GridView.js b/app/client/components/GridView.js index 6656da14..31f002eb 100644 --- a/app/client/components/GridView.js +++ b/app/client/components/GridView.js @@ -23,6 +23,7 @@ var CopySelection = require('./CopySelection'); const {renderAllRows} = require('app/client/components/Printing'); const {reportError} = require('app/client/models/AppModel'); +const {onDblClickMatchElem} = require('app/client/lib/dblclick'); // Grist UI Components const {Holder} = require('grainjs'); @@ -115,12 +116,7 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) { //-------------------------------------------------- // Set up DOM event handling. - - this.onEvent(this.scrollPane, 'dblclick', '.field', function(elem, event) { - // Assumes `click` event also occurs on a `dblclick` and has already repositioned the cursor. - this.activateEditorAtCursor(); - }); - + onDblClickMatchElem(this.scrollPane, '.field', () => this.activateEditorAtCursor()); this.onEvent(this.scrollPane, 'scroll', this.onScroll); //-------------------------------------------------- @@ -955,7 +951,15 @@ GridView.prototype.buildDom = function() { /** @inheritdoc */ GridView.prototype.onResize = function() { - this.scrolly.scheduleUpdateSize(); + if (this.activeFieldBuilder().isEditorActive()) { + // When the editor is active, the common case for a resize is if the virtual keyboard is being + // shown on mobile device. In that case, we need to scroll active cell into view, and need to + // do it synchronously, to allow repositioning the editor to it in response to the same event. + this.scrolly.updateSize(); + this.scrolly.scrollRowIntoView(this.cursor.rowIndex.peek()); + } else { + this.scrolly.scheduleUpdateSize(); + } }; /** @inheritdoc */ diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index aca122ed..8e0040d4 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -223,6 +223,9 @@ export class GristDoc extends DisposableWithEvents { this.autoDispose(dom.onElem(window, 'dragover', (ev) => ev.preventDefault())); // The default action is to open dragged files as a link, navigating out of the app. this.autoDispose(dom.onElem(window, 'drop', (ev) => ev.preventDefault())); + + // On window resize, trigger the resizeEmitter to update ViewLayout and individual BaseViews. + this.autoDispose(dom.onElem(window, 'resize', () => this.resizeEmitter.emit())); } public addOptionsTab(label: string, iconElem: any, contentObj: TabContent[], options: TabOptions): IDisposable { diff --git a/app/client/lib/FocusLayer.ts b/app/client/lib/FocusLayer.ts index c7e02cf4..9bba1f48 100644 --- a/app/client/lib/FocusLayer.ts +++ b/app/client/lib/FocusLayer.ts @@ -72,6 +72,7 @@ class FocusLayerManager extends Disposable { } public addLayer(layer: FocusLayer) { + this.getCurrentLayer()?.onDefaultBlur(); this._focusLayers.push(layer); // Move the focus to the new layer. Not just grabFocus, because if the focus is on the previous // layer's defaultFocusElem, the new layer might consider it "allowed" and never get the focus. @@ -102,6 +103,7 @@ class FocusLayerManager extends Disposable { this._timeoutId = null; const layer = this.getCurrentLayer(); if (!layer || document.activeElement === layer.defaultFocusElem) { + layer?.onDefaultFocus(); return; } // If the window doesn't have focus, don't rush to grab it, or we can interfere with focus @@ -111,10 +113,10 @@ class FocusLayerManager extends Disposable { } if (document.activeElement && layer.allowFocus(document.activeElement)) { watchElementForBlur(document.activeElement, () => this.grabFocus()); - layer.onDefaultBlur?.(); + layer.onDefaultBlur(); } else { layer.defaultFocusElem.focus(); - layer.onDefaultFocus?.(); + layer.onDefaultFocus(); } } } @@ -130,15 +132,16 @@ export class FocusLayer extends Disposable implements FocusLayerOptions { public defaultFocusElem: HTMLElement; public allowFocus: (elem: Element) => boolean; - public onDefaultFocus?: () => void; - public onDefaultBlur?: () => void; + public _onDefaultFocus?: () => void; + public _onDefaultBlur?: () => void; + private _isDefaultFocused: boolean|null = null; constructor(options: FocusLayerOptions) { super(); this.defaultFocusElem = options.defaultFocusElem; this.allowFocus = options.allowFocus; - this.onDefaultFocus = options.onDefaultFocus; - this.onDefaultBlur = options.onDefaultBlur; + this._onDefaultFocus = options.onDefaultFocus; + this._onDefaultBlur = options.onDefaultBlur; const managerRefCount = this.autoDispose(_focusLayerManager.use(null)); const manager = managerRefCount.get(); @@ -146,6 +149,20 @@ export class FocusLayer extends Disposable implements FocusLayerOptions { this.onDispose(() => manager.removeLayer(this)); this.autoDispose(dom.onElem(this.defaultFocusElem, 'blur', () => manager.grabFocus())); } + + public onDefaultFocus() { + // Only trigger onDefaultFocus() callback when the focus status actually changed. + if (this._isDefaultFocused) { return; } + this._isDefaultFocused = true; + this._onDefaultFocus?.(); + } + + public onDefaultBlur() { + // Only trigger onDefaultBlur() callback when the focus status actually changed. + if (this._isDefaultFocused === false) { return; } + this._isDefaultFocused = false; + this._onDefaultBlur?.(); + } } /** diff --git a/app/client/lib/browserInfo.ts b/app/client/lib/browserInfo.ts index 8be428c9..f8672cf8 100644 --- a/app/client/lib/browserInfo.ts +++ b/app/client/lib/browserInfo.ts @@ -11,3 +11,10 @@ export function isDesktop() { const platformType = getParser().getPlatformType(); return (!platformType || platformType === 'desktop'); } + +// Returns whether the browser is on mobile iOS. +// This is used in particular in viewport.ts to set maximum-scale=1 (to prevent iOS auto-zoom when +// an input is focused, without preventing manual pinch-to-zoom). +export function isIOS() { + return navigator.platform && /iPad|iPhone|iPod/.test(navigator.platform); +} diff --git a/app/client/lib/dblclick.ts b/app/client/lib/dblclick.ts new file mode 100644 index 00000000..2911194e --- /dev/null +++ b/app/client/lib/dblclick.ts @@ -0,0 +1,37 @@ +import {dom, EventCB} from 'grainjs'; + +const DOUBLE_TAP_INTERVAL_MS = 500; + +/** + * Helper to handle 'dblclick' events on either browser or mobile. + * + * This is equivalent to a 'dblclick' handler when touch events are not supported. When they are, + * the callback will be called on second touch within a short time of a first one. (In that case, + * preventDefault() prevents a 'dblclick' event from being emulated.) + * + * Background: though mobile browsers we care about already generate 'click' and 'dblclick' events + * in response to touch events, it doesn't seem to be treated as a direct user interaction. E.g. + * double-click to edit a cell should focus the editor and open the mobile keyboard, but a + * JS-issued focus() call only works when triggered by a direct user interaction, and synthesized + * dblclick doesn't seem to do that. + * + * Helpful links on emulated (synthesized) events: + * - https://developer.mozilla.org/en-US/docs/Web/API/Touch_events/Supporting_both_TouchEvent_and_MouseEvent + * - https://github.com/w3c/pointerevents/issues/171 + */ +export function onDblClickMatchElem(elem: EventTarget, selector: string, callback: EventCB): void { + dom.onMatchElem(elem, selector, 'dblclick', (ev, _elem) => { + callback(ev, _elem); + }); + + let lastTapTime = 0; + dom.onMatchElem(elem, selector, 'touchend', (ev, _elem) => { + const currentTime = Date.now(); + const tapLength = currentTime - lastTapTime; + lastTapTime = currentTime; + if (tapLength < DOUBLE_TAP_INTERVAL_MS && tapLength > 0) { + ev.preventDefault(); + callback(ev, _elem); + } + }); +} diff --git a/app/client/ui/App.ts b/app/client/ui/App.ts index 477bf1d9..94065115 100644 --- a/app/client/ui/App.ts +++ b/app/client/ui/App.ts @@ -6,6 +6,7 @@ import * as commands from 'app/client/components/commands'; import {unsavedChanges} from 'app/client/components/UnsavedChanges'; import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals'; import {isDesktop} from 'app/client/lib/browserInfo'; +import {FocusLayer} from 'app/client/lib/FocusLayer'; import * as koUtil from 'app/client/lib/koUtil'; import {reportError, TopAppModel, TopAppModelImpl} from 'app/client/models/AppModel'; import {setUpErrorHandling} from 'app/client/models/errors'; @@ -60,6 +61,18 @@ export class App extends DisposableWithEvents { if (isDesktop()) { this.autoDispose(Clipboard.create(this)); + } else { + // On mobile, we do not want to keep focus on a special textarea (which would cause unwanted + // scrolling and showing of mobile keyboard). But we still rely on 'cliboard_focus' and + // 'clipboard_blur' events to know when the "app" has a focus (rather than a particular + // input), by making document.body focusable and using a FocusLayer with it as the default. + document.body.setAttribute('tabindex', '-1'); + FocusLayer.create(this, { + defaultFocusElem: document.body, + allowFocus: Clipboard.allowFocus, + onDefaultFocus: () => this.trigger('clipboard_focus'), + onDefaultBlur: () => this.trigger('clipboard_blur'), + }); } this.topAppModel = this.autoDispose(TopAppModelImpl.create(null, G.window)); diff --git a/app/client/ui/viewport.ts b/app/client/ui/viewport.ts index 208f7aa6..eecb823f 100644 --- a/app/client/ui/viewport.ts +++ b/app/client/ui/viewport.ts @@ -1,3 +1,4 @@ +import {isIOS} from 'app/client/lib/browserInfo'; import {localStorageBoolObs} from 'app/client/lib/localStorageObs'; import {dom} from 'grainjs'; @@ -13,8 +14,12 @@ export function toggleViewport() { export function addViewportTag() { dom.update(document.head, - dom.maybe(viewportEnabled, () => - dom('meta', {name: "viewport", content: "width=device-width,initial-scale=1.0"}) - ) + dom.maybe(viewportEnabled, () => { + // For the maximum-scale=1 advice, see https://stackoverflow.com/a/46254706/328565. On iOS, + // it prevents the auto-zoom when an input is focused, but does not prevent manual + // pinch-to-zoom. On Android, it's not needed, and would prevent manual zoom. + const viewportContent = "width=device-width,initial-scale=1.0" + (isIOS() ? ",maximum-scale=1" : ""); + return dom('meta', {name: "viewport", content: viewportContent}); + }) ); } diff --git a/app/client/widgets/AttachmentsEditor.ts b/app/client/widgets/AttachmentsEditor.ts index 7ea16cf2..cc248e84 100644 --- a/app/client/widgets/AttachmentsEditor.ts +++ b/app/client/widgets/AttachmentsEditor.ts @@ -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); diff --git a/app/client/widgets/BaseEditor.js b/app/client/widgets/BaseEditor.js index af5339dd..b508710b 100644 --- a/app/client/widgets/BaseEditor.js +++ b/app/client/widgets/BaseEditor.js @@ -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. }; diff --git a/app/client/widgets/EditorButtons.ts b/app/client/widgets/EditorButtons.ts new file mode 100644 index 00000000..81cdbe51 --- /dev/null +++ b/app/client/widgets/EditorButtons.ts @@ -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; +`); diff --git a/app/client/widgets/EditorPlacement.ts b/app/client/widgets/EditorPlacement.ts index f52818bc..a8e73e29 100644 --- a/app/client/widgets/EditorPlacement.ts +++ b/app/client/widgets/EditorPlacement.ts @@ -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, + }; +} diff --git a/app/client/widgets/FieldEditor.ts b/app/client/widgets/FieldEditor.ts index 6c80e7e9..56d8858c 100644 --- a/app/client/widgets/FieldEditor.ts +++ b/app/client/widgets/FieldEditor.ts @@ -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 = Holder.create(this); private _saveEditPromise: Promise|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, - }; -} diff --git a/app/client/widgets/FormulaEditor.ts b/app/client/widgets/FormulaEditor.ts index 7a0bef7a..04ad883f 100644 --- a/app/client/widgets/FormulaEditor.ts +++ b/app/client/widgets/FormulaEditor.ts @@ -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(); } diff --git a/app/client/widgets/NTextEditor.ts b/app/client/widgets/NTextEditor.ts index 1781aeca..5e3d814f 100644 --- a/app/client/widgets/NTextEditor.ts +++ b/app/client/widgets/NTextEditor.ts @@ -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. diff --git a/app/client/widgets/NewBaseEditor.ts b/app/client/widgets/NewBaseEditor.ts index 4368c12d..a3ddc953 100644 --- a/app/client/widgets/NewBaseEditor.ts +++ b/app/client/widgets/NewBaseEditor.ts @@ -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; 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. diff --git a/app/client/widgets/ReferenceEditor.ts b/app/client/widgets/ReferenceEditor.ts index fab86847..37fc754d 100644 --- a/app/client/widgets/ReferenceEditor.ts +++ b/app/client/widgets/ReferenceEditor.ts @@ -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(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', ` diff --git a/app/client/widgets/TextEditor.js b/app/client/widgets/TextEditor.js index 4aa9adab..05937949 100644 --- a/app/client/widgets/TextEditor.js +++ b/app/client/widgets/TextEditor.js @@ -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.