From 18ad39cba330a36d8681d8bbe454eff9753c1f13 Mon Sep 17 00:00:00 2001 From: George Gevoian Date: Fri, 28 Apr 2023 02:20:28 -0700 Subject: [PATCH] (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 --- app/client/components/BaseView.js | 10 +- app/client/components/Clipboard.js | 227 +++++++++++++++--- app/client/components/DataTables.ts | 2 +- app/client/components/DetailView.js | 102 +++++++- app/client/components/GridView.js | 7 +- app/client/components/RecordLayout.js | 9 +- app/client/components/SelectionSummary.ts | 2 +- app/client/components/ViewConfigTab.js | 16 +- app/client/components/commandList.ts | 36 ++- app/client/components/commands.ts | 48 ++-- .../{copyToClipboard.ts => clipboardUtils.ts} | 48 +++- app/client/ui/CellContextMenu.ts | 9 +- app/client/ui/ColumnTitle.ts | 2 +- app/client/ui/DocumentSettings.ts | 2 +- app/client/ui/FieldContextMenu.ts | 27 +++ app/client/ui/UserManager.ts | 2 +- test/nbrowser/testUtils.ts | 16 +- 17 files changed, 468 insertions(+), 97 deletions(-) rename app/client/lib/{copyToClipboard.ts => clipboardUtils.ts} (53%) create mode 100644 app/client/ui/FieldContextMenu.ts diff --git a/app/client/components/BaseView.js b/app/client/components/BaseView.js index 0c9c6f55..df4c1518 100644 --- a/app/client/components/BaseView.js +++ b/app/client/components/BaseView.js @@ -22,7 +22,7 @@ const {ClientColumnGetters} = require('app/client/models/ClientColumnGetters'); const {reportError, reportSuccess} = require('app/client/models/errors'); const {urlState} = require('app/client/models/gristUrlState'); 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 {ExtraRows} = require('app/client/models/DataTableModelWithDiff'); const {createFilterMenu} = require('app/client/ui/ColumnFilterMenu'); @@ -153,7 +153,7 @@ function BaseView(gristDoc, viewSectionModel, options) { 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.viewSection.disableAddRemoveRows() && !this.disableEditing())); @@ -602,6 +602,9 @@ BaseView.prototype._saveEditRowField = function(editRowModel, colName, value) { * @returns {pasteObj} - Paste object */ BaseView.prototype.copy = function(selection) { + // Clear the previous copy selection, if any. + commands.allCommands.clearCopySelection.run(); + this.copySelection(selection); return { @@ -617,6 +620,9 @@ BaseView.prototype.copy = function(selection) { * @returns {pasteObj} - Paste object */ BaseView.prototype.cut = function(selection) { + // Clear the previous copy selection, if any. + commands.allCommands.clearCopySelection.run(); + this.copySelection(selection); return { diff --git a/app/client/components/Clipboard.js b/app/client/components/Clipboard.js index 9dabcaea..c196cd81 100644 --- a/app/client/components/Clipboard.js +++ b/app/client/components/Clipboard.js @@ -34,14 +34,23 @@ /* 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 {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; @@ -91,9 +100,17 @@ function Clipboard(app) { // 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(); }, + 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. @@ -106,6 +123,12 @@ Clipboard.prototype._onCopy = function(elem, event) { 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) { event.preventDefault(); @@ -114,20 +137,63 @@ Clipboard.prototype._onCut = function(elem, event) { 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; } - 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); + 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 = plainText; + this._cutData = cutData; } else { this._cutCallback = null; this._cutData = null; @@ -140,36 +206,11 @@ Clipboard.prototype._setCBdata = function(pasteObj, clipboardData) { */ 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").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; + 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 = { @@ -179,6 +220,72 @@ var FOCUS_TARGET_TAGS = { '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 @@ -192,4 +299,48 @@ function allowFocus(elem) { 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; +`); diff --git a/app/client/components/DataTables.ts b/app/client/components/DataTables.ts index 6696e1e5..c606464f 100644 --- a/app/client/components/DataTables.ts +++ b/app/client/components/DataTables.ts @@ -1,5 +1,5 @@ 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 {TableRec} from 'app/client/models/DocModel'; import {docListHeader, docMenuTrigger} from 'app/client/ui/DocMenuCss'; diff --git a/app/client/components/DetailView.js b/app/client/components/DetailView.js index a869c6bb..75f07567 100644 --- a/app/client/components/DetailView.js +++ b/app/client/components/DetailView.js @@ -11,9 +11,12 @@ require('app/client/lib/koUtil'); // Needed for subscribeInit. const Base = require('./Base'); const BaseView = require('./BaseView'); +const selector = require('./CellSelector'); const {CopySelection} = require('./CopySelection'); const RecordLayout = require('./RecordLayout'); const commands = require('./commands'); +const tableUtil = require('../lib/tableUtil'); +const {FieldContextMenu} = require('../ui/FieldContextMenu'); const {RowContextMenu} = require('../ui/RowContextMenu'); const {parsePasteForView} = require("./BaseView2"); const {columnInfoTooltip} = require("../ui/tooltips"); @@ -25,6 +28,8 @@ const {columnInfoTooltip} = require("../ui/tooltips"); function DetailView(gristDoc, viewSectionModel) { BaseView.call(this, gristDoc, viewSectionModel, { 'addNewRow': true }); + this.cellSelector = selector.CellSelector.create(this, this); + this.viewFields = gristDoc.docModel.viewFields; this._isSingle = (this.viewSection.parentKey.peek() === 'single'); @@ -33,7 +38,8 @@ function DetailView(gristDoc, viewSectionModel) { this.recordLayout = this.autoDispose(RecordLayout.create({ viewSection: this.viewSection, buildFieldDom: this.buildFieldDom.bind(this), - buildContextMenu : this.buildContextMenu.bind(this), + buildRowContextMenu : this.buildRowContextMenu.bind(this), + buildFieldContextMenu : this.buildFieldContextMenu.bind(this), resizeCallback: () => { if (!this._isSingle) { this.scrolly().updateSize(); @@ -109,8 +115,10 @@ function DetailView(gristDoc, viewSectionModel) { //-------------------------------------------------- // Instantiate CommandGroups for the different modes. this.autoDispose(commands.createGroup(DetailView.generalCommands, this, this.viewSection.hasFocus)); - this.newFieldCommandGroup = this.autoDispose( - commands.createGroup(DetailView.newFieldCommands, this, this.isNewFieldActive)); + this.autoDispose(commands.createGroup(DetailView.fieldCommands, this, this.viewSection.hasFocus)); + const hasSelection = this.autoDispose(ko.pureComputed(() => + !this.cellSelector.isCurrentSelectType('') || this.copySelection())); + this.autoDispose(commands.createGroup(DetailView.selectionCommands, this, hasSelection)); } Base.setBaseFor(DetailView); _.extend(DetailView.prototype, BaseView.prototype); @@ -151,7 +159,17 @@ DetailView.generalCommands = { this.scrolly().scrollRowIntoView(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); // Restore the cursor to the right rowId, even if it jumped. 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) { - const defaults = { - 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, - }; - return RowContextMenu(options ? Object.assign(defaults, options) : defaults); +DetailView.prototype.buildRowContextMenu = function(row) { + const rowOptions = this._getRowContextMenuOptions(row); + return RowContextMenu(rowOptions); +} + +DetailView.prototype.buildFieldContextMenu = function(row) { + const rowOptions = this._getRowContextMenuOptions(row); + const fieldOptions = this._getFieldContextMenuOptions(); + return FieldContextMenu(rowOptions, fieldOptions); } /** @@ -463,4 +482,61 @@ DetailView.prototype._canSingleClick = function(field) { 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; diff --git a/app/client/components/GridView.js b/app/client/components/GridView.js index b35089cf..993981dc 100644 --- a/app/client/components/GridView.js +++ b/app/client/components/GridView.js @@ -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 // the Grid popup). GridView.selectionCommands = { + clearCopySelection: function() { this._clearCopySelection(); }, cancel: function() { this.clearSelection(); } } @@ -455,7 +456,7 @@ GridView.prototype.paste = async function(data, cutCallback) { 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) { return ko.computed(() => { if (owner.isDisposed()) { return null; } diff --git a/app/client/components/RecordLayout.js b/app/client/components/RecordLayout.js index dcc730fa..17941327 100644 --- a/app/client/components/RecordLayout.js +++ b/app/client/components/RecordLayout.js @@ -54,7 +54,8 @@ const t = makeT('RecordLayout'); function RecordLayout(options) { this.viewSection = options.viewSection; this.buildFieldDom = options.buildFieldDom; - this.buildContextMenu = options.buildContextMenu; + this.buildRowContextMenu = options.buildRowContextMenu; + this.buildFieldContextMenu = options.buildFieldContextMenu; this.isEditingLayout = ko.observable(false); this.editIndex = ko.observable(0); 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(null); }) : null, - // enables row context menu anywhere on the card - contextMenu(() => this.buildContextMenu(row)), + // enables field context menu anywhere on the card + contextMenu(() => this.buildFieldContextMenu(row)), dom('div.detail_row_num', kd.text(() => (row._index() + 1)), dom.on('contextmenu', ev => { @@ -357,7 +358,7 @@ RecordLayout.prototype.buildLayoutDom = function(row, optCreateEditor) { this.viewSection.hasFocus(true); commands.allCommands.setCursor.run(row); }), - menu(() => this.buildContextMenu(row)), + menu(() => this.buildRowContextMenu(row)), testId('card-menu-trigger') ) ), diff --git a/app/client/components/SelectionSummary.ts b/app/client/components/SelectionSummary.ts index 15610e6c..2fd3ef04 100644 --- a/app/client/components/SelectionSummary.ts +++ b/app/client/components/SelectionSummary.ts @@ -1,5 +1,5 @@ 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 {KoArray} from 'app/client/lib/koArray'; import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec'; diff --git a/app/client/components/ViewConfigTab.js b/app/client/components/ViewConfigTab.js index 9202f636..de8057a6 100644 --- a/app/client/components/ViewConfigTab.js +++ b/app/client/components/ViewConfigTab.js @@ -99,19 +99,19 @@ ViewConfigTab.prototype._makeOnDemand = function(table) { } if (table.onDemand()) { - confirmModal('Unmark table On-Demand?', 'Unmark On-Demand', onConfirm, - dom('div', 'If you unmark table ', dom('b', table), ' as On-Demand, ' + + confirmModal('Unmark table On-Demand?', 'Unmark On-Demand', onConfirm, { + 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 ' + '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 { - confirmModal('Make table On-Demand?', 'Make On-Demand', onConfirm, - dom('div', 'If you make table ', dom('b', table), ' On-Demand, ' + + confirmModal('Make table On-Demand?', 'Make On-Demand', onConfirm, { + 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 ' + '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.'), + }); } }; diff --git a/app/client/components/commandList.ts b/app/client/components/commandList.ts index 09b88142..21d1d921 100644 --- a/app/client/components/commandList.ts +++ b/app/client/components/commandList.ts @@ -59,6 +59,9 @@ export type CommandName = | 'copy' | 'cut' | 'paste' + | 'contextMenuCopy' + | 'contextMenuCut' + | 'contextMenuPaste' | 'fillSelectionDown' | 'clearValues' | 'input' @@ -80,9 +83,11 @@ export type CommandName = | 'insertFieldAfter' | 'renameField' | 'hideFields' + | 'hideCardFields' | 'toggleFreeze' | 'deleteFields' | 'clearColumns' + | 'clearCardFields' | 'convertFormulasToData' | 'addSection' | 'deleteSection' @@ -102,6 +107,7 @@ export type CommandName = | 'clearLinks' | 'clearSectionLinks' | 'transformUpdate' + | 'clearCopySelection' ; @@ -109,6 +115,7 @@ export interface CommandDef { name: CommandName; keys: string[]; desc: string | null; + bindKeys?: boolean; deprecated?: boolean; } @@ -367,6 +374,10 @@ export const groups: CommendGroupDef[] = [{ name: 'copyLink', keys: ['Mod+Shift+A'], 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', 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', keys: ['Mod+D'], @@ -489,6 +515,10 @@ export const groups: CommendGroupDef[] = [{ name: 'hideFields', keys: ['Alt+Shift+-'], desc: 'Hide currently selected columns' + }, { + name: 'hideCardFields', + keys: [], + desc: 'Hide currently selected fields' }, { name: 'toggleFreeze', keys: [], @@ -501,6 +531,10 @@ export const groups: CommendGroupDef[] = [{ name: 'clearColumns', keys: [], desc: 'Clear the selected columns' + }, { + name: 'clearCardFields', + keys: [], + desc: 'Clear the selected fields' }, { name: 'convertFormulasToData', keys: [], diff --git a/app/client/components/commands.ts b/app/client/components/commands.ts index 4042ac12..84275282 100644 --- a/app/client/components/commands.ts +++ b/app/client/components/commands.ts @@ -22,7 +22,7 @@ const G = getBrowserGlobals('window'); type BoolLike = boolean|ko.Observable|ko.Computed; // 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)); /** @@ -62,7 +62,10 @@ export function init(optCommandGroups?: CommendGroupDef[]) { if (allCommands[c.name]) { console.error("Ignoring duplicate command %s in commandList", c.name); } 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: '↓', }; -function getHumanKey(key: string, mac: boolean): string { +export function getHumanKey(key: string, mac: boolean): string { const keyMap = mac ? KEY_MAP_MAC : KEY_MAP_WIN; let keys = key.split('+').map(s => s.trim()); keys = keys.map(k => { @@ -106,6 +109,11 @@ function getHumanKey(key: string, mac: boolean): string { return keys.join( mac ? '' : ' + '); } +export interface CommandOptions { + bindKeys?: boolean; + deprecated?: boolean; +} + /** * 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. @@ -119,21 +127,23 @@ export class Command implements CommandDef { public desc: string|null; public humanKeys: string[]; public keys: string[]; + public bindKeys: boolean; public isActive: ko.Observable; public deprecated: boolean; public run: (...args: any[]) => any; private _implGroupStack: CommandGroup[] = []; 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.desc = desc; this.humanKeys = keys.map(key => getHumanKey(key, isMac)); this.keys = keys.map(function(k) { return k.trim().toLowerCase().replace(/ *\+ */g, '+'); }); + this.bindKeys = options.bindKeys ?? true; this.isActive = ko.observable(false); this._implGroupStack = []; 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. this.run = this._run.bind(this); } @@ -192,19 +202,21 @@ export class Command implements CommandDef { this._activeFunc = _.noop; } - // Now bind or unbind the affected key combinations. - this.keys.forEach(function(key) { - const keyGroups = _allKeys[key]; - if (keyGroups && keyGroups.length > 0) { - const commandGroup = _.last(keyGroups)!; - // Command name might be different from this.name in case we are deactivating a command, and - // the previous meaning of the key points to a different command. - const commandName = commandGroup.knownKeys[key]; - Mousetrap.bind(key, wrapKeyCallback(commandGroup.commands[commandName])); - } else { - Mousetrap.unbind(key); - } - }); + if (this.bindKeys) { + // Now bind or unbind the affected key combinations. + this.keys.forEach(function(key) { + const keyGroups = _allKeys[key]; + if (keyGroups && keyGroups.length > 0) { + const commandGroup = _.last(keyGroups)!; + // Command name might be different from this.name in case we are deactivating a command, and + // the previous meaning of the key points to a different command. + const commandName = commandGroup.knownKeys[key]; + Mousetrap.bind(key, wrapKeyCallback(commandGroup.commands[commandName])); + } else { + Mousetrap.unbind(key); + } + }); + } } private _run(...args: any[]) { diff --git a/app/client/lib/copyToClipboard.ts b/app/client/lib/clipboardUtils.ts similarity index 53% rename from app/client/lib/copyToClipboard.ts rename to app/client/lib/clipboardUtils.ts index df200856..eed1c9a4 100644 --- a/app/client/lib/copyToClipboard.ts +++ b/app/client/lib/clipboardUtils.ts @@ -3,9 +3,20 @@ import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals'; 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 // is convenient. This method works in non-headless tests, and regular chrome // and firefox. @@ -36,3 +47,36 @@ export async function copyToClipboard(txt: string) { 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 { + 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 { + if (!G.window.navigator?.clipboard?.read) { + throw new Error('navigator.clipboard.read is not supported on this browser'); + } + + return G.window.navigator.clipboard.read(); +} diff --git a/app/client/ui/CellContextMenu.ts b/app/client/ui/CellContextMenu.ts index 5bd87de3..7952cd82 100644 --- a/app/client/ui/CellContextMenu.ts +++ b/app/client/ui/CellContextMenu.ts @@ -32,9 +32,10 @@ export function CellContextMenu(rowOptions: IRowContextMenu, colOptions: IMultiC const result: Array = []; result.push( - - // TODO: implement copy/paste actions - + menuItemCmd(allCommands.contextMenuCut, t('Cut'), disableForReadonlyColumn), + menuItemCmd(allCommands.contextMenuCopy, t('Copy')), + menuItemCmd(allCommands.contextMenuPaste, t('Paste'), disableForReadonlyColumn), + menuDivider(), colOptions.isFormula ? null : menuItemCmd(allCommands.clearValues, nameClearCells, disableForReadonlyColumn), @@ -46,7 +47,7 @@ export function CellContextMenu(rowOptions: IRowContextMenu, colOptions: IMultiC menuItemCmd(allCommands.copyLink, t("Copy anchor link")), menuDivider(), 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 )), dom.hide(use => !use(COMMENTS()))) //TODO: i18next ] diff --git a/app/client/ui/ColumnTitle.ts b/app/client/ui/ColumnTitle.ts index 8626b6d7..28205460 100644 --- a/app/client/ui/ColumnTitle.ts +++ b/app/client/ui/ColumnTitle.ts @@ -1,6 +1,6 @@ import * as Clipboard from 'app/client/components/Clipboard'; 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 {makeT} from 'app/client/lib/localization'; import {setTestState} from 'app/client/lib/testState'; diff --git a/app/client/ui/DocumentSettings.ts b/app/client/ui/DocumentSettings.ts index 41606b0b..b368bf5f 100644 --- a/app/client/ui/DocumentSettings.ts +++ b/app/client/ui/DocumentSettings.ts @@ -5,7 +5,7 @@ import {GristDoc} from 'app/client/components/GristDoc'; import {ACIndexImpl} from 'app/client/lib/ACIndex'; 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 {reportError} from 'app/client/models/AppModel'; import {urlState} from 'app/client/models/gristUrlState'; diff --git a/app/client/ui/FieldContextMenu.ts b/app/client/ui/FieldContextMenu.ts new file mode 100644 index 00000000..68b3735f --- /dev/null +++ b/app/client/ui/FieldContextMenu.ts @@ -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')), + ]; +} diff --git a/app/client/ui/UserManager.ts b/app/client/ui/UserManager.ts index 05b19fac..f4f55a54 100644 --- a/app/client/ui/UserManager.ts +++ b/app/client/ui/UserManager.ts @@ -14,7 +14,7 @@ import {Computed, Disposable, dom, DomElementArg, Observable, observable, styled import pick = require('lodash/pick'); 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 {buildMultiUserManagerModal} from 'app/client/lib/MultiUserManager'; import {ACUserItem, buildACMemberEmail} from 'app/client/lib/ACUserManager'; diff --git a/test/nbrowser/testUtils.ts b/test/nbrowser/testUtils.ts index 37caa780..0483d2d3 100644 --- a/test/nbrowser/testUtils.ts +++ b/test/nbrowser/testUtils.ts @@ -33,7 +33,21 @@ setOptionsModifyFunc(({chromeOpts, firefoxOpts}) => { // Don't show popups to save passwords, which are shown when running against a deployment when // we use a login form. "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 // dir, based on discussion here: https://bugs.chromium.org/p/chromedriver/issues/detail?id=2821.