(core) Adds cell context menu

Summary:
 - Brings in a new utility `contextMenu` to open context menu next to the mouse position
 - Use this utility to show a CellContextMenu, that sort of merge cell context menu and column context menu together.
 - Show cell context menu on context click on any grid's cell.
 - Also takes care of showing the row context menu for detail view on a context click that occurs on cells and not only on the row num header as it was the case prior to this diff.
 - task: https://gristlabs.getgrist.com/doc/check-ins/p/5#a1.s9.r1529.c31
 - discussion: https://grist.quip.com/ETGkAroLnc0Y/Cell-Context-Menu

{F40092}

Test Plan: - Adds project test and nbrowser for cell context menu and new cases for the detail row context menu.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D3237
This commit is contained in:
Cyprien P
2022-02-07 15:09:23 +01:00
parent ec7bc9bef3
commit 196ab6c473
9 changed files with 271 additions and 12 deletions

View File

@@ -28,14 +28,16 @@ const {reportError} = require('app/client/models/AppModel');
const {onDblClickMatchElem} = require('app/client/lib/dblclick');
// Grist UI Components
const {Holder, Computed} = require('grainjs');
const {dom: grainjsDom, Holder, Computed} = require('grainjs');
const {menu} = require('../ui2018/menus');
const {calcFieldsCondition} = require('../ui/GridViewMenus');
const {ColumnAddMenu, ColumnContextMenu, MultiColumnMenu, freezeAction} = require('../ui/GridViewMenus');
const {RowContextMenu} = require('../ui/RowContextMenu');
const {setPopupToCreateDom} = require('popweasel');
const {CellContextMenu} = require('app/client/ui/CellContextMenu');
const {testId} = require('app/client/ui2018/cssVars');
const {contextMenu} = require('app/client/ui/contextMenu');
const {menuToggle} = require('app/client/ui/MenuToggle');
const {showTooltip} = require('app/client/ui/tooltips');
@@ -76,6 +78,7 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) {
this.cellSelector = this.autoDispose(selector.CellSelector.create(this, {
// This is a bit of a hack to prevent dragging when there's an open column menu
// TODO: disable dragging when there is an open cell context menu as well
isDisabled: () => Boolean(!this.ctxMenuHolder.isEmpty())
}));
this.colMenuTargets = {}; // Reference from column ref to its menu target dom
@@ -603,6 +606,26 @@ GridView.prototype.assignCursor = function(elem, elemType) {
this.cellSelector.currentSelectType(elemType);
};
/**
* Schedules cursor assignement to happen at end of tick. Calling `preventAssignCursor()` before
* prevents assignment to happen. This was added to prevent cursor assignment on a `context click`
* on a cell that is already selected.
*/
GridView.prototype.scheduleAssignCursor = function(elem, elemType) {
this._assignCursorTimeoutId = setTimeout(() => {
this.assignCursor(elem, elemType);
this._assignCursorTimeoutId = null;
}, 0);
}
/**
* See `scheduleAssignCursor()` for doc.
*/
GridView.prototype.preventAssignCursor = function() {
clearTimeout(this._assignCursorTimeoutId);
this._assignCursorTimeoutId = null;
}
GridView.prototype.deleteRows = function(selection) {
if (!this.viewSection.disableAddRemoveRows()) {
var rowIds = _.without(selection.rowIds, 'new');
@@ -1067,11 +1090,10 @@ GridView.prototype.buildDom = function() {
}),
self.isPreview ? null : menuToggle(null,
dom.on('click', ev => self.maybeSelectRow(ev.currentTarget.parentNode, row.getRowId())),
menu(() => RowContextMenu({
disableInsert: Boolean(self.gristDoc.isReadonly.get() || self.viewSection.disableAddRemoveRows() || self.tableModel.tableMetaRow.onDemand()),
disableDelete: Boolean(self.gristDoc.isReadonly.get() || self.viewSection.disableAddRemoveRows() || self.getSelection().onlyAddRowSelected()),
isViewSorted: self.viewSection.activeSortSpec.peek().length > 0,
}), { trigger: ['click'] }),
menu((ctx) => {
ctx.autoDispose(isRowActive.subscribe(() => ctx.close()));
return self.rowContextMenu();
}, { trigger: ['click'] }),
// Prevent mousedown on the dropdown triangle from initiating row drag.
dom.on('mousedown', () => false),
testId('row-menu-trigger'),
@@ -1095,6 +1117,13 @@ GridView.prototype.buildDom = function() {
self.changeHover(-1);
}
}),
contextMenu((ctx) => {
// We need to close the menu when the row is removed, but the dom of the row is not
// disposed when the record is removed (this is probably due to how scrolly work). Hence,
// we need to subscribe to `isRowActive` to close the menu.
ctx.autoDispose(isRowActive.subscribe(() => ctx.close()));
return self.cellContextMenu();
}),
self.comparison ? kd.cssClass(() => {
const rowType = self.extraRows.getRowType(row.id());
return rowType && `diff-${rowType}` || '';
@@ -1139,7 +1168,21 @@ GridView.prototype.buildDom = function() {
kd.style('borderRightWidth', v.borderWidthPx),
kd.toggleClass('selected', isSelected),
fieldBuilder.buildDomWithCursor(row, isCellActive, isCellSelected)
fieldBuilder.buildDomWithCursor(row, isCellActive, isCellSelected),
grainjsDom.on('contextmenu', (ev, elem) => {
let row = self.domToRowModel(elem, selector.CELL);
let col = self.domToColModel(elem, selector.CELL);
if (self.cellSelector.containsCell(row._index(), col._index())) {
// contextmenu event could be preceded by a mousedown event (ie: when ctrl+click on
// mac) which triggers a cursor assignment that we need to prevent.
self.preventAssignCursor();
} else {
self.assignCursor(elem, selector.NONE);
}
})
);
})
)
@@ -1283,7 +1326,7 @@ GridView.prototype.attachSelectorHandlers = function () {
};
var cellCallbacks = {
'mousedown': { 'select': this.cellMouseDown,
'drag' : function(elem) { this.assignCursor(elem, selector.NONE); },
'drag' : function(elem) { this.scheduleAssignCursor(elem, selector.NONE); },
'elemName': '.field:not(.column_name)',
'source': this.scrollPane
},
@@ -1483,6 +1526,26 @@ GridView.prototype.maybeSelectRow = function(elem, rowId) {
}
};
GridView.prototype.rowContextMenu = function() {
return RowContextMenu(this._getRowContextMenuOptions());
};
GridView.prototype._getRowContextMenuOptions = function() {
return {
disableInsert: Boolean(this.gristDoc.isReadonly.get() || this.viewSection.disableAddRemoveRows() || this.tableModel.tableMetaRow.onDemand()),
disableDelete: Boolean(this.gristDoc.isReadonly.get() || this.viewSection.disableAddRemoveRows() || this.getSelection().onlyAddRowSelected()),
isViewSorted: this.viewSection.activeSortSpec.peek().length > 0,
numRows: this.getSelection().rowIds.length
};
};
GridView.prototype.cellContextMenu = function() {
return CellContextMenu(
this._getRowContextMenuOptions(),
this._getColumnMenuOptions(this.getSelection())
);
};
// End Context Menus
GridView.prototype.scrollToCursor = function(sync = true) {

View File

@@ -38,6 +38,7 @@ var commands = require('./commands');
var {menuToggle} = require('app/client/ui/MenuToggle');
var {menu} = require('../ui2018/menus');
var {testId} = require('app/client/ui2018/cssVars');
var {contextMenu} = require('app/client/ui/contextMenu');
/**
* Construct a RecordLayout.
@@ -334,12 +335,16 @@ 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)),
dom('div.detail_row_num',
kd.text(() => (row._index() + 1)),
dom.on('contextmenu', ev => {
// This is a little hack to position the menu the same way as with a click,
// the same hack as on a column menu.
ev.preventDefault();
// prevent 2nd context menu to show up
ev.stopPropagation();
ev.currentTarget.querySelector('.menu_toggle').click();
}),
menuToggle(null,