mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
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};
|
|||
|
/* 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;
|
|||
|
}
|
|||
|
`);
|