(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'); const {onDblClickMatchElem} = require('app/client/lib/dblclick');
// Grist UI Components // Grist UI Components
const {Holder, Computed} = require('grainjs'); const {dom: grainjsDom, Holder, Computed} = require('grainjs');
const {menu} = require('../ui2018/menus'); const {menu} = require('../ui2018/menus');
const {calcFieldsCondition} = require('../ui/GridViewMenus'); const {calcFieldsCondition} = require('../ui/GridViewMenus');
const {ColumnAddMenu, ColumnContextMenu, MultiColumnMenu, freezeAction} = require('../ui/GridViewMenus'); const {ColumnAddMenu, ColumnContextMenu, MultiColumnMenu, freezeAction} = require('../ui/GridViewMenus');
const {RowContextMenu} = require('../ui/RowContextMenu'); const {RowContextMenu} = require('../ui/RowContextMenu');
const {setPopupToCreateDom} = require('popweasel'); const {setPopupToCreateDom} = require('popweasel');
const {CellContextMenu} = require('app/client/ui/CellContextMenu');
const {testId} = require('app/client/ui2018/cssVars'); const {testId} = require('app/client/ui2018/cssVars');
const {contextMenu} = require('app/client/ui/contextMenu');
const {menuToggle} = require('app/client/ui/MenuToggle'); const {menuToggle} = require('app/client/ui/MenuToggle');
const {showTooltip} = require('app/client/ui/tooltips'); 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.cellSelector = this.autoDispose(selector.CellSelector.create(this, {
// This is a bit of a hack to prevent dragging when there's an open column menu // 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()) isDisabled: () => Boolean(!this.ctxMenuHolder.isEmpty())
})); }));
this.colMenuTargets = {}; // Reference from column ref to its menu target dom 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); 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) { GridView.prototype.deleteRows = function(selection) {
if (!this.viewSection.disableAddRemoveRows()) { if (!this.viewSection.disableAddRemoveRows()) {
var rowIds = _.without(selection.rowIds, 'new'); var rowIds = _.without(selection.rowIds, 'new');
@ -1067,11 +1090,10 @@ GridView.prototype.buildDom = function() {
}), }),
self.isPreview ? null : menuToggle(null, self.isPreview ? null : menuToggle(null,
dom.on('click', ev => self.maybeSelectRow(ev.currentTarget.parentNode, row.getRowId())), dom.on('click', ev => self.maybeSelectRow(ev.currentTarget.parentNode, row.getRowId())),
menu(() => RowContextMenu({ menu((ctx) => {
disableInsert: Boolean(self.gristDoc.isReadonly.get() || self.viewSection.disableAddRemoveRows() || self.tableModel.tableMetaRow.onDemand()), ctx.autoDispose(isRowActive.subscribe(() => ctx.close()));
disableDelete: Boolean(self.gristDoc.isReadonly.get() || self.viewSection.disableAddRemoveRows() || self.getSelection().onlyAddRowSelected()), return self.rowContextMenu();
isViewSorted: self.viewSection.activeSortSpec.peek().length > 0, }, { trigger: ['click'] }),
}), { trigger: ['click'] }),
// Prevent mousedown on the dropdown triangle from initiating row drag. // Prevent mousedown on the dropdown triangle from initiating row drag.
dom.on('mousedown', () => false), dom.on('mousedown', () => false),
testId('row-menu-trigger'), testId('row-menu-trigger'),
@ -1095,6 +1117,13 @@ GridView.prototype.buildDom = function() {
self.changeHover(-1); 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(() => { self.comparison ? kd.cssClass(() => {
const rowType = self.extraRows.getRowType(row.id()); const rowType = self.extraRows.getRowType(row.id());
return rowType && `diff-${rowType}` || ''; return rowType && `diff-${rowType}` || '';
@ -1139,7 +1168,21 @@ GridView.prototype.buildDom = function() {
kd.style('borderRightWidth', v.borderWidthPx), kd.style('borderRightWidth', v.borderWidthPx),
kd.toggleClass('selected', isSelected), 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 = { var cellCallbacks = {
'mousedown': { 'select': this.cellMouseDown, '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)', 'elemName': '.field:not(.column_name)',
'source': this.scrollPane '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 // End Context Menus
GridView.prototype.scrollToCursor = function(sync = true) { GridView.prototype.scrollToCursor = function(sync = true) {

View File

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

View File

@ -30,7 +30,10 @@ export function createAppUI(topAppModel: TopAppModel, appObj: App): IDisposable
dom.update(document.body, content, { dom.update(document.body, content, {
// Cancel out bootstrap's overrides. // Cancel out bootstrap's overrides.
style: 'font-family: inherit; font-size: inherit; line-height: inherit;' style: 'font-family: inherit; font-size: inherit; line-height: inherit;'
}); },
// prevent default context menu to show
dom.on('contextmenu', (ev) => ev.preventDefault())
);
function dispose() { function dispose() {
// Return value of dom.maybe() / dom.domComputed() is a pair of markers with a function that // Return value of dom.maybe() / dom.domComputed() is a pair of markers with a function that

View File

@ -0,0 +1,85 @@
import { allCommands } from 'app/client/components/commands';
import { menuDivider, menuItemCmd } from 'app/client/ui2018/menus';
import { dom } from 'grainjs';
import { IMultiColumnContextMenu } from 'app/client/ui/GridViewMenus';
interface IRowContextMenu {
disableInsert: boolean;
disableDelete: boolean;
isViewSorted: boolean;
numRows: number;
}
export function CellContextMenu(rowOptions: IRowContextMenu, colOptions: IMultiColumnContextMenu) {
const { disableInsert, disableDelete, isViewSorted } = rowOptions;
const { disableModify, isReadonly } = colOptions;
const disableForReadonlyColumn = dom.cls('disabled', Boolean(disableModify) || isReadonly);
const disableForReadonlyView = dom.cls('disabled', isReadonly);
const numCols: number = colOptions.numColumns;
const nameClearColumns = colOptions.isFiltered ?
(numCols > 1 ? `Clear ${numCols} entire columns` : 'Clear entire column') :
(numCols > 1 ? `Clear ${numCols} columns` : 'Clear column');
const nameDeleteColumns = numCols > 1 ? `Delete ${numCols} columns` : 'Delete column';
const numRows: number = rowOptions.numRows;
const nameDeleteRows = numRows > 1 ? `Delete ${numRows} rows` : 'Delete row';
const nameClearCells = (numRows > 1 || numCols > 1) ? 'Clear values' : 'Clear cell';
const result: Array<Element|null> = [];
result.push(
// TODO: implement copy/paste actions
colOptions.isFormula ?
null :
menuItemCmd(allCommands.clearValues, nameClearCells, disableForReadonlyColumn),
menuItemCmd(allCommands.clearColumns, nameClearColumns, disableForReadonlyColumn),
...(
(numCols > 1 || numRows > 1) ? [] : [
menuDivider(),
menuItemCmd(allCommands.copyLink, 'Copy anchor link')
]
),
menuDivider(),
// inserts
...(
isViewSorted ?
// When the view is sorted, any newly added records get shifts instantly at the top or
// bottom. It could be very confusing for users who might expect the record to stay above or
// below the active row. Thus in this case we show a single `insert row` command.
[menuItemCmd(allCommands.insertRecordAfter, 'Insert row',
dom.cls('disabled', disableInsert))] :
[menuItemCmd(allCommands.insertRecordBefore, 'Insert row above',
dom.cls('disabled', disableInsert)),
menuItemCmd(allCommands.insertRecordAfter, 'Insert row below',
dom.cls('disabled', disableInsert))]
),
menuItemCmd(allCommands.insertFieldBefore, 'Insert column to the left',
disableForReadonlyView),
menuItemCmd(allCommands.insertFieldAfter, 'Insert column to the right',
disableForReadonlyView),
menuDivider(),
// deletes
menuItemCmd(allCommands.deleteRecords, nameDeleteRows,
dom.cls('disabled', disableDelete)),
menuItemCmd(allCommands.deleteFields, nameDeleteColumns, disableForReadonlyColumn),
// todo: add "hide N columns"
);
return result;
}

View File

@ -32,7 +32,7 @@ export function ColumnAddMenu(gridView: IView, viewSection: IViewSection) {
}, `Show column ${col.label()}`)) }, `Show column ${col.label()}`))
]; ];
} }
interface IMultiColumnContextMenu { export interface IMultiColumnContextMenu {
// For multiple selection, true/false means the value applies to all columns, 'mixed' means it's // For multiple selection, true/false means the value applies to all columns, 'mixed' means it's
// true for some columns, but not all. // true for some columns, but not all.
numColumns: number; numColumns: number;

View File

@ -28,6 +28,7 @@ export function RowContextMenu({ disableInsert, disableDelete, isViewSorted }: I
} }
result.push( result.push(
menuDivider(), menuDivider(),
// TODO: should show `Delete ${num} rows` when multiple are selected
menuItemCmd(allCommands.deleteRecords, 'Delete', menuItemCmd(allCommands.deleteRecords, 'Delete',
dom.cls('disabled', disableDelete)), dom.cls('disabled', disableDelete)),
); );

View File

@ -0,0 +1,98 @@
/**
* This module implements context menu to be shown on contextmenu event (most commonly associated
* with right+click, but could varies slightly depending on platform, ie: mac support ctrl+click as
* well).
*
* To prevent the default context menu to show everywhere else (including on the top of your custom
* context menu) dont forget to prevent it by including below line at the root of the dom:
* `dom.on('contextmenu', ev => ev.preventDefault())`
*/
import { Disposable, dom, DomArg, DomContents } from "grainjs";
import { cssMenuElem } from 'app/client/ui2018/menus';
import { IOpenController, Menu } from 'popweasel';
export type IContextMenuContentFunc = (ctx: ContextMenuController) => DomContents;
class ContextMenuController extends Disposable implements IOpenController {
private _content: HTMLElement;
constructor(private _event: MouseEvent, contentFunc: IContextMenuContentFunc) {
super();
setTimeout(() => this._updatePosition(), 0);
// Create content and add to the dom but keep hidden until menu gets positioned
const menu = Menu.create(null, this, [contentFunc(this)], {
menuCssClass: cssMenuElem.className + ' grist-floating-menu'
});
const content = this._content = menu.content;
content.style.visibility = 'hidden';
document.body.appendChild(content);
// Prevents arrow to move the cursor while menu is open.
dom.onKeyElem(content, 'keydown', {
ArrowLeft: (ev) => ev.stopPropagation(),
ArrowRight: (ev) => ev.stopPropagation()
// UP and DOWN are already handle by the menu to navigate the menu)
});
// On click anywhere on the page (outside popup content), close it.
const onClick = (evt: MouseEvent) => {
const target: Node|null = evt.target as Node;
if (target && !content.contains(target)) {
this.close();
}
};
this.autoDispose(dom.onElem(document, 'contextmenu', onClick, {useCapture: true}));
this.autoDispose(dom.onElem(document, 'click', onClick, {useCapture: true}));
// Cleanup involves removing the element.
this.onDispose(() => {
dom.domDispose(content);
content.remove();
});
}
public close() {
this.dispose();
}
public setOpenClass() {}
// IOpenController expects a trigger elem but context menu has no trigger. Let's return body for
// now. As of time of writing the trigger elem is only used by popweasel when certain options are
// enabled, ie: strectToSelector, parentSelectoToMark.
// TODO: make a PR on popweasel to support using Menu with no trigger element.
public getTriggerElem() { return document.body; }
public update() {}
private _updatePosition() {
const content = this._content;
const ev = this._event;
const rect = content.getBoundingClientRect();
// position menu on the right of the cursor if it can fit, on the left otherwise
content.style.left = ((ev.pageX + rect.width < window.innerWidth) ? ev.pageX : ev.pageX - rect.width) + 'px';
// position menu below the cursor if it can fit, otherwise fit at the bottom of the screen
content.style.bottom = Math.max(window.innerHeight - (ev.pageY + rect.height), 0) + 'px';
// show content
content.style.visibility = '';
}
}
/**
* Show the return value of contentFunc() in a context menu next to the mouse.
*/
function showContextMenu(ev: MouseEvent, contentFunc: IContextMenuContentFunc) {
return ContextMenuController.create(null, ev, contentFunc);
}
/**
* Show a context menu on contextmenu.
*/
export function contextMenu(contentFunc: IContextMenuContentFunc): DomArg {
return (elem) => {
dom.onElem(elem, 'contextmenu', (ev) => {
ev.preventDefault();
ev.stopPropagation();
dom.autoDisposeElem(elem, showContextMenu(ev, contentFunc));
});
};
}

View File

@ -35,7 +35,7 @@ export function menuItemSubmenu(
return weasel.menuItemSubmenu(submenu, {...defaults, ...options}, ...args); return weasel.menuItemSubmenu(submenu, {...defaults, ...options}, ...args);
} }
const cssMenuElem = styled('div', ` export const cssMenuElem = styled('div', `
font-family: ${vars.fontFamily}; font-family: ${vars.fontFamily};
font-size: ${vars.mediumFontSize}; font-size: ${vars.mediumFontSize};
line-height: initial; line-height: initial;

View File

@ -46,7 +46,7 @@ export const checkLoginPage = homeUtil.checkLoginPage.bind(homeUtil);
export const fixturesRoot: string = testUtils.fixturesRoot; export const fixturesRoot: string = testUtils.fixturesRoot;
// it is sometimes useful in debugging to turn off automatic cleanup of docs and workspaces. // it is sometimes useful in debugging to turn off automatic cleanup of docs and workspaces.
const noCleanup = Boolean(process.env.NO_CLEANUP); export const noCleanup = Boolean(process.env.NO_CLEANUP);
// Most test code uses simulateLogin through the server reference. Keep them to reduce unnecessary // Most test code uses simulateLogin through the server reference. Keep them to reduce unnecessary
// code changes. // code changes.
@ -372,6 +372,10 @@ export async function dbClick(cell: WebElement) {
await driver.withActions(a => a.doubleClick(cell)); await driver.withActions(a => a.doubleClick(cell));
} }
export async function rightClick(cell: WebElement) {
await driver.withActions((actions) => actions.contextClick(cell));
}
/** /**
* Returns {rowNum, col} object representing the position of the cursor in the active view * Returns {rowNum, col} object representing the position of the cursor in the active view
* section. RowNum is a 1-based number as in the row headers, and col is a 0-based index for * section. RowNum is a 1-based number as in the row headers, and col is a 0-based index for