(core) Add cut, copy, and paste to context menu

Summary:
On supported browsers, the new context menu commands work exactly as they do
via keyboard shortcuts. On unsupported browsers, an unavailable command
modal is shown with a suggestion to use keyboard shortcuts instead.

Test Plan: Browser tests.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D3867
This commit is contained in:
George Gevoian 2023-04-28 02:20:28 -07:00
parent c6ec8339f5
commit 18ad39cba3
17 changed files with 468 additions and 97 deletions

View File

@ -22,7 +22,7 @@ const {ClientColumnGetters} = require('app/client/models/ClientColumnGetters');
const {reportError, reportSuccess} = require('app/client/models/errors'); const {reportError, reportSuccess} = require('app/client/models/errors');
const {urlState} = require('app/client/models/gristUrlState'); const {urlState} = require('app/client/models/gristUrlState');
const {SectionFilter} = require('app/client/models/SectionFilter'); const {SectionFilter} = require('app/client/models/SectionFilter');
const {copyToClipboard} = require('app/client/lib/copyToClipboard'); const {copyToClipboard} = require('app/client/lib/clipboardUtils');
const {setTestState} = require('app/client/lib/testState'); const {setTestState} = require('app/client/lib/testState');
const {ExtraRows} = require('app/client/models/DataTableModelWithDiff'); const {ExtraRows} = require('app/client/models/DataTableModelWithDiff');
const {createFilterMenu} = require('app/client/ui/ColumnFilterMenu'); const {createFilterMenu} = require('app/client/ui/ColumnFilterMenu');
@ -153,7 +153,7 @@ function BaseView(gristDoc, viewSectionModel, options) {
return linking && linking.disableEditing(); return linking && linking.disableEditing();
})); }));
this.isPreview = this.options.isPreview; this.isPreview = this.options.isPreview ?? false;
this.enableAddRow = this.autoDispose(ko.computed(() => this.options.addNewRow && this.enableAddRow = this.autoDispose(ko.computed(() => this.options.addNewRow &&
!this.viewSection.disableAddRemoveRows() && !this.disableEditing())); !this.viewSection.disableAddRemoveRows() && !this.disableEditing()));
@ -602,6 +602,9 @@ BaseView.prototype._saveEditRowField = function(editRowModel, colName, value) {
* @returns {pasteObj} - Paste object * @returns {pasteObj} - Paste object
*/ */
BaseView.prototype.copy = function(selection) { BaseView.prototype.copy = function(selection) {
// Clear the previous copy selection, if any.
commands.allCommands.clearCopySelection.run();
this.copySelection(selection); this.copySelection(selection);
return { return {
@ -617,6 +620,9 @@ BaseView.prototype.copy = function(selection) {
* @returns {pasteObj} - Paste object * @returns {pasteObj} - Paste object
*/ */
BaseView.prototype.cut = function(selection) { BaseView.prototype.cut = function(selection) {
// Clear the previous copy selection, if any.
commands.allCommands.clearCopySelection.run();
this.copySelection(selection); this.copySelection(selection);
return { return {

View File

@ -34,14 +34,23 @@
/* global window, document */ /* global window, document */
var {tsvDecode} = require('app/common/tsvFormat'); var {getHumanKey, isMac} = require('app/client/components/commands');
var {copyToClipboard, readDataFromClipboard} = require('app/client/lib/clipboardUtils');
var {FocusLayer} = require('app/client/lib/FocusLayer'); 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 commands = require('./commands');
var dom = require('../lib/dom'); var dom = require('../lib/dom');
var Base = require('./Base'); var Base = require('./Base');
var tableUtil = require('../lib/tableUtil'); var tableUtil = require('../lib/tableUtil');
const t = makeT('Clipboard');
function Clipboard(app) { function Clipboard(app) {
Base.call(this, null); Base.call(this, null);
this._app = app; this._app = app;
@ -91,9 +100,17 @@ function Clipboard(app) {
// The plaintext content of the cut callback. Used to verify that we are pasting the results // 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. // of the cut, rather than new data from outside.
this._cutData = null; this._cutData = null;
this.autoDispose(commands.createGroup(Clipboard.commands, this, true));
} }
Base.setBaseFor(Clipboard); Base.setBaseFor(Clipboard);
Clipboard.commands = {
contextMenuCopy: function() { this._doContextMenuCopy(); },
contextMenuCut: function() { this._doContextMenuCut(); },
contextMenuPaste: function() { this._doContextMenuPaste(); },
};
/** /**
* Internal helper fired on `copy` events. If a callback was registered from a component, calls the * 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. * callback to get selection data and puts it on the clipboard.
@ -106,6 +123,12 @@ Clipboard.prototype._onCopy = function(elem, event) {
this._setCBdata(pasteObj, event.originalEvent.clipboardData); this._setCBdata(pasteObj, event.originalEvent.clipboardData);
}; };
Clipboard.prototype._doContextMenuCopy = function() {
let pasteObj = commands.allCommands.copy.run();
this._copyToClipboard(pasteObj, 'copy');
};
Clipboard.prototype._onCut = function(elem, event) { Clipboard.prototype._onCut = function(elem, event) {
event.preventDefault(); event.preventDefault();
@ -114,20 +137,63 @@ Clipboard.prototype._onCut = function(elem, event) {
this._setCBdata(pasteObj, event.originalEvent.clipboardData); this._setCBdata(pasteObj, event.originalEvent.clipboardData);
}; };
Clipboard.prototype._setCBdata = function(pasteObj, clipboardData) { Clipboard.prototype._doContextMenuCut = function() {
let pasteObj = commands.allCommands.cut.run();
if (!pasteObj) { this._copyToClipboard(pasteObj, 'cut');
};
Clipboard.prototype._setCBdata = function(pasteObj, clipboardData) {
if (!pasteObj) { return; }
const plainText = tableUtil.makePasteText(pasteObj.data, pasteObj.selection);
clipboardData.setData('text/plain', plainText);
const htmlText = tableUtil.makePasteHtml(pasteObj.data, pasteObj.selection);
clipboardData.setData('text/html', htmlText);
this._setCutCallback(pasteObj, plainText);
};
Clipboard.prototype._copyToClipboard = async function(pasteObj, action) {
if (!pasteObj) { return; }
const plainText = tableUtil.makePasteText(pasteObj.data, pasteObj.selection);
let data;
if (typeof ClipboardItem === 'function') {
const htmlText = tableUtil.makePasteHtml(pasteObj.data, pasteObj.selection);
// 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; return;
} }
let plainText = tableUtil.makePasteText(pasteObj.data, pasteObj.selection); this._setCutCallback(pasteObj, plainText);
clipboardData.setData('text/plain', plainText); };
let htmlText = tableUtil.makePasteHtml(pasteObj.data, pasteObj.selection);
clipboardData.setData('text/html', htmlText);
/**
* 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) { if (pasteObj.cutCallback) {
this._cutCallback = pasteObj.cutCallback; this._cutCallback = pasteObj.cutCallback;
this._cutData = plainText; this._cutData = cutData;
} else { } else {
this._cutCallback = null; this._cutCallback = null;
this._cutData = null; this._cutData = null;
@ -140,36 +206,11 @@ Clipboard.prototype._setCBdata = function(pasteObj, clipboardData) {
*/ */
Clipboard.prototype._onPaste = function(elem, event) { Clipboard.prototype._onPaste = function(elem, event) {
event.preventDefault(); event.preventDefault();
let cb = event.originalEvent.clipboardData; const cb = event.originalEvent.clipboardData;
let plainText = cb.getData('text/plain'); const plainText = cb.getData('text/plain');
let htmlText = cb.getData('text/html'); const htmlText = cb.getData('text/html');
let data; const pasteData = getPasteData(plainText, htmlText);
this._doPaste(pasteData, plainText);
// 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").trimEnd());
}
}
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;
}; };
var FOCUS_TARGET_TAGS = { var FOCUS_TARGET_TAGS = {
@ -179,6 +220,72 @@ var FOCUS_TARGET_TAGS = {
'IFRAME': 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 * 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 events. Besides inputs and textareas, any element can be marked to be a valid
@ -192,4 +299,48 @@ function allowFocus(elem) {
Clipboard.allowFocus = allowFocus; 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; module.exports = Clipboard;
const cssModalContent = styled('div', `
line-height: 18px;
`);

View File

@ -1,5 +1,5 @@
import {GristDoc} from 'app/client/components/GristDoc'; import {GristDoc} from 'app/client/components/GristDoc';
import {copyToClipboard} from 'app/client/lib/copyToClipboard'; import {copyToClipboard} from 'app/client/lib/clipboardUtils';
import {setTestState} from 'app/client/lib/testState'; import {setTestState} from 'app/client/lib/testState';
import {TableRec} from 'app/client/models/DocModel'; import {TableRec} from 'app/client/models/DocModel';
import {docListHeader, docMenuTrigger} from 'app/client/ui/DocMenuCss'; import {docListHeader, docMenuTrigger} from 'app/client/ui/DocMenuCss';

View File

@ -11,9 +11,12 @@ require('app/client/lib/koUtil'); // Needed for subscribeInit.
const Base = require('./Base'); const Base = require('./Base');
const BaseView = require('./BaseView'); const BaseView = require('./BaseView');
const selector = require('./CellSelector');
const {CopySelection} = require('./CopySelection'); const {CopySelection} = require('./CopySelection');
const RecordLayout = require('./RecordLayout'); const RecordLayout = require('./RecordLayout');
const commands = require('./commands'); const commands = require('./commands');
const tableUtil = require('../lib/tableUtil');
const {FieldContextMenu} = require('../ui/FieldContextMenu');
const {RowContextMenu} = require('../ui/RowContextMenu'); const {RowContextMenu} = require('../ui/RowContextMenu');
const {parsePasteForView} = require("./BaseView2"); const {parsePasteForView} = require("./BaseView2");
const {columnInfoTooltip} = require("../ui/tooltips"); const {columnInfoTooltip} = require("../ui/tooltips");
@ -25,6 +28,8 @@ const {columnInfoTooltip} = require("../ui/tooltips");
function DetailView(gristDoc, viewSectionModel) { function DetailView(gristDoc, viewSectionModel) {
BaseView.call(this, gristDoc, viewSectionModel, { 'addNewRow': true }); BaseView.call(this, gristDoc, viewSectionModel, { 'addNewRow': true });
this.cellSelector = selector.CellSelector.create(this, this);
this.viewFields = gristDoc.docModel.viewFields; this.viewFields = gristDoc.docModel.viewFields;
this._isSingle = (this.viewSection.parentKey.peek() === 'single'); this._isSingle = (this.viewSection.parentKey.peek() === 'single');
@ -33,7 +38,8 @@ function DetailView(gristDoc, viewSectionModel) {
this.recordLayout = this.autoDispose(RecordLayout.create({ this.recordLayout = this.autoDispose(RecordLayout.create({
viewSection: this.viewSection, viewSection: this.viewSection,
buildFieldDom: this.buildFieldDom.bind(this), buildFieldDom: this.buildFieldDom.bind(this),
buildContextMenu : this.buildContextMenu.bind(this), buildRowContextMenu : this.buildRowContextMenu.bind(this),
buildFieldContextMenu : this.buildFieldContextMenu.bind(this),
resizeCallback: () => { resizeCallback: () => {
if (!this._isSingle) { if (!this._isSingle) {
this.scrolly().updateSize(); this.scrolly().updateSize();
@ -109,8 +115,10 @@ function DetailView(gristDoc, viewSectionModel) {
//-------------------------------------------------- //--------------------------------------------------
// Instantiate CommandGroups for the different modes. // Instantiate CommandGroups for the different modes.
this.autoDispose(commands.createGroup(DetailView.generalCommands, this, this.viewSection.hasFocus)); this.autoDispose(commands.createGroup(DetailView.generalCommands, this, this.viewSection.hasFocus));
this.newFieldCommandGroup = this.autoDispose( this.autoDispose(commands.createGroup(DetailView.fieldCommands, this, this.viewSection.hasFocus));
commands.createGroup(DetailView.newFieldCommands, this, this.isNewFieldActive)); const hasSelection = this.autoDispose(ko.pureComputed(() =>
!this.cellSelector.isCurrentSelectType('') || this.copySelection()));
this.autoDispose(commands.createGroup(DetailView.selectionCommands, this, hasSelection));
} }
Base.setBaseFor(DetailView); Base.setBaseFor(DetailView);
_.extend(DetailView.prototype, BaseView.prototype); _.extend(DetailView.prototype, BaseView.prototype);
@ -151,7 +159,17 @@ DetailView.generalCommands = {
this.scrolly().scrollRowIntoView(this.cursor.rowIndex()); this.scrolly().scrollRowIntoView(this.cursor.rowIndex());
} }
this.recordLayout.editLayout(this.cursor.rowIndex()); this.recordLayout.editLayout(this.cursor.rowIndex());
} },
};
DetailView.fieldCommands = {
clearCardFields: function() { this._clearCardFields(); },
hideCardFields: function() { this._hideCardFields(); },
};
DetailView.selectionCommands = {
clearCopySelection: function() { this._clearCopySelection(); },
cancel: function() { this._clearSelection(); }
}; };
//---------------------------------------------------------------------- //----------------------------------------------------------------------
@ -205,7 +223,7 @@ DetailView.prototype.paste = async function(data, cutCallback) {
const addRowId = (action[0] === 'BulkAddRecord' ? results[0][0] : null); const addRowId = (action[0] === 'BulkAddRecord' ? results[0][0] : null);
// Restore the cursor to the right rowId, even if it jumped. // Restore the cursor to the right rowId, even if it jumped.
this.cursor.setCursorPos({rowId: cursorPos.rowId === 'new' ? addRowId : cursorPos.rowId}); this.cursor.setCursorPos({rowId: cursorPos.rowId === 'new' ? addRowId : cursorPos.rowId});
this.copySelection(null); commands.allCommands.clearCopySelection.run();
}); });
}; };
@ -224,14 +242,15 @@ DetailView.prototype.getSelection = function() {
); );
}; };
DetailView.prototype.buildContextMenu = function(row, options) { DetailView.prototype.buildRowContextMenu = function(row) {
const defaults = { const rowOptions = this._getRowContextMenuOptions(row);
disableInsert: Boolean(this.gristDoc.isReadonly.get() || this.viewSection.disableAddRemoveRows() || this.tableModel.tableMetaRow.onDemand()), return RowContextMenu(rowOptions);
disableDelete: Boolean(this.gristDoc.isReadonly.get() || this.viewSection.disableAddRemoveRows() || row._isAddRow()), }
isViewSorted: this.viewSection.activeSortSpec.peek().length > 0,
numRows: this.getSelection().rowIds.length, DetailView.prototype.buildFieldContextMenu = function(row) {
}; const rowOptions = this._getRowContextMenuOptions(row);
return RowContextMenu(options ? Object.assign(defaults, options) : defaults); const fieldOptions = this._getFieldContextMenuOptions();
return FieldContextMenu(rowOptions, fieldOptions);
} }
/** /**
@ -463,4 +482,61 @@ DetailView.prototype._canSingleClick = function(field) {
return true; return true;
}; };
DetailView.prototype._clearCardFields = function() {
const {isFormula} = this._getFieldContextMenuOptions();
if (isFormula === true) {
this.activateEditorAtCursor({init: ''});
} else {
const clearAction = tableUtil.makeDeleteAction(this.getSelection());
if (clearAction) {
this.gristDoc.docData.sendAction(clearAction);
}
}
};
DetailView.prototype._hideCardFields = function() {
const selection = this.getSelection();
const actions = selection.fields.map(field => ['RemoveRecord', field.id()]);
return this.gristDoc.docModel.viewFields.sendTableActions(
actions,
`Hide fields ${actions.map(a => a[1]).join(', ')} ` +
`from ${this.tableModel.tableData.tableId}.`
);
}
DetailView.prototype._clearSelection = function() {
this.copySelection(null);
this.cellSelector.setToCursor();
};
DetailView.prototype._clearCopySelection = function() {
this.copySelection(null);
};
DetailView.prototype._getRowContextMenuOptions = function(row) {
return {
disableInsert: Boolean(
this.gristDoc.isReadonly.get() ||
this.viewSection.disableAddRemoveRows() ||
this.tableModel.tableMetaRow.onDemand()
),
disableDelete: Boolean(
this.gristDoc.isReadonly.get() ||
this.viewSection.disableAddRemoveRows() ||
row._isAddRow()
),
isViewSorted: this.viewSection.activeSortSpec.peek().length > 0,
numRows: this.getSelection().rowIds.length,
};
}
DetailView.prototype._getFieldContextMenuOptions = function() {
const selection = this.getSelection();
return {
disableModify: Boolean(selection.fields[0]?.disableModify.peek()),
isReadonly: this.gristDoc.isReadonly.get() || this.isPreview,
isFormula: Boolean(selection.fields[0]?.column.peek().isRealFormula.peek()),
};
}
module.exports = DetailView; module.exports = DetailView;

View File

@ -277,6 +277,7 @@ _.extend(GridView.prototype, BaseView.prototype);
// Moved out of all commands to support Raw Data Views (which use this command to close // Moved out of all commands to support Raw Data Views (which use this command to close
// the Grid popup). // the Grid popup).
GridView.selectionCommands = { GridView.selectionCommands = {
clearCopySelection: function() { this._clearCopySelection(); },
cancel: function() { this.clearSelection(); } cancel: function() { this.clearSelection(); }
} }
@ -455,7 +456,7 @@ GridView.prototype.paste = async function(data, cutCallback) {
topRowIndex + outputHeight - 1, leftIndex + outputWidth - 1); topRowIndex + outputHeight - 1, leftIndex + outputWidth - 1);
} }
this.copySelection(null); commands.allCommands.clearCopySelection.run();
}); });
} }
}; };
@ -1738,6 +1739,10 @@ GridView.prototype._duplicateRows = async function() {
} }
} }
GridView.prototype._clearCopySelection = function() {
this.copySelection(null);
};
function buildStyleOption(owner, computedRule, optionName) { function buildStyleOption(owner, computedRule, optionName) {
return ko.computed(() => { return ko.computed(() => {
if (owner.isDisposed()) { return null; } if (owner.isDisposed()) { return null; }

View File

@ -54,7 +54,8 @@ const t = makeT('RecordLayout');
function RecordLayout(options) { function RecordLayout(options) {
this.viewSection = options.viewSection; this.viewSection = options.viewSection;
this.buildFieldDom = options.buildFieldDom; this.buildFieldDom = options.buildFieldDom;
this.buildContextMenu = options.buildContextMenu; this.buildRowContextMenu = options.buildRowContextMenu;
this.buildFieldContextMenu = options.buildFieldContextMenu;
this.isEditingLayout = ko.observable(false); this.isEditingLayout = ko.observable(false);
this.editIndex = ko.observable(0); this.editIndex = ko.observable(0);
this.layoutEditor = ko.observable(null); // RecordLayoutEditor when one is active. this.layoutEditor = ko.observable(null); // RecordLayoutEditor when one is active.
@ -340,8 +341,8 @@ RecordLayout.prototype.buildLayoutDom = function(row, optCreateEditor) {
this.layoutEditor.peek().dispose(); this.layoutEditor.peek().dispose();
this.layoutEditor(null); this.layoutEditor(null);
}) : null, }) : null,
// enables row context menu anywhere on the card // enables field context menu anywhere on the card
contextMenu(() => this.buildContextMenu(row)), contextMenu(() => this.buildFieldContextMenu(row)),
dom('div.detail_row_num', dom('div.detail_row_num',
kd.text(() => (row._index() + 1)), kd.text(() => (row._index() + 1)),
dom.on('contextmenu', ev => { dom.on('contextmenu', ev => {
@ -357,7 +358,7 @@ RecordLayout.prototype.buildLayoutDom = function(row, optCreateEditor) {
this.viewSection.hasFocus(true); this.viewSection.hasFocus(true);
commands.allCommands.setCursor.run(row); commands.allCommands.setCursor.run(row);
}), }),
menu(() => this.buildContextMenu(row)), menu(() => this.buildRowContextMenu(row)),
testId('card-menu-trigger') testId('card-menu-trigger')
) )
), ),

View File

@ -1,5 +1,5 @@
import {CellSelector, COL, ROW} from 'app/client/components/CellSelector'; import {CellSelector, COL, ROW} from 'app/client/components/CellSelector';
import {copyToClipboard} from 'app/client/lib/copyToClipboard'; import {copyToClipboard} from 'app/client/lib/clipboardUtils';
import {Delay} from "app/client/lib/Delay"; import {Delay} from "app/client/lib/Delay";
import {KoArray} from 'app/client/lib/koArray'; import {KoArray} from 'app/client/lib/koArray';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec'; import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';

View File

@ -99,19 +99,19 @@ ViewConfigTab.prototype._makeOnDemand = function(table) {
} }
if (table.onDemand()) { if (table.onDemand()) {
confirmModal('Unmark table On-Demand?', 'Unmark On-Demand', onConfirm, confirmModal('Unmark table On-Demand?', 'Unmark On-Demand', onConfirm, {
dom('div', 'If you unmark table ', dom('b', table), ' as On-Demand, ' + explanation: dom('div', 'If you unmark table ', dom('b', table), ' as On-Demand, ' +
'its data will be loaded into the calculation engine and will be available ' + 'its data will be loaded into the calculation engine and will be available ' +
'for use in formulas. For a big table, this may greatly increase load times.', 'for use in formulas. For a big table, this may greatly increase load times.',
dom('br'), dom('br'), 'Changing this setting will reload the document for all users.') dom('br'), dom('br'), 'Changing this setting will reload the document for all users.'),
); });
} else { } else {
confirmModal('Make table On-Demand?', 'Make On-Demand', onConfirm, confirmModal('Make table On-Demand?', 'Make On-Demand', onConfirm, {
dom('div', 'If you make table ', dom('b', table), ' On-Demand, ' + explanation: dom('div', 'If you make table ', dom('b', table), ' On-Demand, ' +
'its data will no longer be loaded into the calculation engine and will not be available ' + 'its data will no longer be loaded into the calculation engine and will not be available ' +
'for use in formulas. It will remain available for viewing and editing.', 'for use in formulas. It will remain available for viewing and editing.',
dom('br'), dom('br'), 'Changing this setting will reload the document for all users.') dom('br'), dom('br'), 'Changing this setting will reload the document for all users.'),
); });
} }
}; };

View File

@ -59,6 +59,9 @@ export type CommandName =
| 'copy' | 'copy'
| 'cut' | 'cut'
| 'paste' | 'paste'
| 'contextMenuCopy'
| 'contextMenuCut'
| 'contextMenuPaste'
| 'fillSelectionDown' | 'fillSelectionDown'
| 'clearValues' | 'clearValues'
| 'input' | 'input'
@ -80,9 +83,11 @@ export type CommandName =
| 'insertFieldAfter' | 'insertFieldAfter'
| 'renameField' | 'renameField'
| 'hideFields' | 'hideFields'
| 'hideCardFields'
| 'toggleFreeze' | 'toggleFreeze'
| 'deleteFields' | 'deleteFields'
| 'clearColumns' | 'clearColumns'
| 'clearCardFields'
| 'convertFormulasToData' | 'convertFormulasToData'
| 'addSection' | 'addSection'
| 'deleteSection' | 'deleteSection'
@ -102,6 +107,7 @@ export type CommandName =
| 'clearLinks' | 'clearLinks'
| 'clearSectionLinks' | 'clearSectionLinks'
| 'transformUpdate' | 'transformUpdate'
| 'clearCopySelection'
; ;
@ -109,6 +115,7 @@ export interface CommandDef {
name: CommandName; name: CommandName;
keys: string[]; keys: string[];
desc: string | null; desc: string | null;
bindKeys?: boolean;
deprecated?: boolean; deprecated?: boolean;
} }
@ -367,6 +374,10 @@ export const groups: CommendGroupDef[] = [{
name: 'copyLink', name: 'copyLink',
keys: ['Mod+Shift+A'], keys: ['Mod+Shift+A'],
desc: 'Copy anchor link' desc: 'Copy anchor link'
}, {
name: 'clearCopySelection',
keys: [],
desc: 'Clears the current copy selection, if any'
} }
], ],
}, { }, {
@ -399,7 +410,22 @@ export const groups: CommendGroupDef[] = [{
}, { }, {
name: 'paste', name: 'paste',
keys: [], keys: [],
desc: 'Paste clipboard contents at cursor' desc: 'Paste clipboard contents at cursor',
}, {
name: 'contextMenuCopy',
keys: ['Mod+C'],
desc: 'Copy current selection to clipboard',
bindKeys: false,
}, {
name: 'contextMenuCut',
keys: ['Mod+X'],
desc: 'Cut current selection to clipboard',
bindKeys: false,
}, {
name: 'contextMenuPaste',
keys: ['Mod+V'],
desc: 'Paste clipboard contents at cursor',
bindKeys: false,
}, { }, {
name: 'fillSelectionDown', name: 'fillSelectionDown',
keys: ['Mod+D'], keys: ['Mod+D'],
@ -489,6 +515,10 @@ export const groups: CommendGroupDef[] = [{
name: 'hideFields', name: 'hideFields',
keys: ['Alt+Shift+-'], keys: ['Alt+Shift+-'],
desc: 'Hide currently selected columns' desc: 'Hide currently selected columns'
}, {
name: 'hideCardFields',
keys: [],
desc: 'Hide currently selected fields'
}, { }, {
name: 'toggleFreeze', name: 'toggleFreeze',
keys: [], keys: [],
@ -501,6 +531,10 @@ export const groups: CommendGroupDef[] = [{
name: 'clearColumns', name: 'clearColumns',
keys: [], keys: [],
desc: 'Clear the selected columns' desc: 'Clear the selected columns'
}, {
name: 'clearCardFields',
keys: [],
desc: 'Clear the selected fields'
}, { }, {
name: 'convertFormulasToData', name: 'convertFormulasToData',
keys: [], keys: [],

View File

@ -22,7 +22,7 @@ const G = getBrowserGlobals('window');
type BoolLike = boolean|ko.Observable<boolean>|ko.Computed<boolean>; type BoolLike = boolean|ko.Observable<boolean>|ko.Computed<boolean>;
// Same logic as used by mousetrap to map 'Mod' key to platform-specific key. // Same logic as used by mousetrap to map 'Mod' key to platform-specific key.
const isMac = (typeof navigator !== 'undefined' && navigator && export const isMac = (typeof navigator !== 'undefined' && navigator &&
/Mac|iPod|iPhone|iPad/.test(navigator.platform)); /Mac|iPod|iPhone|iPad/.test(navigator.platform));
/** /**
@ -62,7 +62,10 @@ export function init(optCommandGroups?: CommendGroupDef[]) {
if (allCommands[c.name]) { if (allCommands[c.name]) {
console.error("Ignoring duplicate command %s in commandList", c.name); console.error("Ignoring duplicate command %s in commandList", c.name);
} else { } else {
allCommands[c.name] = new Command(c.name, c.desc, c.keys, c.deprecated); allCommands[c.name] = new Command(c.name, c.desc, c.keys, {
bindKeys: c.bindKeys,
deprecated: c.deprecated,
});
} }
}); });
}); });
@ -95,7 +98,7 @@ const KEY_MAP_WIN = {
Down: '↓', Down: '↓',
}; };
function getHumanKey(key: string, mac: boolean): string { export function getHumanKey(key: string, mac: boolean): string {
const keyMap = mac ? KEY_MAP_MAC : KEY_MAP_WIN; const keyMap = mac ? KEY_MAP_MAC : KEY_MAP_WIN;
let keys = key.split('+').map(s => s.trim()); let keys = key.split('+').map(s => s.trim());
keys = keys.map(k => { keys = keys.map(k => {
@ -106,6 +109,11 @@ function getHumanKey(key: string, mac: boolean): string {
return keys.join( mac ? '' : ' + '); return keys.join( mac ? '' : ' + ');
} }
export interface CommandOptions {
bindKeys?: boolean;
deprecated?: boolean;
}
/** /**
* Command represents a single command. It is exposed via the `allCommands` map. * Command represents a single command. It is exposed via the `allCommands` map.
* @property {String} name: The name of the command, same as the key into the `allCommands` map. * @property {String} name: The name of the command, same as the key into the `allCommands` map.
@ -119,21 +127,23 @@ export class Command implements CommandDef {
public desc: string|null; public desc: string|null;
public humanKeys: string[]; public humanKeys: string[];
public keys: string[]; public keys: string[];
public bindKeys: boolean;
public isActive: ko.Observable<boolean>; public isActive: ko.Observable<boolean>;
public deprecated: boolean; public deprecated: boolean;
public run: (...args: any[]) => any; public run: (...args: any[]) => any;
private _implGroupStack: CommandGroup[] = []; private _implGroupStack: CommandGroup[] = [];
private _activeFunc: (...args: any[]) => any = _.noop; private _activeFunc: (...args: any[]) => any = _.noop;
constructor(name: CommandName, desc: string|null, keys: string[], deprecated?: boolean) { constructor(name: CommandName, desc: string|null, keys: string[], options: CommandOptions = {}) {
this.name = name; this.name = name;
this.desc = desc; this.desc = desc;
this.humanKeys = keys.map(key => getHumanKey(key, isMac)); this.humanKeys = keys.map(key => getHumanKey(key, isMac));
this.keys = keys.map(function(k) { return k.trim().toLowerCase().replace(/ *\+ */g, '+'); }); this.keys = keys.map(function(k) { return k.trim().toLowerCase().replace(/ *\+ */g, '+'); });
this.bindKeys = options.bindKeys ?? true;
this.isActive = ko.observable(false); this.isActive = ko.observable(false);
this._implGroupStack = []; this._implGroupStack = [];
this._activeFunc = _.noop; // The function to run when this command is invoked. this._activeFunc = _.noop; // The function to run when this command is invoked.
this.deprecated = deprecated || false; this.deprecated = options.deprecated || false;
// Let .run bind the Command object, so that it can be used as a stand-alone callback. // Let .run bind the Command object, so that it can be used as a stand-alone callback.
this.run = this._run.bind(this); this.run = this._run.bind(this);
} }
@ -192,19 +202,21 @@ export class Command implements CommandDef {
this._activeFunc = _.noop; this._activeFunc = _.noop;
} }
// Now bind or unbind the affected key combinations. if (this.bindKeys) {
this.keys.forEach(function(key) { // Now bind or unbind the affected key combinations.
const keyGroups = _allKeys[key]; this.keys.forEach(function(key) {
if (keyGroups && keyGroups.length > 0) { const keyGroups = _allKeys[key];
const commandGroup = _.last(keyGroups)!; if (keyGroups && keyGroups.length > 0) {
// Command name might be different from this.name in case we are deactivating a command, and const commandGroup = _.last(keyGroups)!;
// the previous meaning of the key points to a different command. // Command name might be different from this.name in case we are deactivating a command, and
const commandName = commandGroup.knownKeys[key]; // the previous meaning of the key points to a different command.
Mousetrap.bind(key, wrapKeyCallback(commandGroup.commands[commandName])); const commandName = commandGroup.knownKeys[key];
} else { Mousetrap.bind(key, wrapKeyCallback(commandGroup.commands[commandName]));
Mousetrap.unbind(key); } else {
} Mousetrap.unbind(key);
}); }
});
}
} }
private _run(...args: any[]) { private _run(...args: any[]) {

View File

@ -3,9 +3,20 @@ import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
const G = getBrowserGlobals('document', 'window'); const G = getBrowserGlobals('document', 'window');
/** /**
* Copy some text to the clipboard, by hook or by crook. * Copy text or data to the clipboard.
*/ */
export async function copyToClipboard(txt: string) { export async function copyToClipboard(data: string | ClipboardItem) {
if (typeof data === 'string') {
await copyTextToClipboard(data);
} else {
await copyDataToClipboard(data);
}
}
/**
* Copy text to the clipboard.
*/
async function copyTextToClipboard(txt: string) {
// If present and we have permission to use it, the navigator.clipboard interface // If present and we have permission to use it, the navigator.clipboard interface
// is convenient. This method works in non-headless tests, and regular chrome // is convenient. This method works in non-headless tests, and regular chrome
// and firefox. // and firefox.
@ -36,3 +47,36 @@ export async function copyToClipboard(txt: string) {
G.document.getSelection().addRange(selection); G.document.getSelection().addRange(selection);
} }
} }
/**
* Copy data to the clipboard.
*/
async function copyDataToClipboard(data: ClipboardItem) {
if (!G.window.navigator?.clipboard?.write) {
throw new Error('navigator.clipboard.write is not supported on this browser');
}
await G.window.navigator.clipboard.write([data]);
}
/**
* Read text from the clipboard.
*/
export function readTextFromClipboard(): Promise<string> {
if (!G.window.navigator?.clipboard?.readText) {
throw new Error('navigator.clipboard.readText is not supported on this browser');
}
return G.window.navigator.clipboard.readText();
}
/**
* Read data from the clipboard.
*/
export function readDataFromClipboard(): Promise<ClipboardItem[]> {
if (!G.window.navigator?.clipboard?.read) {
throw new Error('navigator.clipboard.read is not supported on this browser');
}
return G.window.navigator.clipboard.read();
}

View File

@ -32,9 +32,10 @@ export function CellContextMenu(rowOptions: IRowContextMenu, colOptions: IMultiC
const result: Array<Element|null> = []; const result: Array<Element|null> = [];
result.push( result.push(
menuItemCmd(allCommands.contextMenuCut, t('Cut'), disableForReadonlyColumn),
// TODO: implement copy/paste actions menuItemCmd(allCommands.contextMenuCopy, t('Copy')),
menuItemCmd(allCommands.contextMenuPaste, t('Paste'), disableForReadonlyColumn),
menuDivider(),
colOptions.isFormula ? colOptions.isFormula ?
null : null :
menuItemCmd(allCommands.clearValues, nameClearCells, disableForReadonlyColumn), menuItemCmd(allCommands.clearValues, nameClearCells, disableForReadonlyColumn),
@ -46,7 +47,7 @@ export function CellContextMenu(rowOptions: IRowContextMenu, colOptions: IMultiC
menuItemCmd(allCommands.copyLink, t("Copy anchor link")), menuItemCmd(allCommands.copyLink, t("Copy anchor link")),
menuDivider(), menuDivider(),
menuItemCmd(allCommands.filterByThisCellValue, t("Filter by this value")), menuItemCmd(allCommands.filterByThisCellValue, t("Filter by this value")),
menuItemCmd(allCommands.openDiscussion, 'Comment', dom.cls('disabled', ( menuItemCmd(allCommands.openDiscussion, t('Comment'), dom.cls('disabled', (
isReadonly || numRows === 0 || numCols === 0 isReadonly || numRows === 0 || numCols === 0
)), dom.hide(use => !use(COMMENTS()))) //TODO: i18next )), dom.hide(use => !use(COMMENTS()))) //TODO: i18next
] ]

View File

@ -1,6 +1,6 @@
import * as Clipboard from 'app/client/components/Clipboard'; import * as Clipboard from 'app/client/components/Clipboard';
import * as commands from 'app/client/components/commands'; import * as commands from 'app/client/components/commands';
import {copyToClipboard} from 'app/client/lib/copyToClipboard'; import {copyToClipboard} from 'app/client/lib/clipboardUtils';
import {FocusLayer} from 'app/client/lib/FocusLayer'; import {FocusLayer} from 'app/client/lib/FocusLayer';
import {makeT} from 'app/client/lib/localization'; import {makeT} from 'app/client/lib/localization';
import {setTestState} from 'app/client/lib/testState'; import {setTestState} from 'app/client/lib/testState';

View File

@ -5,7 +5,7 @@
import {GristDoc} from 'app/client/components/GristDoc'; import {GristDoc} from 'app/client/components/GristDoc';
import {ACIndexImpl} from 'app/client/lib/ACIndex'; import {ACIndexImpl} from 'app/client/lib/ACIndex';
import {ACSelectItem, buildACSelect} from 'app/client/lib/ACSelect'; import {ACSelectItem, buildACSelect} from 'app/client/lib/ACSelect';
import {copyToClipboard} from 'app/client/lib/copyToClipboard'; import {copyToClipboard} from 'app/client/lib/clipboardUtils';
import {makeT} from 'app/client/lib/localization'; import {makeT} from 'app/client/lib/localization';
import {reportError} from 'app/client/models/AppModel'; import {reportError} from 'app/client/models/AppModel';
import {urlState} from 'app/client/models/gristUrlState'; import {urlState} from 'app/client/models/gristUrlState';

View File

@ -0,0 +1,27 @@
import {allCommands} from 'app/client/components/commands';
import {makeT} from 'app/client/lib/localization';
import {IRowContextMenu} from 'app/client/ui/RowContextMenu';
import {menuDivider, menuItemCmd} from 'app/client/ui2018/menus';
import {dom} from 'grainjs';
const t = makeT('FieldContextMenu');
export interface IFieldContextMenu {
disableModify: boolean;
isReadonly: boolean;
}
export function FieldContextMenu(_rowOptions: IRowContextMenu, fieldOptions: IFieldContextMenu) {
const {disableModify, isReadonly} = fieldOptions;
const disableForReadonlyColumn = dom.cls('disabled', disableModify || isReadonly);
return [
menuItemCmd(allCommands.contextMenuCut, t('Cut'), disableForReadonlyColumn),
menuItemCmd(allCommands.contextMenuCopy, t('Copy')),
menuItemCmd(allCommands.contextMenuPaste, t('Paste'), disableForReadonlyColumn),
menuDivider(),
menuItemCmd(allCommands.clearCardFields, t('Clear field'), disableForReadonlyColumn),
menuItemCmd(allCommands.hideCardFields, t('Hide field')),
menuDivider(),
menuItemCmd(allCommands.copyLink, t('Copy anchor link')),
];
}

View File

@ -14,7 +14,7 @@ import {Computed, Disposable, dom, DomElementArg, Observable, observable, styled
import pick = require('lodash/pick'); import pick = require('lodash/pick');
import {ACIndexImpl, normalizeText} from 'app/client/lib/ACIndex'; import {ACIndexImpl, normalizeText} from 'app/client/lib/ACIndex';
import {copyToClipboard} from 'app/client/lib/copyToClipboard'; import {copyToClipboard} from 'app/client/lib/clipboardUtils';
import {setTestState} from 'app/client/lib/testState'; import {setTestState} from 'app/client/lib/testState';
import {buildMultiUserManagerModal} from 'app/client/lib/MultiUserManager'; import {buildMultiUserManagerModal} from 'app/client/lib/MultiUserManager';
import {ACUserItem, buildACMemberEmail} from 'app/client/lib/ACUserManager'; import {ACUserItem, buildACMemberEmail} from 'app/client/lib/ACUserManager';

View File

@ -33,7 +33,21 @@ setOptionsModifyFunc(({chromeOpts, firefoxOpts}) => {
// Don't show popups to save passwords, which are shown when running against a deployment when // Don't show popups to save passwords, which are shown when running against a deployment when
// we use a login form. // we use a login form.
"credentials_enable_service": false, "credentials_enable_service": false,
"profile.password_manager_enabled" : false, "profile": {
content_settings: {
exceptions: {
clipboard: {
'*': {
// Grant access to the system clipboard. This applies to regular (non-headless)
// Chrome. On headless Chrome, this has no effect.
setting: 1,
}
},
},
},
// Don't show popups to save passwords.
password_manager_enabled: false,
},
// These preferences are my best effort to set up "print to pdf" that saves into the test's temp // These preferences are my best effort to set up "print to pdf" that saves into the test's temp
// dir, based on discussion here: https://bugs.chromium.org/p/chromedriver/issues/detail?id=2821. // dir, based on discussion here: https://bugs.chromium.org/p/chromedriver/issues/detail?id=2821.