mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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:
parent
c6ec8339f5
commit
18ad39cba3
@ -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 {
|
||||||
|
@ -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;
|
||||||
|
`);
|
||||||
|
@ -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';
|
||||||
|
@ -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;
|
||||||
|
@ -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; }
|
||||||
|
@ -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')
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
@ -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';
|
||||||
|
@ -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.'),
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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: [],
|
||||||
|
@ -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,6 +202,7 @@ export class Command implements CommandDef {
|
|||||||
this._activeFunc = _.noop;
|
this._activeFunc = _.noop;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.bindKeys) {
|
||||||
// Now bind or unbind the affected key combinations.
|
// Now bind or unbind the affected key combinations.
|
||||||
this.keys.forEach(function(key) {
|
this.keys.forEach(function(key) {
|
||||||
const keyGroups = _allKeys[key];
|
const keyGroups = _allKeys[key];
|
||||||
@ -206,6 +217,7 @@ export class Command implements CommandDef {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private _run(...args: any[]) {
|
private _run(...args: any[]) {
|
||||||
return this._activeFunc(...args);
|
return this._activeFunc(...args);
|
||||||
|
@ -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();
|
||||||
|
}
|
@ -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
|
||||||
]
|
]
|
||||||
|
@ -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';
|
||||||
|
@ -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';
|
||||||
|
27
app/client/ui/FieldContextMenu.ts
Normal file
27
app/client/ui/FieldContextMenu.ts
Normal 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')),
|
||||||
|
];
|
||||||
|
}
|
@ -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';
|
||||||
|
@ -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.
|
||||||
|
Loading…
Reference in New Issue
Block a user