mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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:
parent
7c81cf2368
commit
7284644313
@ -62,7 +62,7 @@ function Clipboard(app) {
|
|||||||
|
|
||||||
FocusLayer.create(this, {
|
FocusLayer.create(this, {
|
||||||
defaultFocusElem: this.copypasteField,
|
defaultFocusElem: this.copypasteField,
|
||||||
allowFocus: isCopyPasteTarget,
|
allowFocus: allowFocus,
|
||||||
onDefaultFocus: () => {
|
onDefaultFocus: () => {
|
||||||
this.copypasteField.value = ' ';
|
this.copypasteField.value = ' ';
|
||||||
this.copypasteField.select();
|
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 events. Besides inputs and textareas, any element can be marked to be a valid
|
||||||
* copy-paste target by adding 'clipboard_focus' class to it.
|
* copy-paste target by adding 'clipboard_focus' class to it.
|
||||||
*/
|
*/
|
||||||
function isCopyPasteTarget(elem) {
|
function allowFocus(elem) {
|
||||||
return elem && (FOCUS_TARGET_TAGS.hasOwnProperty(elem.tagName) ||
|
return elem && (FOCUS_TARGET_TAGS.hasOwnProperty(elem.tagName) ||
|
||||||
elem.hasAttribute("tabindex") ||
|
elem.hasAttribute("tabindex") ||
|
||||||
elem.classList.contains('clipboard_focus'));
|
elem.classList.contains('clipboard_focus'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Clipboard.allowFocus = allowFocus;
|
||||||
|
|
||||||
module.exports = Clipboard;
|
module.exports = Clipboard;
|
||||||
|
@ -23,6 +23,7 @@ var CopySelection = require('./CopySelection');
|
|||||||
|
|
||||||
const {renderAllRows} = require('app/client/components/Printing');
|
const {renderAllRows} = require('app/client/components/Printing');
|
||||||
const {reportError} = require('app/client/models/AppModel');
|
const {reportError} = require('app/client/models/AppModel');
|
||||||
|
const {onDblClickMatchElem} = require('app/client/lib/dblclick');
|
||||||
|
|
||||||
// Grist UI Components
|
// Grist UI Components
|
||||||
const {Holder} = require('grainjs');
|
const {Holder} = require('grainjs');
|
||||||
@ -115,12 +116,7 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) {
|
|||||||
|
|
||||||
//--------------------------------------------------
|
//--------------------------------------------------
|
||||||
// Set up DOM event handling.
|
// Set up DOM event handling.
|
||||||
|
onDblClickMatchElem(this.scrollPane, '.field', () => this.activateEditorAtCursor());
|
||||||
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();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.onEvent(this.scrollPane, 'scroll', this.onScroll);
|
this.onEvent(this.scrollPane, 'scroll', this.onScroll);
|
||||||
|
|
||||||
//--------------------------------------------------
|
//--------------------------------------------------
|
||||||
@ -955,7 +951,15 @@ GridView.prototype.buildDom = function() {
|
|||||||
|
|
||||||
/** @inheritdoc */
|
/** @inheritdoc */
|
||||||
GridView.prototype.onResize = function() {
|
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 */
|
/** @inheritdoc */
|
||||||
|
@ -223,6 +223,9 @@ export class GristDoc extends DisposableWithEvents {
|
|||||||
this.autoDispose(dom.onElem(window, 'dragover', (ev) => ev.preventDefault()));
|
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.
|
// 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()));
|
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 {
|
public addOptionsTab(label: string, iconElem: any, contentObj: TabContent[], options: TabOptions): IDisposable {
|
||||||
|
@ -72,6 +72,7 @@ class FocusLayerManager extends Disposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public addLayer(layer: FocusLayer) {
|
public addLayer(layer: FocusLayer) {
|
||||||
|
this.getCurrentLayer()?.onDefaultBlur();
|
||||||
this._focusLayers.push(layer);
|
this._focusLayers.push(layer);
|
||||||
// Move the focus to the new layer. Not just grabFocus, because if the focus is on the previous
|
// 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.
|
// 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;
|
this._timeoutId = null;
|
||||||
const layer = this.getCurrentLayer();
|
const layer = this.getCurrentLayer();
|
||||||
if (!layer || document.activeElement === layer.defaultFocusElem) {
|
if (!layer || document.activeElement === layer.defaultFocusElem) {
|
||||||
|
layer?.onDefaultFocus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// If the window doesn't have focus, don't rush to grab it, or we can interfere with focus
|
// 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)) {
|
if (document.activeElement && layer.allowFocus(document.activeElement)) {
|
||||||
watchElementForBlur(document.activeElement, () => this.grabFocus());
|
watchElementForBlur(document.activeElement, () => this.grabFocus());
|
||||||
layer.onDefaultBlur?.();
|
layer.onDefaultBlur();
|
||||||
} else {
|
} else {
|
||||||
layer.defaultFocusElem.focus();
|
layer.defaultFocusElem.focus();
|
||||||
layer.onDefaultFocus?.();
|
layer.onDefaultFocus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -130,15 +132,16 @@ export class FocusLayer extends Disposable implements FocusLayerOptions {
|
|||||||
|
|
||||||
public defaultFocusElem: HTMLElement;
|
public defaultFocusElem: HTMLElement;
|
||||||
public allowFocus: (elem: Element) => boolean;
|
public allowFocus: (elem: Element) => boolean;
|
||||||
public onDefaultFocus?: () => void;
|
public _onDefaultFocus?: () => void;
|
||||||
public onDefaultBlur?: () => void;
|
public _onDefaultBlur?: () => void;
|
||||||
|
private _isDefaultFocused: boolean|null = null;
|
||||||
|
|
||||||
constructor(options: FocusLayerOptions) {
|
constructor(options: FocusLayerOptions) {
|
||||||
super();
|
super();
|
||||||
this.defaultFocusElem = options.defaultFocusElem;
|
this.defaultFocusElem = options.defaultFocusElem;
|
||||||
this.allowFocus = options.allowFocus;
|
this.allowFocus = options.allowFocus;
|
||||||
this.onDefaultFocus = options.onDefaultFocus;
|
this._onDefaultFocus = options.onDefaultFocus;
|
||||||
this.onDefaultBlur = options.onDefaultBlur;
|
this._onDefaultBlur = options.onDefaultBlur;
|
||||||
|
|
||||||
const managerRefCount = this.autoDispose(_focusLayerManager.use(null));
|
const managerRefCount = this.autoDispose(_focusLayerManager.use(null));
|
||||||
const manager = managerRefCount.get();
|
const manager = managerRefCount.get();
|
||||||
@ -146,6 +149,20 @@ export class FocusLayer extends Disposable implements FocusLayerOptions {
|
|||||||
this.onDispose(() => manager.removeLayer(this));
|
this.onDispose(() => manager.removeLayer(this));
|
||||||
this.autoDispose(dom.onElem(this.defaultFocusElem, 'blur', () => manager.grabFocus()));
|
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?.();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -11,3 +11,10 @@ export function isDesktop() {
|
|||||||
const platformType = getParser().getPlatformType();
|
const platformType = getParser().getPlatformType();
|
||||||
return (!platformType || platformType === 'desktop');
|
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);
|
||||||
|
}
|
||||||
|
37
app/client/lib/dblclick.ts
Normal file
37
app/client/lib/dblclick.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
@ -6,6 +6,7 @@ import * as commands from 'app/client/components/commands';
|
|||||||
import {unsavedChanges} from 'app/client/components/UnsavedChanges';
|
import {unsavedChanges} from 'app/client/components/UnsavedChanges';
|
||||||
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
|
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
|
||||||
import {isDesktop} from 'app/client/lib/browserInfo';
|
import {isDesktop} from 'app/client/lib/browserInfo';
|
||||||
|
import {FocusLayer} from 'app/client/lib/FocusLayer';
|
||||||
import * as koUtil from 'app/client/lib/koUtil';
|
import * as koUtil from 'app/client/lib/koUtil';
|
||||||
import {reportError, TopAppModel, TopAppModelImpl} from 'app/client/models/AppModel';
|
import {reportError, TopAppModel, TopAppModelImpl} from 'app/client/models/AppModel';
|
||||||
import {setUpErrorHandling} from 'app/client/models/errors';
|
import {setUpErrorHandling} from 'app/client/models/errors';
|
||||||
@ -60,6 +61,18 @@ export class App extends DisposableWithEvents {
|
|||||||
|
|
||||||
if (isDesktop()) {
|
if (isDesktop()) {
|
||||||
this.autoDispose(Clipboard.create(this));
|
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));
|
this.topAppModel = this.autoDispose(TopAppModelImpl.create(null, G.window));
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import {isIOS} from 'app/client/lib/browserInfo';
|
||||||
import {localStorageBoolObs} from 'app/client/lib/localStorageObs';
|
import {localStorageBoolObs} from 'app/client/lib/localStorageObs';
|
||||||
import {dom} from 'grainjs';
|
import {dom} from 'grainjs';
|
||||||
|
|
||||||
@ -13,8 +14,12 @@ export function toggleViewport() {
|
|||||||
|
|
||||||
export function addViewportTag() {
|
export function addViewportTag() {
|
||||||
dom.update(document.head,
|
dom.update(document.head,
|
||||||
dom.maybe(viewportEnabled, () =>
|
dom.maybe(viewportEnabled, () => {
|
||||||
dom('meta', {name: "viewport", content: "width=device-width,initial-scale=1.0"})
|
// 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});
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -91,7 +91,7 @@ export class AttachmentsEditor extends NewBaseEditor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// This "attach" is not about "attachments", but about attaching this widget to the page DOM.
|
// 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) => {
|
modal((ctl, owner) => {
|
||||||
// If FieldEditor is disposed externally (e.g. on navigation), be sure to close the modal.
|
// If FieldEditor is disposed externally (e.g. on navigation), be sure to close the modal.
|
||||||
this.onDispose(ctl.close);
|
this.onDispose(ctl.close);
|
||||||
|
@ -12,10 +12,10 @@ function BaseEditor(options) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Called after the editor is instantiated to attach its DOM to the page.
|
* 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.
|
* 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.
|
// No-op by default.
|
||||||
};
|
};
|
||||||
|
|
||||||
|
51
app/client/widgets/EditorButtons.ts
Normal file
51
app/client/widgets/EditorButtons.ts
Normal 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;
|
||||||
|
`);
|
@ -1,4 +1,4 @@
|
|||||||
import {Disposable, dom} from 'grainjs';
|
import {Disposable, dom, Emitter} from 'grainjs';
|
||||||
|
|
||||||
export interface ISize {
|
export interface ISize {
|
||||||
width: number;
|
width: number;
|
||||||
@ -10,7 +10,15 @@ interface ISizeOpts {
|
|||||||
calcOnly?: boolean;
|
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;
|
const edgeMargin = 12;
|
||||||
|
|
||||||
// How large the editor can get when it needs to shift to the left or upwards.
|
// 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.
|
* This class also takes care of attaching the editor DOM and destroying it on disposal.
|
||||||
*/
|
*/
|
||||||
export class EditorPlacement extends Disposable {
|
export class EditorPlacement extends Disposable {
|
||||||
|
public readonly onReposition = this.autoDispose(new Emitter());
|
||||||
|
|
||||||
private _editorRoot: HTMLElement;
|
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.
|
// - 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
|
// - cellElem is the cell being mirrored by the editor; the editor generally expands to match
|
||||||
// expands to match the size of the cell.
|
// the size of the cell.
|
||||||
constructor(editorDom: HTMLElement, private _cellRect: ClientRect|DOMRect) {
|
// - 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();
|
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);
|
const editorRoot = this._editorRoot = dom('div.cell_editor', editorDom);
|
||||||
// To hide from the user the incorrectly-sized element, we set visibility to hidden, and
|
// 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.
|
// 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.
|
* The position and size are applied to the editor unless {calcOnly: true} option is given.
|
||||||
*/
|
*/
|
||||||
public calcSize(desiredSize: ISize, options: ISizeOpts = {}): ISize {
|
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 noShiftMaxWidth = maxRect.right - margin.right - this._cellRect.left;
|
||||||
const maxWidth = Math.min(maxRect.width - 2 * edgeMargin, Math.max(maxShiftWidth, noShiftMaxWidth));
|
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 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 noShiftMaxHeight = maxRect.bottom - margin.bottom - this._cellRect.top;
|
||||||
const maxHeight = Math.min(maxRect.height - 2 * edgeMargin, Math.max(maxShiftHeight, noShiftMaxHeight));
|
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 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 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.
|
// 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@ -6,7 +6,7 @@ import {DataRowModel} from 'app/client/models/DataRowModel';
|
|||||||
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
||||||
import {reportError} from 'app/client/models/errors';
|
import {reportError} from 'app/client/models/errors';
|
||||||
import {FormulaEditor} from 'app/client/widgets/FormulaEditor';
|
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 {CellValue} from "app/common/DocActions";
|
||||||
import {isRaisedException} from 'app/common/gristTypes';
|
import {isRaisedException} from 'app/common/gristTypes';
|
||||||
import * as gutil from 'app/common/gutil';
|
import * as gutil from 'app/common/gutil';
|
||||||
@ -14,7 +14,6 @@ import {Disposable, Holder, Observable} from 'grainjs';
|
|||||||
import isEqual = require('lodash/isEqual');
|
import isEqual = require('lodash/isEqual');
|
||||||
|
|
||||||
type IEditorConstructor = typeof NewBaseEditor;
|
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,
|
* 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 _field: ViewFieldRec;
|
||||||
private _cursor: Cursor;
|
private _cursor: Cursor;
|
||||||
private _editRow: DataRowModel;
|
private _editRow: DataRowModel;
|
||||||
private _cellRect: ClientRect|DOMRect;
|
private _cellElem: Element;
|
||||||
private _editCommands: ICommandGroup;
|
private _editCommands: IEditorCommandGroup;
|
||||||
private _editorCtor: IEditorConstructor;
|
private _editorCtor: IEditorConstructor;
|
||||||
private _editorHolder: Holder<NewBaseEditor> = Holder.create(this);
|
private _editorHolder: Holder<NewBaseEditor> = Holder.create(this);
|
||||||
private _saveEditPromise: Promise<boolean>|null = null;
|
private _saveEditPromise: Promise<boolean>|null = null;
|
||||||
@ -70,7 +69,7 @@ export class FieldEditor extends Disposable {
|
|||||||
this._cursor = options.cursor;
|
this._cursor = options.cursor;
|
||||||
this._editRow = options.editRow;
|
this._editRow = options.editRow;
|
||||||
this._editorCtor = options.editorCtor;
|
this._editorCtor = options.editorCtor;
|
||||||
this._cellRect = rectWithoutBorders(options.cellElem);
|
this._cellElem = options.cellElem;
|
||||||
|
|
||||||
const startVal = options.startVal;
|
const startVal = options.startVal;
|
||||||
|
|
||||||
@ -157,7 +156,7 @@ export class FieldEditor extends Disposable {
|
|||||||
cursorPos,
|
cursorPos,
|
||||||
commands: this._editCommands,
|
commands: this._editCommands,
|
||||||
}));
|
}));
|
||||||
editor.attach(this._cellRect);
|
editor.attach(this._cellElem);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _makeFormula() {
|
private _makeFormula() {
|
||||||
@ -247,22 +246,3 @@ export class FieldEditor extends Disposable {
|
|||||||
return (saveIndex !== cursor.rowIndex());
|
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
@ -4,6 +4,7 @@ import {DataRowModel} from 'app/client/models/DataRowModel';
|
|||||||
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
||||||
import {colors, testId} from 'app/client/ui2018/cssVars';
|
import {colors, testId} from 'app/client/ui2018/cssVars';
|
||||||
import {icon} from 'app/client/ui2018/icons';
|
import {icon} from 'app/client/ui2018/icons';
|
||||||
|
import {createMobileButtons, getButtonMargins} from 'app/client/widgets/EditorButtons';
|
||||||
import {EditorPlacement, ISize} from 'app/client/widgets/EditorPlacement';
|
import {EditorPlacement, ISize} from 'app/client/widgets/EditorPlacement';
|
||||||
import {NewBaseEditor, Options} from 'app/client/widgets/NewBaseEditor';
|
import {NewBaseEditor, Options} from 'app/client/widgets/NewBaseEditor';
|
||||||
import {undefDefault} from 'app/common/gutil';
|
import {undefDefault} from 'app/common/gutil';
|
||||||
@ -47,6 +48,8 @@ export class FormulaEditor extends NewBaseEditor {
|
|||||||
|
|
||||||
this.autoDispose(this._formulaEditor);
|
this.autoDispose(this._formulaEditor);
|
||||||
this._dom = dom('div.default_editor',
|
this._dom = dom('div.default_editor',
|
||||||
|
createMobileButtons(options.commands),
|
||||||
|
|
||||||
// This shouldn't be needed, but needed for tests.
|
// This shouldn't be needed, but needed for tests.
|
||||||
dom.on('mousedown', (ev) => {
|
dom.on('mousedown', (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
@ -90,8 +93,11 @@ export class FormulaEditor extends NewBaseEditor {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public attach(cellRect: ClientRect|DOMRect): void {
|
public attach(cellElem: Element): void {
|
||||||
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._formulaEditor.resize, this._formulaEditor));
|
||||||
this._formulaEditor.onAttach();
|
this._formulaEditor.onAttach();
|
||||||
this._formulaEditor.editor.focus();
|
this._formulaEditor.editor.focus();
|
||||||
}
|
}
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
import {createGroup} from 'app/client/components/commands';
|
import {createGroup} from 'app/client/components/commands';
|
||||||
import {testId} from 'app/client/ui2018/cssVars';
|
import {testId} from 'app/client/ui2018/cssVars';
|
||||||
|
import {createMobileButtons, getButtonMargins} from 'app/client/widgets/EditorButtons';
|
||||||
import {EditorPlacement} from 'app/client/widgets/EditorPlacement';
|
import {EditorPlacement} from 'app/client/widgets/EditorPlacement';
|
||||||
import {NewBaseEditor, Options} from 'app/client/widgets/NewBaseEditor';
|
import {NewBaseEditor, Options} from 'app/client/widgets/NewBaseEditor';
|
||||||
import {CellValue} from "app/common/DocActions";
|
import {CellValue} from "app/common/DocActions";
|
||||||
@ -37,13 +38,18 @@ export class NTextEditor extends NewBaseEditor {
|
|||||||
// Resize the textbox whenever user types in it.
|
// Resize the textbox whenever user types in it.
|
||||||
dom.on('input', () => this.resizeInput())
|
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.
|
// 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();
|
this.setSizerLimits();
|
||||||
|
|
||||||
// Once the editor is attached to DOM, resize it to content, focus, and set cursor.
|
// Once the editor is attached to DOM, resize it to content, focus, and set cursor.
|
||||||
|
@ -7,6 +7,12 @@ import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
|||||||
import {CellValue} from "app/common/DocActions";
|
import {CellValue} from "app/common/DocActions";
|
||||||
import {Disposable, IDisposableOwner, Observable} from 'grainjs';
|
import {Disposable, IDisposableOwner, Observable} from 'grainjs';
|
||||||
|
|
||||||
|
export interface IEditorCommandGroup {
|
||||||
|
fieldEditCancel: () => void;
|
||||||
|
fieldEditSaveHere: () => void;
|
||||||
|
[cmd: string]: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Options {
|
export interface Options {
|
||||||
gristDoc: GristDoc;
|
gristDoc: GristDoc;
|
||||||
field: ViewFieldRec;
|
field: ViewFieldRec;
|
||||||
@ -14,7 +20,7 @@ export interface Options {
|
|||||||
formulaError?: Observable<CellValue>;
|
formulaError?: Observable<CellValue>;
|
||||||
editValue?: string;
|
editValue?: string;
|
||||||
cursorPos: number;
|
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.cellValue: The value in the underlying cell being edited.
|
||||||
* @param {String} options.editValue: String to be edited, or undefined to use cellValue.
|
* @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 {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
|
* @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.
|
* 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.
|
* 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.
|
* 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.
|
* Called to get the value to save back to the cell.
|
||||||
|
@ -78,8 +78,8 @@ export class ReferenceEditor extends NTextEditor {
|
|||||||
.catch(reportError);
|
.catch(reportError);
|
||||||
}
|
}
|
||||||
|
|
||||||
public attach(cellRect: ClientRect|DOMRect): void {
|
public attach(cellElem: Element): void {
|
||||||
super.attach(cellRect);
|
super.attach(cellElem);
|
||||||
this._autocomplete = this.autoDispose(new Autocomplete<ICellItem>(this.textInput, {
|
this._autocomplete = this.autoDispose(new Autocomplete<ICellItem>(this.textInput, {
|
||||||
menuCssClass: menuCssClass + ' ' + cssRefList.className,
|
menuCssClass: menuCssClass + ' ' + cssRefList.className,
|
||||||
search: this._doSearch.bind(this),
|
search: this._doSearch.bind(this),
|
||||||
@ -168,7 +168,7 @@ function nocaseEqual(a: string, b: string) {
|
|||||||
|
|
||||||
const cssRefEditor = styled('div', `
|
const cssRefEditor = styled('div', `
|
||||||
& > .celleditor_text_editor, & > .celleditor_content_measure {
|
& > .celleditor_text_editor, & > .celleditor_content_measure {
|
||||||
padding-left: 21px;
|
padding-left: 18px;
|
||||||
}
|
}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@ -236,7 +236,7 @@ const cssRefEditIcon = styled(icon, `
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
margin: 2px 3px 0 3px;
|
margin: 3px 3px 0 3px;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const cssMatchText = styled('span', `
|
const cssMatchText = styled('span', `
|
||||||
|
@ -6,6 +6,7 @@ var dispose = require('../lib/dispose');
|
|||||||
var BaseEditor = require('./BaseEditor');
|
var BaseEditor = require('./BaseEditor');
|
||||||
var commands = require('../components/commands');
|
var commands = require('../components/commands');
|
||||||
const {testId} = require('app/client/ui2018/cssVars');
|
const {testId} = require('app/client/ui2018/cssVars');
|
||||||
|
const {createMobileButtons, getButtonMargins} = require('app/client/widgets/EditorButtons');
|
||||||
const {EditorPlacement} = require('app/client/widgets/EditorPlacement');
|
const {EditorPlacement} = require('app/client/widgets/EditorPlacement');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -44,16 +45,21 @@ function TextEditor(options) {
|
|||||||
// Resize the textbox whenever user types in it.
|
// Resize the textbox whenever user types in it.
|
||||||
dom.on('input', () => this._resizeInput())
|
dom.on('input', () => this._resizeInput())
|
||||||
)
|
)
|
||||||
)
|
),
|
||||||
|
createMobileButtons(options.commands),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose.makeDisposable(TextEditor);
|
dispose.makeDisposable(TextEditor);
|
||||||
_.extend(TextEditor.prototype, BaseEditor.prototype);
|
_.extend(TextEditor.prototype, BaseEditor.prototype);
|
||||||
|
|
||||||
TextEditor.prototype.attach = function(cellRect) {
|
TextEditor.prototype.attach = function(cellElem) {
|
||||||
// Attach the editor dom to page DOM.
|
// 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();
|
this.setSizerLimits();
|
||||||
|
|
||||||
// Once the editor is attached to DOM, resize it to content, focus, and set cursor.
|
// Once the editor is attached to DOM, resize it to content, focus, and set cursor.
|
||||||
|
Loading…
Reference in New Issue
Block a user