mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
755a742d6f
* Add "Copy with headers" to grid cell popup. This is what you want when you're going to paste into e.g. an email. Tested just by manually trying copy and paste into an editor and an email, and then again using the new variant to confirm the headers show up. https://github.com/gristlabs/grist-core/pull/1208
354 lines
11 KiB
JavaScript
354 lines
11 KiB
JavaScript
/**
|
|
* 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 {getHumanKey, isMac} = require('app/client/components/commands');
|
|
var {copyToClipboard, readDataFromClipboard} = require('app/client/lib/clipboardUtils');
|
|
var {FocusLayer} = require('app/client/lib/FocusLayer');
|
|
var {makeT} = require('app/client/lib/localization');
|
|
|
|
var {tsvDecode} = require('app/common/tsvFormat');
|
|
var {ShortcutKey, ShortcutKeyContent} = require('app/client/ui/ShortcutKey');
|
|
var {confirmModal} = require('app/client/ui2018/modals');
|
|
var {styled} = require('grainjs');
|
|
|
|
var commands = require('./commands');
|
|
var dom = require('../lib/dom');
|
|
var Base = require('./Base');
|
|
var tableUtil = require('../lib/tableUtil');
|
|
|
|
const t = makeT('Clipboard');
|
|
|
|
function Clipboard(app) {
|
|
Base.call(this, null);
|
|
this._app = app;
|
|
this.copypasteField = this.autoDispose(dom('textarea.copypaste.mousetrap', ''));
|
|
this.timeoutId = null;
|
|
|
|
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);
|
|
|
|
FocusLayer.create(this, {
|
|
defaultFocusElem: this.copypasteField,
|
|
allowFocus: allowFocus,
|
|
onDefaultFocus: () => {
|
|
this.copypasteField.value = ' ';
|
|
this.copypasteField.select();
|
|
this._app.trigger('clipboard_focus');
|
|
},
|
|
onDefaultBlur: () => {
|
|
this._app.trigger('clipboard_blur');
|
|
},
|
|
});
|
|
|
|
// Expose the grabber as a global to allow upload from tests to explicitly restore focus
|
|
window.gristClipboardGrabFocus = () => FocusLayer.grabFocus();
|
|
|
|
// 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) {
|
|
FocusLayer.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;
|
|
|
|
this.autoDispose(commands.createGroup(Clipboard.commands, this, true));
|
|
}
|
|
Base.setBaseFor(Clipboard);
|
|
|
|
Clipboard.commands = {
|
|
contextMenuCopy: function() { this._doContextMenuCopy(); },
|
|
contextMenuCopyWithHeaders: function() { this._doContextMenuCopyWithHeaders(); },
|
|
contextMenuCut: function() { this._doContextMenuCut(); },
|
|
contextMenuPaste: function() { this._doContextMenuPaste(); },
|
|
};
|
|
|
|
/**
|
|
* 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._doContextMenuCopy = function() {
|
|
let pasteObj = commands.allCommands.copy.run();
|
|
|
|
this._copyToClipboard(pasteObj, 'copy', false);
|
|
};
|
|
|
|
Clipboard.prototype._doContextMenuCopyWithHeaders = function() {
|
|
let pasteObj = commands.allCommands.copy.run();
|
|
|
|
this._copyToClipboard(pasteObj, 'copy', true);
|
|
};
|
|
|
|
Clipboard.prototype._onCut = function(elem, event) {
|
|
event.preventDefault();
|
|
|
|
let pasteObj = commands.allCommands.cut.run();
|
|
|
|
this._setCBdata(pasteObj, event.originalEvent.clipboardData);
|
|
};
|
|
|
|
Clipboard.prototype._doContextMenuCut = function() {
|
|
let pasteObj = commands.allCommands.cut.run();
|
|
|
|
this._copyToClipboard(pasteObj, 'cut');
|
|
};
|
|
|
|
Clipboard.prototype._setCBdata = function(pasteObj, clipboardData) {
|
|
if (!pasteObj) { return; }
|
|
|
|
const plainText = tableUtil.makePasteText(pasteObj.data, pasteObj.selection, false);
|
|
clipboardData.setData('text/plain', plainText);
|
|
const htmlText = tableUtil.makePasteHtml(pasteObj.data, pasteObj.selection, false);
|
|
clipboardData.setData('text/html', htmlText);
|
|
|
|
this._setCutCallback(pasteObj, plainText);
|
|
};
|
|
|
|
Clipboard.prototype._copyToClipboard = async function(pasteObj, action, includeColHeaders) {
|
|
if (!pasteObj) { return; }
|
|
|
|
const plainText = tableUtil.makePasteText(pasteObj.data, pasteObj.selection, includeColHeaders);
|
|
let data;
|
|
if (typeof ClipboardItem === 'function') {
|
|
const htmlText = tableUtil.makePasteHtml(pasteObj.data, pasteObj.selection, includeColHeaders);
|
|
// eslint-disable-next-line no-undef
|
|
data = new ClipboardItem({
|
|
// eslint-disable-next-line no-undef
|
|
'text/plain': new Blob([plainText], {type: 'text/plain'}),
|
|
// eslint-disable-next-line no-undef
|
|
'text/html': new Blob([htmlText], {type: 'text/html'}),
|
|
});
|
|
} else {
|
|
data = plainText;
|
|
}
|
|
|
|
try {
|
|
await copyToClipboard(data);
|
|
} catch {
|
|
showUnavailableMenuCommandModal(action);
|
|
return;
|
|
}
|
|
|
|
this._setCutCallback(pasteObj, plainText);
|
|
};
|
|
|
|
/**
|
|
* Sets the cut callback from the `pasteObj` if one exists. Otherwise clears the
|
|
* cut callback.
|
|
*
|
|
* The callback is called on paste, and only if the pasted data matches the `cutData`
|
|
* that was cut from within Grist. The callback handles removal of the data that was
|
|
* cut.
|
|
*/
|
|
Clipboard.prototype._setCutCallback = function(pasteObj, cutData) {
|
|
if (pasteObj.cutCallback) {
|
|
this._cutCallback = pasteObj.cutCallback;
|
|
this._cutData = cutData;
|
|
} 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();
|
|
const cb = event.originalEvent.clipboardData;
|
|
const plainText = cb.getData('text/plain');
|
|
const htmlText = cb.getData('text/html');
|
|
const pasteData = getPasteData(plainText, htmlText);
|
|
this._doPaste(pasteData, plainText);
|
|
};
|
|
|
|
var FOCUS_TARGET_TAGS = {
|
|
'INPUT': true,
|
|
'TEXTAREA': true,
|
|
'SELECT': true,
|
|
'IFRAME': true,
|
|
};
|
|
|
|
Clipboard.prototype._doContextMenuPaste = async function() {
|
|
let clipboardItem;
|
|
try {
|
|
clipboardItem = (await readDataFromClipboard())?.[0];
|
|
} catch {
|
|
showUnavailableMenuCommandModal('paste');
|
|
return;
|
|
}
|
|
const plainText = await getTextFromClipboardItem(clipboardItem, 'text/plain');
|
|
const htmlText = await getTextFromClipboardItem(clipboardItem, 'text/html');
|
|
const pasteData = getPasteData(plainText, htmlText);
|
|
this._doPaste(pasteData, plainText);
|
|
};
|
|
|
|
Clipboard.prototype._doPaste = function(pasteData, plainText) {
|
|
console.log(this._cutData, plainText, this._cutCallback);
|
|
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(pasteData, this._cutCallback);
|
|
}
|
|
} else {
|
|
this._cutData = null;
|
|
commands.allCommands.paste.run(pasteData, null);
|
|
}
|
|
// The cut callback should only be usable once so it needs to be cleared after every paste.
|
|
this._cutCallback = null;
|
|
}
|
|
|
|
/**
|
|
* Returns data formatted as a 2D array of strings, suitable for pasting within Grist.
|
|
*
|
|
* 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.
|
|
*/
|
|
function getPasteData(plainText, htmlText) {
|
|
try {
|
|
return tableUtil.parsePasteHtml(htmlText);
|
|
} catch (e) {
|
|
if (plainText === '' || plainText.charCodeAt(0) === 0xFEFF) {
|
|
return [['']];
|
|
} else {
|
|
return tsvDecode(plainText.replace(/\r\n?/g, "\n").trimEnd());
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns clipboard data of the given `type` from `clipboardItem` as text.
|
|
*
|
|
* Returns an empty string if `clipboardItem` is nullish or no data exists
|
|
* for the given `type`.
|
|
*/
|
|
async function getTextFromClipboardItem(clipboardItem, type) {
|
|
if (!clipboardItem) { return ''; }
|
|
|
|
try {
|
|
return (await clipboardItem.getType(type)).text();
|
|
} catch {
|
|
// No clipboard data exists for the MIME type.
|
|
return '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 allowFocus(elem) {
|
|
return elem && (FOCUS_TARGET_TAGS.hasOwnProperty(elem.tagName) ||
|
|
elem.hasAttribute("tabindex") ||
|
|
elem.classList.contains('clipboard_focus'));
|
|
}
|
|
|
|
Clipboard.allowFocus = allowFocus;
|
|
|
|
function showUnavailableMenuCommandModal(action) {
|
|
let keys;
|
|
switch (action) {
|
|
case 'cut': {
|
|
keys = 'Mod+X'
|
|
break;
|
|
}
|
|
case 'copy': {
|
|
keys = 'Mod+C'
|
|
break;
|
|
}
|
|
case 'paste': {
|
|
keys = 'Mod+V'
|
|
break;
|
|
}
|
|
default: {
|
|
throw new Error(`Clipboard: unrecognized action ${action}`);
|
|
}
|
|
}
|
|
|
|
confirmModal(
|
|
t("Unavailable Command"),
|
|
t("Got it"),
|
|
() => {},
|
|
{
|
|
explanation: cssModalContent(
|
|
t(
|
|
'The {{action}} menu command is not available in this browser. You can still {{action}}' +
|
|
' by using the keyboard shortcut {{shortcut}}.',
|
|
{
|
|
action,
|
|
shortcut: ShortcutKey(ShortcutKeyContent(getHumanKey(keys, isMac))),
|
|
}
|
|
),
|
|
),
|
|
hideCancel: true,
|
|
},
|
|
);
|
|
}
|
|
|
|
module.exports = Clipboard;
|
|
|
|
const cssModalContent = styled('div', `
|
|
line-height: 18px;
|
|
`);
|