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/D3237pull/131/head
parent
ec7bc9bef3
commit
196ab6c473
@ -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;
|
||||
}
|
@ -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));
|
||||
});
|
||||
};
|
||||
}
|
Loading…
Reference in new issue