(core) Add cell selection summary

Summary:
Adds a cell selection summary to grid view that shows either a count or
sum of all the selected values. Implementation was done by Dmitry.

Test Plan: Browser tests.

Reviewers: jarek

Reviewed By: jarek

Subscribers: paulfitz, dsagal, jarek

Differential Revision: https://phab.getgrist.com/D3630
messytables-requirements
George Gevoian 2 years ago
parent 433e1ecfc2
commit 364610c69d

@ -0,0 +1,157 @@
import ko from 'knockout';
import type BaseView from 'app/client/components/BaseView';
import type {DataRowModel} from 'app/client/models/DataRowModel';
import {between} from 'app/common/gutil';
import {Disposable} from 'grainjs';
export const ROW = 'row';
export const COL = 'col';
export const CELL = 'cell';
export const NONE = '';
export type ElemType = 'row' | 'col' | 'cell' | '';
interface GridView extends BaseView {
domToRowModel(elem: Element, elemType: ElemType): DataRowModel;
domToColModel(elem: Element, elemType: ElemType): DataRowModel;
}
export class CellSelector extends Disposable {
// row or col.start denotes the anchor/initial index of the select range.
// start is not necessarily smaller than end.
// IE: clicking on col 10 and dragging until the mouse is on col 5 will yield: start = 10, end = 5
public row = {
start: ko.observable(0),
end: ko.observable(0),
linePos: ko.observable('0px'), // Used by GridView for dragging rows
dropIndex: ko.observable(-1), // Used by GridView for dragging rows
};
public col = {
start: ko.observable(0),
end: ko.observable(0),
linePos: ko.observable('0px'), // Used by GridView for dragging columns
dropIndex: ko.observable(-1), // Used by GridView for dragging columns
};
public currentSelectType = ko.observable<ElemType>(NONE);
public currentDragType = ko.observable<ElemType>(NONE);
constructor(public readonly view: GridView) {
super();
this.autoDispose(this.view.cursor.rowIndex.subscribe(() => this.setToCursor()));
this.autoDispose(this.view.cursor.fieldIndex.subscribe(() => this.setToCursor()));
this.setToCursor();
}
public setToCursor(elemType: ElemType = NONE) {
// Must check that the view contains cursor.rowIndex/cursor.fieldIndex
// in case it has changed.
if (this.view.cursor.rowIndex) {
this.row.start(this.view.cursor.rowIndex()!);
this.row.end(this.view.cursor.rowIndex()!);
}
if (this.view.cursor.fieldIndex) {
this.col.start(this.view.cursor.fieldIndex());
this.col.end(this.view.cursor.fieldIndex());
}
this.currentSelectType(elemType);
}
public containsCell(rowIndex: number, colIndex: number): boolean {
return this.containsCol(colIndex) && this.containsRow(rowIndex);
}
public containsRow(rowIndex: number): boolean {
return between(rowIndex, this.row.start(), this.row.end());
}
public containsCol(colIndex: number): boolean {
return between(colIndex, this.col.start(), this.col.end());
}
public isSelected(elem: Element, handlerName: ElemType) {
if (handlerName !== this.currentSelectType()) {
return false;
}
// TODO: this only works with view: GridView.
// But it seems like we only ever use selectors with gridview anyway
const row = this.view.domToRowModel(elem, handlerName);
const col = this.view.domToColModel(elem, handlerName);
switch (handlerName) {
case ROW:
return this.containsRow(row._index()!);
case COL:
return this.containsCol(col._index()!);
case CELL:
return this.containsCell(row._index()!, col._index()!);
default:
console.error('Given element is not a row, cell or column');
return false;
}
}
public isRowSelected(rowIndex: number): boolean {
return this.isCurrentSelectType(COL) || this.containsRow(rowIndex);
}
public isColSelected(colIndex: number): boolean {
return this.isCurrentSelectType(ROW) || this.containsCol(colIndex);
}
public isCellSelected(rowIndex: number, colIndex: number): boolean {
return this.isColSelected(colIndex) && this.isRowSelected(rowIndex);
}
public onlyCellSelected(rowIndex: number, colIndex: number): boolean {
return (this.row.start() === rowIndex && this.row.end() === rowIndex) &&
(this.col.start() === colIndex && this.col.end() === colIndex);
}
public isCurrentSelectType(elemType: ElemType): boolean {
return this._isCurrentType(this.currentSelectType(), elemType);
}
public isCurrentDragType(elemType: ElemType): boolean {
return this._isCurrentType(this.currentDragType(), elemType);
}
public colLower(): number {
return Math.min(this.col.start(), this.col.end());
}
public colUpper(): number {
return Math.max(this.col.start(), this.col.end());
}
public rowLower(): number {
return Math.min(this.row.start(), this.row.end());
}
public rowUpper(): number {
return Math.max(this.row.start(), this.row.end());
}
public colCount(): number {
return this.colUpper() - this.colLower() + 1;
}
public rowCount(): number {
return this.rowUpper() - this.rowLower() + 1;
}
public selectArea(rowStartIdx: number, colStartIdx: number, rowEndIdx: number, colEndIdx: number): void {
this.row.start(rowStartIdx);
this.col.start(colStartIdx);
this.row.end(rowEndIdx);
this.col.end(colEndIdx);
// Only select the area if it's not a single cell
if (this.colCount() > 1 || this.rowCount() > 1) {
this.currentSelectType(CELL);
}
}
private _isCurrentType(currentType: ElemType, elemType: ElemType): boolean {
console.assert([ROW, COL, CELL, NONE].indexOf(elemType) !== -1);
return currentType === elemType;
}
}

