mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) move client code to core
Summary: This moves all client code to core, and makes minimal fix-ups to get grist and grist-core to compile correctly. The client works in core, but I'm leaving clean-up around the build and bundles to follow-up. Test Plan: existing tests pass; server-dev bundle looks sane Reviewers: dsagal Reviewed By: dsagal Differential Revision: https://phab.getgrist.com/D2627
This commit is contained in:
248
app/client/components/Clipboard.js
Normal file
248
app/client/components/Clipboard.js
Normal file
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Clipboard component manages the copy/cut/paste events by capturing these events from the browser,
|
||||
* managing their state, and exposing an API to other components to get/set the data.
|
||||
*
|
||||
* Because of a lack of standardization of ClipboardEvents between browsers, the way Clipboard
|
||||
* captures the events is by creating a hidden textarea element that's always focused with some text
|
||||
* selected. Here is a good write-up of this:
|
||||
* https://www.lucidchart.com/techblog/2014/12/02/definitive-guide-copying-pasting-javascript/
|
||||
*
|
||||
* When ClipboardEvent is detected, Clipboard captures the event and calls the corresponding
|
||||
* copy/cut/paste/input command actions, which will get called on the appropriate component.
|
||||
*
|
||||
* Usage:
|
||||
* Components need to register copy/cut/paste actions with command.js:
|
||||
* .copy() should return @pasteObj (defined below).
|
||||
* .paste(plainText, [cutSelection]) should take a plainText value and an optional cutSelection
|
||||
* parameter which will specify the selection that should be cleared as part of paste.
|
||||
* .input(char) should take a single input character and will be called when the user types a
|
||||
* visible character (useful if component wants to interpret typing into a cell, for example).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Paste object that should be returned by implementation of `copy`.
|
||||
*
|
||||
* @typedef pasteObj {{
|
||||
* docName: string,
|
||||
* tableId: string,
|
||||
* data: object,
|
||||
* selection: object
|
||||
* }}
|
||||
*/
|
||||
|
||||
|
||||
|
||||
/* global window, document, $ */
|
||||
|
||||
var ko = require('knockout');
|
||||
var {tsvDecode} = require('app/common/tsvFormat');
|
||||
|
||||
var commands = require('./commands');
|
||||
var dom = require('../lib/dom');
|
||||
var Base = require('./Base');
|
||||
var tableUtil = require('../lib/tableUtil');
|
||||
|
||||
function Clipboard(app) {
|
||||
Base.call(this, null);
|
||||
this._app = app;
|
||||
this.copypasteField = this.autoDispose(dom('textarea.copypaste.mousetrap', ''));
|
||||
this.timeoutId = null;
|
||||
|
||||
this.onEvent(window, 'focus', this.grabFocus);
|
||||
this.onEvent(this.copypasteField, 'blur', this.grabFocus);
|
||||
|
||||
this.onEvent(this.copypasteField, 'input', function(elem, event) {
|
||||
var value = elem.value;
|
||||
elem.value = '';
|
||||
commands.allCommands.input.run(value);
|
||||
return false;
|
||||
});
|
||||
this.onEvent(this.copypasteField, 'copy', this._onCopy);
|
||||
this.onEvent(this.copypasteField, 'cut', this._onCut);
|
||||
this.onEvent(this.copypasteField, 'paste', this._onPaste);
|
||||
|
||||
document.body.appendChild(this.copypasteField);
|
||||
this.grabFocus();
|
||||
|
||||
// The following block of code deals with what happens when the window is in the background.
|
||||
// When it is, focus and blur events are unreliable, and we'll watch explicitly for events which
|
||||
// may cause a change in focus. These wouldn't happen normally for a background window, but do
|
||||
// happen in Selenium Webdriver testing.
|
||||
var grabber = this.grabFocus.bind(this);
|
||||
function setBackgroundCapture(onOff) {
|
||||
var addRemove = onOff ? window.addEventListener : window.removeEventListener;
|
||||
// Note the third argument useCapture=true, which lets us notice these events before other
|
||||
// code that might call .stopPropagation on them.
|
||||
addRemove.call(window, 'click', grabber, true);
|
||||
addRemove.call(window, 'mousedown', grabber, true);
|
||||
addRemove.call(window, 'keydown', grabber, true);
|
||||
}
|
||||
this.onEvent(window, 'blur', setBackgroundCapture.bind(null, true));
|
||||
this.onEvent(window, 'focus', setBackgroundCapture.bind(null, false));
|
||||
setBackgroundCapture(!document.hasFocus());
|
||||
|
||||
// Expose the grabber as a global to allow upload from tests to explicitly restore focus
|
||||
window.gristClipboardGrabFocus = grabber;
|
||||
|
||||
// Some bugs may prevent Clipboard from re-grabbing focus. To limit the impact of such bugs on
|
||||
// the user, recover from a bad state in mousedown events. (At the moment of this comment, all
|
||||
// such known bugs are fixed.)
|
||||
this.onEvent(window, 'mousedown', (ev) => {
|
||||
if (!document.activeElement || document.activeElement === document.body) {
|
||||
this.grabFocus();
|
||||
}
|
||||
});
|
||||
|
||||
// In the event of a cut a callback is provided by the viewsection that is the target of the cut.
|
||||
// When called it returns the additional removal action needed for a cut.
|
||||
this._cutCallback = null;
|
||||
// The plaintext content of the cut callback. Used to verify that we are pasting the results
|
||||
// of the cut, rather than new data from outside.
|
||||
this._cutData = null;
|
||||
}
|
||||
Base.setBaseFor(Clipboard);
|
||||
|
||||
/**
|
||||
* Internal helper fired on `copy` events. If a callback was registered from a component, calls the
|
||||
* callback to get selection data and puts it on the clipboard.
|
||||
*/
|
||||
Clipboard.prototype._onCopy = function(elem, event) {
|
||||
event.preventDefault();
|
||||
|
||||
let pasteObj = commands.allCommands.copy.run();
|
||||
|
||||
this._setCBdata(pasteObj, event.originalEvent.clipboardData);
|
||||
};
|
||||
|
||||
Clipboard.prototype._onCut = function(elem, event) {
|
||||
event.preventDefault();
|
||||
|
||||
let pasteObj = commands.allCommands.cut.run();
|
||||
|
||||
this._setCBdata(pasteObj, event.originalEvent.clipboardData);
|
||||
};
|
||||
|
||||
Clipboard.prototype._setCBdata = function(pasteObj, clipboardData) {
|
||||
|
||||
if (!pasteObj) {
|
||||
return;
|
||||
}
|
||||
|
||||
let plainText = tableUtil.makePasteText(pasteObj.data, pasteObj.selection);
|
||||
clipboardData.setData('text/plain', plainText);
|
||||
let htmlText = tableUtil.makePasteHtml(pasteObj.data, pasteObj.selection);
|
||||
clipboardData.setData('text/html', htmlText);
|
||||
|
||||
if (pasteObj.cutCallback) {
|
||||
this._cutCallback = pasteObj.cutCallback;
|
||||
this._cutData = plainText;
|
||||
} else {
|
||||
this._cutCallback = null;
|
||||
this._cutData = null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Internal helper fired on `paste` events. If a callback was registered from a component, calls the
|
||||
* callback with data from the clipboard.
|
||||
*/
|
||||
Clipboard.prototype._onPaste = function(elem, event) {
|
||||
event.preventDefault();
|
||||
let cb = event.originalEvent.clipboardData;
|
||||
let plainText = cb.getData('text/plain');
|
||||
let htmlText = cb.getData('text/html');
|
||||
let data;
|
||||
|
||||
// Grist stores both text/html and text/plain when copying data. When pasting back, we first
|
||||
// check if text/html exists (should exist for Grist and other spreadsheet software), and fall
|
||||
// back to text/plain otherwise.
|
||||
try {
|
||||
data = tableUtil.parsePasteHtml(htmlText);
|
||||
} catch (e) {
|
||||
if (plainText === '' || plainText.charCodeAt(0) === 0xFEFF) {
|
||||
data = [['']];
|
||||
} else {
|
||||
data = tsvDecode(plainText.replace(/\r\n?/g, "\n"));
|
||||
}
|
||||
}
|
||||
|
||||
if (this._cutData === plainText) {
|
||||
if (this._cutCallback) {
|
||||
// Cuts should only be possible on the first paste after a cut and only if the data being
|
||||
// pasted matches the data that was cut.
|
||||
commands.allCommands.paste.run(data, this._cutCallback);
|
||||
}
|
||||
} else {
|
||||
this._cutData = null;
|
||||
commands.allCommands.paste.run(data, null);
|
||||
}
|
||||
// The cut callback should only be usable once so it needs to be cleared after every paste.
|
||||
this._cutCallback = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to watch a focused element to lose focus, in which point the Clipboard will grab it.
|
||||
* Because elements getting removed from the DOM don't always trigger 'blur' event, this also
|
||||
* watches for the element getting disposed (using ko.removeNode or ko.cleanNode).
|
||||
*/
|
||||
Clipboard.prototype._watchElementForBlur = function(elem) {
|
||||
var self = this;
|
||||
function done() {
|
||||
$(elem).off('blur.clipboard');
|
||||
ko.utils.domNodeDisposal.removeDisposeCallback(elem, done);
|
||||
self.grabFocus();
|
||||
}
|
||||
$(elem).one('blur.clipboard', done);
|
||||
// TODO We need to add proper integration of grainjs and knockout dom-disposal. Otherwise a
|
||||
// focused node that's disposed by grainjs will not trigger this knockout disposal callback.
|
||||
ko.utils.domNodeDisposal.addDisposeCallback(elem, done);
|
||||
};
|
||||
|
||||
var FOCUS_TARGET_TAGS = {
|
||||
'INPUT': true,
|
||||
'TEXTAREA': true,
|
||||
'SELECT': true,
|
||||
'IFRAME': true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to determine if the currently active element deserves to keep its own focus, and capture
|
||||
* 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) {
|
||||
return elem && (FOCUS_TARGET_TAGS.hasOwnProperty(elem.tagName) ||
|
||||
elem.hasAttribute("tabindex") ||
|
||||
elem.classList.contains('clipboard_focus'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the special copypaste field to capture clipboard events.
|
||||
*/
|
||||
Clipboard.prototype.grabFocus = function() {
|
||||
if (!this.timeoutId) {
|
||||
var self = this;
|
||||
this.timeoutId = setTimeout(() => {
|
||||
if (self.isDisposed()) { return; }
|
||||
self.timeoutId = null;
|
||||
if (document.activeElement === self.copypasteField) {
|
||||
return;
|
||||
}
|
||||
// If the window doesn't have focus, don't rush to grab it, or we can interfere with focus
|
||||
// outside the frame when embedded. We'll grab focus when setBackgroundCapture tells us to.
|
||||
if (!document.hasFocus()) {
|
||||
return;
|
||||
}
|
||||
if (isCopyPasteTarget(document.activeElement)) {
|
||||
self._watchElementForBlur(document.activeElement);
|
||||
self._app.trigger('clipboard_blur');
|
||||
} else {
|
||||
self.copypasteField.value = ' ';
|
||||
self.copypasteField.select();
|
||||
self._app.trigger('clipboard_focus');
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = Clipboard;
|
||||
Reference in New Issue
Block a user