mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
d3d50cdca8
Test Plan: Tested manually, doesn't seem worth a dedicated test. Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D3653
340 lines
12 KiB
TypeScript
340 lines
12 KiB
TypeScript
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};
|
||
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;
|
||
/* Set explicit backdrop to improve visibility in raw data views. */
|
||
background-color: ${theme.mainPanelBg};
|
||
|
||
&-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;
|
||
}
|
||
`);
|