@ -1,4 +1,4 @@
/* globals alert, document, $ */
/* globals alert, $ */
const _ = require('underscore');
const ko = require('knockout');
@ -19,8 +19,9 @@ const commands = require('./commands');
const viewCommon = require('./viewCommon');
const Base = require('./Base');
const BaseView = require('./BaseView');
const selector = require('./Selector');
const selector = require('./CellSelector');
const {CopySelection} = require('./CopySelection');
const {SelectionSummary} = require('./SelectionSummary');
const koUtil = require('app/client/lib/koUtil');
const convert = require('color-convert');
@ -39,6 +40,7 @@ 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 {mouseDragMatchElem} = require('app/client/ui/mouseDrag');
const {menuToggle} = require('app/client/ui/MenuToggle');
const {showTooltip} = require('app/client/ui/tooltips');
const {parsePasteForView} = require("./BaseView2");
@ -79,11 +81,9 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) {
this.scrollTop = ko.observable(0);
this.isScrolledTop = this.autoDispose(ko.computed(() => this.scrollTop() > 0));
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.cellSelector = selector.CellSelector.create(this, this);
this.selectionSummary = SelectionSummary.create(this,
this.cellSelector, this.tableModel.tableData, this.sortedRows, this.viewSection.viewFields);
this.colMenuTargets = {}; // Reference from column ref to its menu target dom
// Cache of column right offsets, used to determine the col select range
@ -856,7 +856,6 @@ GridView.prototype._getColStyle = function(colIndex) {
return { 'width' : this.viewSection.viewFields().at(colIndex).widthPx() };
};
// TODO: for now lets just assume you are clicking on a .field, .row, or .column
GridView.prototype.domToRowModel = function(elem, elemType) {
switch (elemType) {
@ -1359,19 +1358,19 @@ GridView.prototype.rowMouseDown = function(elem, event) {
}
};
GridView.prototype.rowMouseMove = function(elem, event) {
GridView.prototype.rowMouseMove = function(event) {
this.cellSelector.row.end(this.currentMouseRow(event.pageY));
};
GridView.prototype.colMouseMove = function(elem, event) {
GridView.prototype.colMouseMove = function(event) {
var currentCol = Math.min(this.getMousePosCol(event.pageX),
this.viewSection.viewFields().peekLength - 1);
this.cellSelector.col.end(currentCol);
};
GridView.prototype.cellMouseMove = function(elem, event, extra) {
this.colMouseMove(elem, event);
this.rowMouseMove(elem, event);
GridView.prototype.cellMouseMove = function(event) {
this.colMouseMove(event);
this.rowMouseMove(event);
// Maintain single cells cannot be selected invariant
if (this.cellSelector.onlyCellSelected(this.cursor.rowIndex(), this.cursor.fieldIndex())) {
this.cellSelector.currentSelectType(selector.NONE);
@ -1388,58 +1387,70 @@ GridView.prototype.createSelector = function() {
// but we can't attach any of the mouse handlers in the Selector class until the
// dom elements exist so we attach the selector handlers separately from instantiation
GridView.prototype.attachSelectorHandlers = function () {
// We attach mousemove and mouseup to document so that selecting and drag/dropping
// work even if the mouse leaves the view pane: http://news.qooxdoo.org/mouse-capturing
// Mousemove/up events fire to document even if the mouse leaves the browser window.
var rowCallbacks = {
'disableDrag': this.viewSection.disableDragRows,
'mousedown': { 'select': this.rowMouseDown,
'drag': this.styleRowDragElements,
'elemName': '.gridview_data_row_num',
'source': this.viewPane,
},
'mousemove': { 'select': this.rowMouseMove,
'drag': this.dragRows,
'source': document,
},
'mouseup': { 'select': this.rowMouseUp,
'drag': this.dropRows,
'source': document,
}
};
var colCallbacks = {
'mousedown': { 'select': this.colMouseDown,
'drag': this.styleColDragElements,
// Trigger on column headings but not on the add column button
'elemName': '.column_name.field:not(.mod-add-column)',
'source': this.viewPane,
},
'mousemove': { 'select': this.colMouseMove,
'drag': this.dragCols,
'source': document,
},
'mouseup': { 'drag': this.dropCols,
'source': document,
const ignoreEvent = (event, elem) => (
event.button !== 0 ||
event.target.classList.contains('ui-resizable-handle') ||
// 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
!this.ctxMenuHolder.isEmpty()
);
this.autoDispose(mouseDragMatchElem(this.viewPane, '.gridview_data_row_num', (event, elem) => {
if (!ignoreEvent(event, elem)) {
if (!this.cellSelector.isSelected(elem, selector.ROW)) {
this.rowMouseDown(elem, event);
return {
onMove: (ev) => this.rowMouseMove(ev),
onStop: (ev) => {},
};
} else if (!this.viewSection.disableDragRows()) {
this.styleRowDragElements(elem, event);
return {
onMove: (ev) => this.dragRows(ev),
onStop: (ev) => this.dropRows(),
};
}
}
};
var cellCallbacks = {
'mousedown': { 'select': this.cellMouseDown,
'drag' : function(elem) { this.scheduleAssignCursor(elem, selector.NONE); },
'elemName': '.field:not(.column_name)',
'source': this.scrollPane
},
'mousemove': { 'select': this.cellMouseMove,
'source': document,
},
'mouseup': { 'select': this.cellMouseUp,
'source': document,
}));
// Trigger on column headings but not on the add column button
this.autoDispose(mouseDragMatchElem(this.viewPane, '.column_name.field:not(.mod-add-column)', (event, elem) => {
if (!ignoreEvent(event, elem)) {
if (!this.cellSelector.isSelected(elem, selector.COL)) {
this.colMouseDown(elem, event);
return {
onMove: (ev) => this.colMouseMove(ev),
onStop: (ev) => {},
};
} else {
this.styleColDragElements(elem, event);
return {
onMove: (ev) => this.dragCols(ev),
onStop: (ev) => this.dropCols(),
};
}
}
};
}));
this.cellSelector.registerMouseHandlers(rowCallbacks, selector.ROW);
this.cellSelector.registerMouseHandlers(colCallbacks, selector.COL);
this.cellSelector.registerMouseHandlers(cellCallbacks, selector.CELL);
};
this.autoDispose(mouseDragMatchElem(this.scrollPane, '.field:not(.column_name)', (event, elem) => {
if (!ignoreEvent(event, elem)) {
// TODO: should always enable
if (!this.cellSelector.isSelected(elem, selector.CELL)) {
this.cellMouseDown(elem, event);
return {
onMove: (ev) => this.cellMouseMove(ev),
onStop: (ev) => {},
}
} else { // TODO: if true above, this will never come into play.
this.scheduleAssignCursor(elem, selector.NONE);
return {
onMove: (ev) => {},
onStop: (ev) => { this.cellSelector.currentDragType(selector.NONE); },
};
}
}
}));
}
// End of Selector stuff
@ -1487,7 +1498,7 @@ GridView.prototype.styleColDragElements = function(elem, event) {
* 3) If the last col/row is in the select range, the indicator line should be clamped to the start of the
* select range.
**/
GridView.prototype.dragRows = function(elem, event) {
GridView.prototype.dragRows = function(event) {
var dropIndex = Math.min(this.getMousePosRow(event.pageY + this.scrollTop()),
this.getLastDataRowIndex());
if (this.cellSelector.containsRow(dropIndex)) {
@ -1506,7 +1517,7 @@ GridView.prototype.dragRows = function(elem, event) {
this.dragY(event.pageY);
};
GridView.prototype.dragCols = function(elem, event) {
GridView.prototype.dragCols = function(event) {
let dropIndex = Math.min(this.getMousePosCol(event.pageX),
this.viewSection.viewFields().peekLength - 1);
if (this.cellSelector.containsCol(dropIndex)) {
@ -1539,6 +1550,7 @@ GridView.prototype.dragCols = function(elem, event) {
GridView.prototype.dropRows = function() {
var oldIndices = _.range(this.cellSelector.rowLower(), this.cellSelector.rowUpper() + 1);
this.moveRows(oldIndices, this.cellSelector.row.dropIndex());
this.cellSelector.currentDragType(selector.NONE);
};
GridView.prototype.dropCols = function() {
@ -1552,6 +1564,7 @@ GridView.prototype.dropCols = function() {
this.currentEditingColumnIndex(idx);
}
this._colClickTime = 0;
this.cellSelector.currentDragType(selector.NONE);
};
// End of Dragging logic

@ -1262,7 +1262,7 @@ const cssViewContentPane = styled('div', `
flex: auto;
display: flex;
flex-direction: column;
overflow: hidden;
overflow: visible;
position: relative;
min-width: 240px;
margin: 12px;

@ -116,7 +116,7 @@ const cssOverlay = styled('div', `
inset: 0px;
height: 100%;
width: 100%;
padding: 32px 56px 0px 56px;
padding: 20px 56px 20px 56px;
position: absolute;
@media ${mediaSmall} {
& {
@ -131,7 +131,6 @@ const cssSectionWrapper = styled('div', `
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 5px;
border-bottom-left-radius: 0px;
border-bottom-right-radius: 0px;

@ -0,0 +1,339 @@
import {CellSelector, COL, ROW} from 'app/client/components/CellSelector';
import {copyToClipboard} from 'app/client/lib/copyToClipboard';
import {Delay} from "app/client/lib/Delay";
import {KoArray} from 'app/client/lib/koArray';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {UserError} from 'app/client/models/errors';
import {ALL, RowsChanged, SortedRowSet} from "app/client/models/rowset";
import {showTransientTooltip} from 'app/client/ui/tooltips';
import {colors, isNarrowScreen, isNarrowScreenObs, theme, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {CellValue} from 'app/common/DocActions';
import {isEmptyList, isListType, isRefListType} from "app/common/gristTypes";
import {TableData} from "app/common/TableData";
import {BaseFormatter} from 'app/common/ValueFormatter';
import ko from 'knockout';
import {Computed, Disposable, dom, makeTestId, Observable, styled, subscribe} from 'grainjs';
/**
* A beginning and end index for a range of columns or rows.
*/
interface Range {
begin: number;
end: number;
}
/**
* A single part of the cell selection summary.
*/
interface SummaryPart {
/** Identifier for the summary part. */
id: 'sum' | 'count' | 'dimensions';
/** Label that's shown to the left of `value`. */
label: string;
/** Value of the summary part. */
value: string;
/** If true, displays a copy button on hover. Defaults to false. */
clickToCopy?: boolean;
}
const testId = makeTestId('test-selection-summary-');
// We can handle a million cells in under 60ms on a good laptop. Much beyond that, and we'll break
// selection with the bad performance. Instead, skip the counting and summing for too many cells.
const MAX_CELLS_TO_SCAN = 1_000_000;
export class SelectionSummary extends Disposable {
private _colTotalCount = Computed.create(this, (use) =>
use(use(this._viewFields).getObservable()).length);
private _rowTotalCount = Computed.create(this, (use) => {
const rowIds = use(this._sortedRows.getKoArray().getObservable());
const includesNewRow = (rowIds.length > 0 && rowIds[rowIds.length - 1] === 'new');
return rowIds.length - (includesNewRow ? 1 : 0);
});
// In CellSelector, start and end are 0-based, inclusive, and not necessarily in order.
// It's not good for representing an empty range. Here, we convert ranges as [begin, end),
// with end >= begin.
private _rowRange = Computed.create<Range>(this, (use) => {
const type = use(this._cellSelector.currentSelectType);
if (type === COL) {
return {begin: 0, end: use(this._rowTotalCount)};
} else {
const start = use(this._cellSelector.row.start);
const end = use(this._cellSelector.row.end);
return {
begin: Math.min(start, end),
end: Math.max(start, end) + 1,
};
}
});
private _colRange = Computed.create<Range>(this, (use) => {
const type = use(this._cellSelector.currentSelectType);
if (type === ROW) {
return {begin: 0, end: use(this._colTotalCount)};
} else {
const start = use(this._cellSelector.col.start);
const end = use(this._cellSelector.col.end);
return {
begin: Math.min(start, end),
end: Math.max(start, end) + 1,
};
}
});
private _summary = Observable.create<SummaryPart[]>(this, []);
private _delayedRecalc = this.autoDispose(Delay.create());
constructor(
private _cellSelector: CellSelector,
private _tableData: TableData,
private _sortedRows: SortedRowSet,
private _viewFields: ko.Computed<KoArray<ViewFieldRec>>,
) {
super();
this.autoDispose(this._sortedRows.getKoArray().subscribe(this._onSpliceChange, this, 'spliceChange'));
const onRowNotify = this._onRowNotify.bind(this);
this._sortedRows.on('rowNotify', onRowNotify);
this.onDispose(() => this._sortedRows.off('rowNotify', onRowNotify));
this.autoDispose(subscribe(this._rowRange, this._colRange,
() => this._scheduleRecalc()));
this.autoDispose(isNarrowScreenObs().addListener((isNarrow) => {
if (isNarrow) { return; }
// No calculations occur while the screen is narrow, so we need to schedule one.
this._scheduleRecalc();
}));
}
public buildDom() {
return cssSummary(
dom.forEach(this._summary, ({id, label, value, clickToCopy}) =>
cssSummaryPart(
label ? dom('span', cssLabelText(label), cssCopyIcon('Copy')) : null,
value,
cssSummaryPart.cls('-copyable', Boolean(clickToCopy)),
(clickToCopy ? dom.on('click', (ev, elem) => doCopy(value, elem)) : null),
testId(id),
)
),
);
}
private _onSpliceChange(splice: {start: number}) {
const rowRange = this._rowRange.get();
const rowCount = rowRange.end - rowRange.begin;
if (rowCount === 1) { return; }
if (splice.start >= rowRange.end) { return; }
// We could be smart here and only recalculate when the splice affects our selection. But for
// that to make sense, the selection itself needs to be smart. Currently, the selection is
// lost whenever the cursor is affected. For example, when you have a selection and another
// user adds/removes columns or rows before the selection, the selection won't be shifted
// with the cursor, and will instead be cleared. Since we can't always rely on the selection
// being there, we'll err on the safe side and always schedule a recalc.
this._scheduleRecalc();
}
private _onRowNotify(rows: RowsChanged) {
const rowRange = this._rowRange.get();
if (rows === ALL) {
this._scheduleRecalc();
} else {
const rowArray = this._sortedRows.getKoArray().peek();
const rowIdSet = new Set(rows);
for (let r = rowRange.begin; r < rowRange.end; r++) {
if (rowIdSet.has(rowArray[r])) {
this._scheduleRecalc();
break;
}
}
}
}
/**
* Schedules a re-calculation to occur in the immediate future.
*
* May be called repeatedly, but only a single re-calculation will be scheduled, to
* avoid queueing unnecessary amounts of work.
*/
private _scheduleRecalc() {
// `_recalc` may take a non-trivial amount of time, so we defer until the stack is clear.
this._delayedRecalc.schedule(0, () => this._recalc());
}
private _recalc() {
const rowRange = this._rowRange.get();
const colRange = this._colRange.get();
let rowCount = rowRange.end - rowRange.begin;
let colCount = colRange.end - colRange.begin;
const cellCount = rowCount * colCount;
const summary: SummaryPart[] = [];
// Do nothing on narrow screens, because we haven't come up with a place to render sum anyway.
if (cellCount > 1 && !isNarrowScreen()) {
if (cellCount <= MAX_CELLS_TO_SCAN) {
const rowArray = this._sortedRows.getKoArray().peek();
const fields = this._viewFields.peek().peek();
let countNumeric = 0;
let countNonEmpty = 0;
let sum = 0;
let sumFormatter: BaseFormatter|null = null;
const rowIndices: number[] = [];
for (let r = rowRange.begin; r < rowRange.end; r++) {
const rowId = rowArray[r];
if (rowId === undefined || rowId === 'new') {
// We can run into this whenever the selection gets out of sync due to external
// changes, like another user removing some rows. For now, we'll skip rows that are
// still selected and no longer exist, but the real TODO is to better update the
// selection so that it doesn't have out-of-date and invalid ranges.
rowCount -= 1;
continue;
}
rowIndices.push(this._tableData.getRowIdIndex(rowId)!);
}
for (let c = colRange.begin; c < colRange.end; c++) {
const field = fields[c];
if (field === undefined) {
// Like with rows (see comment above), we need to watch out for out-of-date ranges.
colCount -= 1;
continue;
}
const col = fields[c].column.peek();
const displayCol = fields[c].displayColModel.peek();
const colType = col.type.peek();
const visibleColType = fields[c].visibleColModel.peek().type.peek();
const effectiveColType = visibleColType ?? colType;
const displayColId = displayCol.colId.peek();
// Note: we get values from the display column so that reference columns displaying
// numbers are included in the computed sum. Unfortunately, that also means we can't
// show a count of non-empty references. For now, that's a trade-off we'll have to make,
// but in the future it should be possible to allow showing multiple summary parts with
// some level of configurability.
const values = this._tableData.getColValues(displayColId);
if (!values) {
throw new UserError(`Invalid column ${this._tableData.tableId}.${displayColId}`);
}
const isNumeric = ['Numeric', 'Int', 'Any'].includes(effectiveColType);
const isEmpty: undefined | ((value: CellValue) => boolean) = (
colType.startsWith('Ref:') && !visibleColType ? value => (value === 0) :
isRefListType(colType) || isListType(effectiveColType) ? isEmptyList :
undefined
);
// The loops below are optimized, minimizing the amount of work done per row. For
// example, column values are retrieved in bulk above instead of once per row. In one
// unscientific test, they take 30-60ms per million numeric cells.
//
// TODO: Add a benchmark test suite that automates checking for performance regressions.
if (isNumeric) {
if (!sumFormatter) {
sumFormatter = fields[c].formatter.peek();
}
for (const i of rowIndices) {
const value = values[i];
if (typeof value === 'number') {
countNumeric++;
sum += value;
} else if (value !== null && value !== undefined && value !== '' && !isEmpty?.(value)) {
countNonEmpty++;
}
}
} else {
for (const i of rowIndices) {
const value = values[i];
if (value !== null && value !== undefined && value !== '' && !isEmpty?.(value)) {
countNonEmpty++;
}
}
}
}
if (countNumeric > 0) {
const sumValue = sumFormatter ? sumFormatter.formatAny(sum) : String(sum);
summary.push({id: 'sum', label: 'Sum ', value: sumValue, clickToCopy: true});
} else {
summary.push({id: 'count', label: 'Count ', value: String(countNonEmpty), clickToCopy: true});
}
}
summary.push({id: 'dimensions', label: '', value: `${rowCount}${colCount}`});
}
this._summary.set(summary);
}
}
async function doCopy(value: string, elem: Element) {
await copyToClipboard(value);
showTransientTooltip(elem, 'Copied to clipboard', {key: 'copy-selection-summary'});
}
const cssSummary = styled('div', `
position: absolute;
bottom: -18px;
height: 18px;
line-height: 18px;
display: flex;
column-gap: 8px;
width: 100%;
justify-content: end;
color: ${theme.text};
/* Small hack: override the backdrop when viewing raw data to improve visibility. */
background-color: ${theme.mainPanelBg};
font-family: ${vars.fontFamilyData};
@media print {
& {
display: none;
}
}
`);
// Note: the use of an extra element for the background is to set its opacity, to make it a bit
// lighter (or darker, in dark-mode) than actual mediumGrey, without defining a special color.
const cssSummaryPart = styled('div', `
padding: 0 8px;
border-radius: 4px;
border-top-left-radius: 0px;
border-top-right-radius: 0px;
border-top: none;
z-index: 100;
position: relative;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&-copyable:hover {
cursor: pointer;
}
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
background-color: ${colors.mediumGrey};
opacity: 0.8;
z-index: -1;
}
`);
const cssLabelText = styled('span', `
font-size: ${vars.xsmallFontSize};
text-transform: uppercase;
position: relative;
margin-right: 4px;
.${cssSummaryPart.className}-copyable:hover & {
visibility: hidden;
}
`);
const cssCopyIcon = styled(icon, `
position: absolute;
top: 0;
margin: 1px 0 0 4px;
--icon-color: ${theme.controlFg};
display: none;
.${cssSummaryPart.className}-copyable:hover & {
display: block;
}
`);

@ -1,291 +0,0 @@
/**
* Selector takes care of attaching callbacks to the relevant mouse events on the given view.
* Selection and dragging/dropping consists of 3 phases: mouse down -> mouse move -> mouse up
* The Selector class is purposefully lightweight because different views might have
* different select/drag/drop behavior. Most of the work is done in the callbacks
* provided to the Selector class.
*
* Usage:
Selectors are instantiated with a view.
@param{view}: The view containing the selectable/draggable elements
* Views must also supply the Selector class with mousedown/mousemove/mouseup callbacks and
* the associated element's that listen for the mouse events.
* through registerMouseHandlers.
*/
/* globals document */
var ko = require('knockout');
var _ = require('underscore');
var dispose = require('../lib/dispose');
var gutil = require('app/common/gutil');
const {isWin} = require('app/client/lib/browserInfo')
var ROW = 'row';
var COL = 'col';
var CELL = 'cell';
var NONE = '';
var SELECT = 'select';
var DRAG = 'drag';
exports.ROW = ROW;
exports.COL = COL;
exports.CELL = CELL;
exports.NONE = NONE;
/**
* @param {Object} view
* @param {Object} opt
* @param {function} opt.isDisabled - Is this selector disabled? Allows caller to specify
* conditions for temporarily disabling capturing of mouse events.
*/
function Selector(view, opt) {
this.view = view;
// TODO: There should be a better way to ensure that select/drag doesnt happen when clicking
// on these things. Also, these classes should not be in the generic Selector class.
// TODO: get rid of the Selector class entirely and make this a Cell/GridSelector class specifically
// for GridView(and its derived views).
this.exemptClasses = [
'glyphicon-pencil',
'ui-resizable-handle',
'dropdown-toggle',
];
opt = opt || {};
this.isDisabled = opt.isDisabled || _.constant(false);
this.isWindows = isWin();
}
/**
* Register mouse callbacks to various sources.
* @param {Object} callbacks - an object containing mousedown/mouseup/mouseup functions
* for selecting and dragging, along with with the source string name and target element
* string name to which the mouse events must listen on.
* @param {string} handlerName - string name of the kind of element that the mouse callbacks
* are acting on.
* handlerName is used to deduce what kind of element is triggering the mouse callbacks
* The alternative is to look at triggering DOM element's css classes which is more hacky.
*/
Selector.prototype.registerMouseHandlers = function(callbacks, handlerName) {
this.setCallbackDefaults(callbacks);
var self = this;
this.view.onEvent(callbacks.mousedown.source, 'mousedown', callbacks.mousedown.elemName,
function(elem, event) {
if (self.isExemptMouseTarget(event) || event.button !== 0 || self.isDisabled()) {
return true; // Do nothing if the mouse event if exempt or not a left click
}
if (!self.isSelected(elem, handlerName) && !callbacks.disableSelect()) {
self.applyCallbacks(SELECT, callbacks, elem, event);
} else if (!callbacks.disableDrag()) {
self.applyCallbacks(DRAG, callbacks, elem, event);
}
});
};
Selector.prototype.isExemptMouseTarget = function(event) {
var cl = event.target.classList;
return _.some(this.exemptClasses, cl.contains.bind(cl));
};
Selector.prototype.setCallbackDefaults = function(callbacks) {
_.defaults(callbacks, {'mousedown': {}, 'mousemove': {}, 'mouseup': {},
'disableDrag': _.constant(false), 'disableSelect': _.constant(false)}
);
_.defaults(callbacks.mousedown, {'select': _.noop, 'drag': _.noop, 'elemName': null,
'source': null});
_.defaults(callbacks.mousemove, {'select': _.noop, 'drag': _.noop, 'elemName': null,
'source': document});
_.defaults(callbacks.mouseup, {'select': _.noop, 'drag': _.noop, 'elemName': null,
'source': document});
};
/**
* Applies the drag or select callback for mousedown and then registers
* the appropriate mousemove and mouseup callbacks. We only register mousemove/mouseup
* after seeing a mousedown event so that we don't have to constantly listen for
* mousemove/mouseup.
* @param {String} dragOrSelect - string that is either 'drag' or 'select' which denotes
* which mouse methods to apply on mouse events.
* @param {Object} callbacks - an object containing mousedown/mouseup/mouseup functions
* for selecting and dragging, along with with the source string name and target element
* string name to which the mouse events must listen on.
*/
Selector.prototype.applyCallbacks = function(dragOrSelect, callbacks, mouseDownElem, mouseDownEvent) {
console.assert(dragOrSelect === DRAG || dragOrSelect === SELECT);
var self = this;
callbacks.mousedown[dragOrSelect].call(this.view, mouseDownElem, mouseDownEvent);
this.view.onEvent(callbacks.mousemove.source, 'mousemove', function(elem, event) {
// On windows, when browser doesn't have focus, the first click produces artificial mousemove
// event. Fortunately, the mousemove event has the same coordinates as the mousedown event, so
// we will ignore it.
// Related issues:
// https://bugs.chromium.org/p/chromium/issues/detail?id=161464
// https://bugs.chromium.org/p/chromium/issues/detail?id=721341#c34
if (self.isWindows) {
if (event.screenX === mouseDownEvent.screenX && event.screenY === mouseDownEvent.screenY) {
return;
}
}
callbacks.mousemove[dragOrSelect].call(self.view, elem, event);
});
this.view.onEvent(callbacks.mouseup.source, 'mouseup', function(elem, event) {
callbacks.mouseup[dragOrSelect].call(self.view, elem, event);
self.view.clearEvent(callbacks.mousemove.source, 'mousemove');
self.view.clearEvent(callbacks.mouseup.source, 'mouseup');
if (dragOrSelect === DRAG) self.currentDragType(NONE);
});
};
// ===========================================================================
// CELL SELECTOR
function CellSelector(view, opt) {
Selector.call(this, view, opt);
// row or col.start denotes the anchor/initial index of the select range.
// start is not necessarily smaller than end.
// IE: clicking on col 10 and dragging until the mouse is on col 5 will yield: start = 10, end = 5
this.row = {
start: ko.observable(0),
end: ko.observable(0),
linePos: ko.observable('0px'),
dropIndex: ko.observable(-1),
};
this.col = {
start: ko.observable(0),
end: ko.observable(0),
linePos: ko.observable('0px'),
dropIndex: ko.observable(-1),
};
this.currentSelectType = ko.observable(NONE);
this.currentDragType = ko.observable(NONE);
this.autoDispose(this.view.cursor.rowIndex.subscribeInit(function(rowIndex) {
this.setToCursor();
}, this));
this.autoDispose(this.view.cursor.fieldIndex.subscribeInit(function(colIndex) {
this.setToCursor();
}, this));
}
dispose.makeDisposable(CellSelector);
_.extend(CellSelector.prototype, Selector.prototype);
CellSelector.prototype.setToCursor = function(elemType) {
// Must check that the view contains cursor.rowIndex/cursor.fieldIndex
// in case it has changed.
if (this.view.cursor.rowIndex) {
this.row.start(this.view.cursor.rowIndex());
this.row.end(this.view.cursor.rowIndex());
}
if (this.view.cursor.fieldIndex) {
this.col.start(this.view.cursor.fieldIndex());
this.col.end(this.view.cursor.fieldIndex());
}
this.currentSelectType(elemType || NONE);
};
CellSelector.prototype.containsCell = function(rowIndex, colIndex) {
return this.containsCol(colIndex) && this.containsRow(rowIndex);
};
CellSelector.prototype.containsRow = function(rowIndex) {
return gutil.between(rowIndex, this.row.start(), this.row.end());
};
CellSelector.prototype.containsCol = function(colIndex) {
return gutil.between(colIndex, this.col.start(), this.col.end());
};
CellSelector.prototype.isSelected = function(elem, handlerName) {
if (handlerName !== this.currentSelectType()) return false;
// TODO: this only works with view: GridView.
// But it seems like we only ever use selectors with gridview anyway
let row = this.view.domToRowModel(elem, handlerName);
let col = this.view.domToColModel(elem, handlerName);
switch (handlerName) {
case ROW:
return this.containsRow(row._index());
case COL:
return this.containsCol(col._index());
case CELL:
return this.containsCell(row._index(), col._index());
default:
console.error('Given element is not a row, cell or column');
return false;
}
};
CellSelector.prototype.isRowSelected = function(rowIndex) {
return this.isCurrentSelectType(COL) || this.containsRow(rowIndex);
};
CellSelector.prototype.isColSelected = function(colIndex) {
return this.isCurrentSelectType(ROW) || this.containsCol(colIndex);
};
CellSelector.prototype.isCellSelected = function(rowIndex, colIndex) {
return this.isColSelected(colIndex) && this.isRowSelected(rowIndex);
};
CellSelector.prototype.onlyCellSelected = function(rowIndex, colIndex) {
return (this.row.start() === rowIndex && this.row.end() === rowIndex) &&
(this.col.start() === colIndex && this.col.end() === colIndex);
};
CellSelector.prototype.isCurrentSelectType = function(elemType) {
return this._isCurrentType(this.currentSelectType(), elemType);
};
CellSelector.prototype.isCurrentDragType = function(elemType) {
return this._isCurrentType(this.currentDragType(), elemType);
};
CellSelector.prototype._isCurrentType = function(currentType, elemType) {
console.assert([ROW, COL, CELL, NONE].indexOf(elemType) !== -1);
return currentType === elemType;
};
CellSelector.prototype.colLower = function() {
return Math.min(this.col.start(), this.col.end());
};
CellSelector.prototype.colUpper = function() {
return Math.max(this.col.start(), this.col.end());
};
CellSelector.prototype.rowLower = function() {
return Math.min(this.row.start(), this.row.end());
};
CellSelector.prototype.rowUpper = function() {
return Math.max(this.row.start(), this.row.end());
};
CellSelector.prototype.colCount = function() {
return this.colUpper() - this.colLower() + 1;
};
CellSelector.prototype.rowCount = function() {
return this.rowUpper() - this.rowLower() + 1;
};
CellSelector.prototype.selectArea = function(rowStartIdx, colStartIdx, rowEndIdx, colEndIdx) {
this.row.start(rowStartIdx);
this.col.start(colStartIdx);
this.row.end(rowEndIdx);
this.col.end(colEndIdx);
// Only select the area if it's not a single cell
if (this.colCount() > 1 || this.rowCount() > 1) {
this.currentSelectType(CELL);
}
};
exports.CellSelector = CellSelector;

@ -15,7 +15,7 @@ import {reportError} from 'app/client/models/errors';
import {filterBar} from 'app/client/ui/FilterBar';
import {viewSectionMenu} from 'app/client/ui/ViewSectionMenu';
import {buildWidgetTitle} from 'app/client/ui/WidgetTitle';
import {mediaSmall, testId, theme} from 'app/client/ui2018/cssVars';
import {isNarrowScreenObs, mediaSmall, testId, theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
import {mod} from 'app/common/gutil';
@ -309,7 +309,7 @@ export function buildViewSectionDom(options: {
)),
dom.maybe((use) => use(vs.activeFilterBar) || use(vs.isRaw) && use(vs.activeFilters).length,
() => dom.create(filterBar, vs)),
dom.maybe<BaseView|null>(vs.viewInstance, (viewInstance) =>
dom.maybe<BaseView|null>(vs.viewInstance, (viewInstance) => [
dom('div.view_data_pane_container.flexvbox',
cssResizing.cls('', isResizing),
dom.maybe(viewInstance.disableEditing, () =>
@ -319,9 +319,10 @@ export function buildViewSectionDom(options: {
dom('div.viewsection_truncated', 'Not all data is shown')
),
dom.cls((use) => 'viewsection_type_' + use(vs.parentKey)),
viewInstance.viewPane
)
),
viewInstance.viewPane,
),
dom.maybe(use => !use(isNarrowScreenObs()), () => viewInstance.selectionSummary?.buildDom()),
]),
dom.on('mousedown', () => { viewModel?.activeSectionId(sectionRowId); }),
);
}

@ -32,6 +32,7 @@ declare module "app/client/components/BaseView" {
import {Cursor, CursorPos} from 'app/client/components/Cursor';
import {GristDoc} from 'app/client/components/GristDoc';
import {SelectionSummary} from 'app/client/components/SelectionSummary';
import {Disposable} from 'app/client/lib/dispose';
import BaseRowModel from "app/client/models/BaseRowModel";
import {DataRowModel} from 'app/client/models/DataRowModel';
@ -61,6 +62,7 @@ declare module "app/client/components/BaseView" {
public disableEditing: ko.Computed<boolean>;
public isTruncated: ko.Observable<boolean>;
public tableModel: DataTableModel;
public selectionSummary?: SelectionSummary;
constructor(gristDoc: GristDoc, viewSectionModel: any, options?: {addNewRow?: boolean, isPreview?: boolean});
public setCursorPos(cursorPos: CursorPos): void;

@ -30,6 +30,12 @@ export function mouseDrag(onStart: MouseDragStart): DomElementMethod {
return (elem) => { mouseDragElem(elem, onStart); };
}
// Same as mouseDragElem, but listens for mousedown on descendants of elem that match selector.
export function mouseDragMatchElem(elem: HTMLElement, selector: string, onStart: MouseDragStart): IDisposable {
return dom.onMatchElem(elem, selector, 'mousedown',
(ev, el) => _startDragging(ev as MouseEvent, el as HTMLElement, onStart));
}
function _startDragging(startEv: MouseEvent, elem: HTMLElement, onStart: MouseDragStart) {
const dragHandler = onStart(startEv, elem);
if (dragHandler) {

@ -169,18 +169,24 @@ export class TableData extends ActionDispatcher implements SkippableRows {
return this._rowMap.has(rowId);
}
/**
* Returns the index of the given rowId, if it exists, in the same unstable order that's
* returned by getRowIds() and getColValues().
*/
public getRowIdIndex(rowId: UIRowId): number|undefined {
return this._rowMap.get(rowId as number);
}
/**
* Given a column name, returns a function that takes a rowId and returns the value for that
* column of that row. The returned function is faster than getValue() calls.
*/
public getRowPropFunc(colId: string): UIRowFunc<CellValue|undefined> {
const colData = this._columns.get(colId);
if (!colData) { return () => undefined; }
const values = colData.values;
const rowMap = this._rowMap;
return (rowId: UIRowId) => {
const colData = this._columns.get(colId);
if (!colData) { return undefined; }
const values = colData.values;
return values[rowMap.get(rowId as number)!];
};
return (rowId: UIRowId) => values[rowMap.get(rowId as number)!];
}
// By default, no rows are skippable, all are kept.

Binary file not shown.

@ -0,0 +1,211 @@
import {assert, driver, Key, WebElement} from 'mocha-webdriver';
import * as gu from 'test/nbrowser/gristUtils';
import {setupTestSuite} from 'test/nbrowser/testUtils';
interface CellPosition {
/** 0-based column index. */
col: number;
/** 0-based row index. */
row: number;
}
interface SelectionSummary {
dimensions: string;
count: number | null;
sum: string | null;
}
describe('SelectionSummary', function () {
this.timeout(20000);
const cleanup = setupTestSuite();
before(async function() {
const session = await gu.session().personalSite.login();
await session.tempDoc(cleanup, 'SelectionSummary.grist');
});
async function assertSelectionSummary(summary: SelectionSummary | null) {
if (!summary) {
assert.isFalse(await driver.find('.test-selection-summary-dimensions').isPresent());
assert.isFalse(await driver.find('.test-selection-summary-count').isPresent());
assert.isFalse(await driver.find('.test-selection-summary-sum').isPresent());
return;
}
const {dimensions, count, sum} = summary;
await gu.waitToPass(async () => assert.equal(
await driver.find('.test-selection-summary-dimensions').getText(),
dimensions
), 500);
if (count === null) {
assert.isFalse(await driver.find('.test-selection-summary-count').isPresent());
} else {
await gu.waitToPass(async () => assert.equal(
await driver.find('.test-selection-summary-count').getText(),
`COUNT ${count}`
), 500);
}
if (sum === null) {
assert.isFalse(await driver.find('.test-selection-summary-sum').isPresent());
} else {
await gu.waitToPass(async () => assert.equal(
await driver.find('.test-selection-summary-sum').getText(),
`SUM ${sum}`
), 500);
}
}
function shiftClick(el: WebElement) {
return driver.withActions((actions) => actions.keyDown(Key.SHIFT).click(el).keyUp(Key.SHIFT));
}
async function selectAndAssert(start: CellPosition, end: CellPosition, summary: SelectionSummary | null) {
const {col: startCol, row: startRow} = start;
await gu.getCell(startCol, startRow + 1).click();
const {col: endCol, row: endRow} = end;
await shiftClick(await gu.getCell(endCol, endRow + 1));
await assertSelectionSummary(summary);
}
it('does not display anything if only a single cell is selected', async function () {
for (const [col, row] of [[0, 1], [2, 3]]) {
await gu.getCell(col, row).click();
await assertSelectionSummary(null);
}
});
it('displays sum if the selection contains numbers', async function () {
await selectAndAssert({col: 0, row: 0}, {col: 0, row: 6}, {
dimensions: '71',
count: null,
sum: '$135,692,590',
});
await selectAndAssert({col: 0, row: 3}, {col: 0, row: 6}, {
dimensions: '41',
count: null,
sum: '$135,679,011',
});
await selectAndAssert({col: 4, row: 0}, {col: 4, row: 6}, {
dimensions: '71',
count: null,
sum: '135692590',
});
await selectAndAssert({col: 0, row: 0}, {col: 4, row: 6}, {
dimensions: '75',
count: null,
sum: '$271,385,168.02',
});
});
it('uses formatter of the first (leftmost) numeric column', async function () {
// Column 0 is U.S. currency, while column 1 is just a plain decimal number.
await selectAndAssert({col: 0, row: 0}, {col: 1, row: 6}, {
dimensions: '72',
count: null,
sum: '$135,692,578.02',
});
await selectAndAssert({col: 1, row: 0}, {col: 1, row: 6}, {
dimensions: '71',
count: null,
sum: '-11.98',
});
// The entire selection (spanning 6 columns) uses the formatter of column 0.
await selectAndAssert({col: 0, row: 0}, {col: 5, row: 6}, {
dimensions: '76',
count: null,
sum: '$271,385,156.04',
});
});
it("displays count if the selection doesn't contain numbers", async function () {
await selectAndAssert({col: 2, row: 0}, {col: 2, row: 6}, {
dimensions: '71',
count: 5,
sum: null,
});
await selectAndAssert({col: 2, row: 3}, {col: 2, row: 6}, {
dimensions: '41',
count: 2,
sum: null,
});
await selectAndAssert({col: 2, row: 0}, {col: 3, row: 5}, {
dimensions: '62',
count: 11,
sum: null,
});
// Scroll horizontally to the end of the table.
await gu.sendKeys(Key.END);
await selectAndAssert({col: 7, row: 0}, {col: 10, row: 4}, {
dimensions: '54',
count: 7,
sum: null,
});
await selectAndAssert({col: 10, row: 0}, {col: 12, row: 6}, {
dimensions: '73',
count: 5,
sum: null,
});
});
it('uses the show column of reference columns for computations', async function () {
// Column 6 is a Reference column pointing to column 0.
await gu.sendKeys(Key.HOME);
await selectAndAssert({col: 6, row: 0}, {col: 6, row: 6}, {
dimensions: '71',
count: null,
sum: '-$123,456',
});
// Column 7 is a Reference List column pointing to column 0. At this time, it
// only displays counts (but flattening sums also seems like intuitive behavior).
await gu.sendKeys(Key.END);
await selectAndAssert({col: 7, row: 0}, {col: 7, row: 6}, {
dimensions: '71',
count: 2,
sum: null,
});
});
it('updates whenever the selection changes', async function () {
// Scroll horizontally to the beginning of the table.
await gu.sendKeys(Key.HOME);
// Select a region of the table.
await selectAndAssert({col: 0, row: 2}, {col: 0, row: 6}, {
dimensions: '51',
count: null,
sum: '$135,691,356',
});
// Without de-selecting, use keyboard shortcuts to grow the selection to the right.
await gu.sendKeys(Key.chord(Key.SHIFT, Key.ARROW_RIGHT));
// Check that the selection summary was updated.
await assertSelectionSummary({
dimensions: '52',
count: null,
sum: '$135,691,368.5',
});
});
it('displays correct sum when all rows/columns are selected', async function () {
await driver.find(".gridview_data_corner_overlay").click();
await assertSelectionSummary({
dimensions: '714',
count: null,
sum: '$271,261,700.04',
});
});
describe('on narrow screens', function() {
gu.narrowScreen();
it('is not visible', async function() {
await assertSelectionSummary(null);
await selectAndAssert({col: 0, row: 0}, {col: 0, row: 6}, null);
});
});
});
Loading…
Cancel
Save