You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
gristlabs_grist-core/app/client/components/GridView.js

2140 lines
86 KiB

/* globals $, window */
const _ = require('underscore');
const ko = require('knockout');
const debounce = require('lodash/debounce');
const gutil = require('app/common/gutil');
const BinaryIndexedTree = require('app/common/BinaryIndexedTree');
const {Sort} = require('app/common/SortSpec');
const dom = require('../lib/dom');
const kd = require('../lib/koDom');
const kf = require('../lib/koForm');
const koDomScrolly = require('../lib/koDomScrolly');
const tableUtil = require('../lib/tableUtil');
const {addToSort, sortBy} = require('../lib/sortUtil');
const commands = require('./commands');
const viewCommon = require('./viewCommon');
const Base = require('./Base');
const BaseView = require('./BaseView');
const selector = require('./CellSelector');
const {CopySelection} = require('./CopySelection');
const {SelectionSummary} = require('./SelectionSummary');
const koUtil = require('app/client/lib/koUtil');
const convert = require('color-convert');
const {renderAllRows} = require('app/client/components/Printing');
const {reportWarning} = require('app/client/models/errors');
const {reportUndo} = require('app/client/components/modals');
const {onDblClickMatchElem} = require('app/client/lib/dblclick');
const {FocusLayer} = require('app/client/lib/FocusLayer');
// Grist UI Components
const {dom: grainjsDom, Holder, Computed} = require('grainjs');
const {closeRegisteredMenu, menu} = require('../ui2018/menus');
const {RowContextMenu} = require('../ui/RowContextMenu');
const {setPopupToCreateDom} = require('popweasel');
const {CellContextMenu} = require('app/client/ui/CellContextMenu');
const {testId, isNarrowScreen} = require('app/client/ui2018/cssVars');
const {contextMenu} = require('app/client/ui/contextMenu');
const {
buildAddColumnMenu,
buildColumnContextMenu,
buildMultiColumnMenu,
calcFieldsCondition,
freezeAction,
} = require('app/client/ui/GridViewMenus');
const {mouseDragMatchElem} = require('app/client/ui/mouseDrag');
const {menuToggle} = require('app/client/ui/MenuToggle');
const {descriptionInfoTooltip, showTooltip} = require('app/client/ui/tooltips');
const {parsePasteForView} = require("./BaseView2");
const {NEW_FILTER_JSON} = require('app/client/models/ColumnFilter');
const {CombinedStyle} = require("app/client/models/Styles");
const {buildRenameColumn} = require('app/client/ui/ColumnTitle');
const {makeT} = require('app/client/lib/localization');
const {reportError} = require('app/client/models/AppModel');
const {RECORD_CARDS} = require('app/client/models/features');
const {urlState} = require('app/client/models/gristUrlState');
const t = makeT('GridView');
// A threshold for interpreting a motionless click as a click rather than a drag.
// Anything longer than this time (in milliseconds) should be interpreted as a drag
// even if there is no movement.
// This is relevant for distinguishing clicking an already-selected column in order
// to rename it, and starting to drag that column and then deciding to leave it where
// it was.
const SHORT_CLICK_IN_MS = 500;
// size of the plus width ()
const PLUS_WIDTH = 40;
// size of the row number field (we assume 4rem)
const ROW_NUMBER_WIDTH = 52;
/**
* GridView component implements the view of a grid of cells.
*/
function GridView(gristDoc, viewSectionModel, isPreview = false) {
BaseView.call(this, gristDoc, viewSectionModel, { isPreview, 'addNewRow': true });
this.viewSection = viewSectionModel;
//--------------------------------------------------
// Observables local to this view
// Some observables/variables used for select and drag/drop
this.dragX = ko.observable(0); // x coord of mouse during drag mouse down
this.dragY = ko.observable(0); // ^ for y coord
this.rowShadowAdjust = 0; // pixel dist from mouse click y-coord and the clicked row's top offset
this.colShadowAdjust = 0; // ^ for x-coord and clicked col's left offset
this.scrollLeft = ko.observable(0);
this.isScrolledLeft = this.autoDispose(ko.computed(() => this.scrollLeft() > 0));
this.scrollTop = ko.observable(0);
this.isScrolledTop = this.autoDispose(ko.computed(() => this.scrollTop() > 0));
this.cellSelector = selector.CellSelector.create(this, this);
if (!isPreview) {
// Disable summaries in import previews, for now.
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
this.selectedColumns = this.autoDispose(ko.pureComputed(() => {
const result = this.viewSection.viewFields().all().filter((field, index) => {
// During column removal or restoring (with undo), some columns fields
// might be disposed.
if (field.isDisposed() || field.column().isDisposed()) { return false; }
return this.cellSelector.containsCol(index);
});
return result;
}));
// Cache of column right offsets, used to determine the col select range
this.colRightOffsets = this.autoDispose(ko.computed(() => {
let fields = this.viewSection.viewFields();
let tree = new BinaryIndexedTree();
tree.fillFromValues(fields.all().map(field => field.widthDef()));
return tree;
}));
// Create observable holding current rowIndex that the view should be scrolled to.
// We will always notify, because we want to scroll to the row even when only the
// column is changed (in situation when the row is not visible).
this.visibleRowIndex = ko.observable(this.cursor.rowIndex()).extend({notify: 'always'});
// Create grain's Computed with current cursor position (we need it to examine position
// before the change and after).
this.currentPosition = Computed.create(this, (use) => ({
rowIndex : use(this.cursor.rowIndex),
fieldIndex : use(this.cursor.fieldIndex)
}));
// Add listener, and check if the cursor is indeed changed, if so, update the row
// and scroll it into view (using kd.scrollChildIntoView in buildDom function).
this.autoDispose(this.currentPosition.addListener((cur, prev) => {
if (cur.rowIndex !== prev.rowIndex || cur.fieldIndex !== prev.fieldIndex) {
this.visibleRowIndex(cur.rowIndex);
}
}));
this.autoDispose(this.cursor.fieldIndex.subscribe(idx => {
this._scrollColumnIntoView(idx);
}));
this.isPreview = isPreview;
// Some observables for the scroll markers that show that the view is cut off on a side.
this.scrollShadow = {
left: this.isScrolledLeft,
top: this.isScrolledTop
};
//--------------------------------------------------
// Set up row and column context menus.
this.ctxMenuHolder = Holder.create(this);
//--------------------------------------------------
// Set frozen columns variables
// keep track of the width for this component
this.width = ko.observable(0);
// helper for clarity
this.numFrozen = this.viewSection.numFrozen;
// calculate total width of all frozen columns
this.frozenWidth = this.autoDispose(ko.pureComputed(() => this.colRightOffsets().getSumTo(this.numFrozen())));
// show frozenLine when have some frozen columns and not scrolled left
this.frozenLine = this.autoDispose(ko.pureComputed(() => this.numFrozen() && !this.isScrolledLeft()));
// even if some columns are frozen, we still want to move them left
// when screen is too narrow - here we will calculate how much space
// is needed to move all the frozen columns left in order to show some
// unfrozen columns to user (by default we will try to show at least one not
// frozen column and a plus button)
this.frozenOffset = this.autoDispose(ko.computed(() => {
// get the last field
const fields = this.viewSection.viewFields().all();
const lastField = fields[fields.length-1];
// get the last field width (or zero - grid can have zero columns)
const revealWidth = lastField ? lastField.widthDef() : 0;
// calculate the offset: start from zero, then move all left to hide frozen columns,
// then to right to fill whole width, then to left to reveal last column and plus button
const initialOffset = -this.frozenWidth() - ROW_NUMBER_WIDTH + this.width() - revealWidth - PLUS_WIDTH;
// Final check - we actually don't want to have
// the split (between frozen and normal columns) be moved left too far,
// it should stop at the middle of the available grid space (whole width - row number width).
// This can happen when last column is too wide, and we are not able to show it in a full width.
// To calculate the middle point: hide all frozen columns (by moving them maximum to the left)
// and then move them to right by half width of the section.
const middleOffset = -this.frozenWidth() - ROW_NUMBER_WIDTH + this.width() / 2;
// final offset is the bigger number of those two (offsets are negative - so take
// the number that is closer to 0)
const offset = Math.floor(Math.max(initialOffset, middleOffset));
// offset must be negative (we are moving columns left), if we ended up moving
// frozen columns to the right, don't move them at all
return offset > 0 ? 0 : Math.abs(offset);
}));
// observable for left scroll - but return left only when columns are frozen
// this will be used to move frozen border alongside with the scrollpane
this.frozenScrollOffset = this.autoDispose(ko.computed(() => this.numFrozen() ? this.scrollLeft() : 0));
// observable that will indicate if shadow is needed on top of frozen columns
this.frozenShadow = this.autoDispose(ko.computed(() => {
return this.numFrozen() && this.frozenOffset() && this.isScrolledLeft();
}));
// calculate column right offsets
this.frozenPositions = this.autoDispose(this.viewSection.viewFields().map(function(field){
return ko.pureComputed(() => this.colRightOffsets().getSumTo(field._index()));
}, this));
// calculate frozen state for all columns
this.frozenMap = this.autoDispose(this.viewSection.viewFields().map(function(field){
return ko.pureComputed(() => field._index() < this.numFrozen());
}, this));
// Holds column index that is hovered, works only in full-edit formula mode.
this.hoverColumn = ko.observable(-1);
this._insertColumnIndex = ko.observable(null);
// Checks if there is active formula editor for a column in this table.
this.editingFormula = ko.pureComputed(() => {
const isEditing = this.gristDoc.docModel.editingFormula();
if (!isEditing) { return false; }
return this.viewSection.viewFields().all().some(field => field.editingFormula());
});
// Debounced method to change current hover column, this is needed
// as mouse when moved from field to field will switch the hover-column
// observable from current index to -1 and then immediately back to current index.
// With debounced version, call to set -1 that is followed by call to set back to the field index
// will be discarded.
this.changeHover = debounce((index) => {
if (this.isDisposed()) { return; }
if (this.editingFormula()) {
this.hoverColumn(index);
}
}, 0);
//--------------------------------------------------
// Create and attach the DOM for the view.
this.isColSelected = this.autoDispose(this.viewSection.viewFields().map(function(field) {
return this._createColSelectedObs(field);
}, this));
this.header = null;
this._cornerDom = null;
// dom for adding new column - used by freeze calculation
this._modField = null;
this.scrollPane = null;
this.viewPane = this.autoDispose(this.buildDom());
this.attachSelectorHandlers();
this.scrolly = koDomScrolly.getInstance(this.viewData);
//--------------------------------------------------
// Set up DOM event handling.
onDblClickMatchElem(this.scrollPane, '.field:not(.column_name)', () => this.activateEditorAtCursor());
if (!this.isPreview) {
grainjsDom.onMatchElem(this.scrollPane, '.field:not(.column_name)', 'contextmenu', (ev, elem) => this.onCellContextMenu(ev, elem), {useCapture: true});
}
this.onEvent(this.scrollPane, 'scroll', this.onScroll);
//--------------------------------------------------
// Command group implementing all grid level commands (except cancel)
this.autoDispose(commands.createGroup(GridView.gridCommands, this, this.viewSection.hasFocus));
// Cancel command is registered conditionally, only when there is an active
// cell selection. This command is also used by Raw Data Views, to close the Grid popup.
const hasSelection = this.autoDispose(ko.pureComputed(() =>
!this.cellSelector.isCurrentSelectType('') || this.copySelection()));
this.autoDispose(commands.createGroup(GridView.selectionCommands, this, hasSelection));
// Timer to allow short, otherwise non-actionable clicks on column names to trigger renaming.
this._colClickTime = 0; // Units: milliseconds.
}
Base.setBaseFor(GridView);
_.extend(GridView.prototype, BaseView.prototype);
// ======================================================================================
// GRID-LEVEL COMMANDS
// Moved out of all commands to support Raw Data Views (which use this command to close
// the Grid popup).
GridView.selectionCommands = {
clearCopySelection: function() { this._clearCopySelection(); },
cancel: function() { this.clearSelection(); }
}
GridView.gridCommands = {
cursorUp: function() {
// This conditional exists so that when users have the cursor in the top row but are not
// scrolled to the top i.e. in the case of a tall row, pressing up again will scroll the
// pane to the top.
if (this.cursor.rowIndex() === 0) {
this.scrollPane.scrollTop = 0;
}
this.cursor.rowIndex(this.cursor.rowIndex() - 1);
},
shiftDown: function() { this._shiftSelect({step: 1, direction: 'down'}); },
shiftUp: function() { this._shiftSelect({step: 1, direction: 'up'}); },
shiftRight: function() { this._shiftSelect({step: 1, direction: 'right'}); },
shiftLeft: function() { this._shiftSelect({step: 1, direction: 'left'}); },
ctrlShiftDown: function () { this._shiftSelectUntilFirstOrLastNonEmptyCell({direction: 'down'}); },
ctrlShiftUp: function () { this._shiftSelectUntilFirstOrLastNonEmptyCell({direction: 'up'}); },
ctrlShiftRight: function () { this._shiftSelectUntilFirstOrLastNonEmptyCell({direction: 'right'}); },
ctrlShiftLeft: function () { this._shiftSelectUntilFirstOrLastNonEmptyCell({direction: 'left'}); },
fillSelectionDown: function() { this.fillSelectionDown(); },
selectAll: function() { this.selectAll(); },
fieldEditSave: function() { this.cursor.rowIndex(this.cursor.rowIndex() + 1); },
// Re-define editField after fieldEditSave to make it take precedence for the Enter key.
editField: function() { closeRegisteredMenu(); this.scrollToCursor(true); this.activateEditorAtCursor(); },
insertFieldBefore: function(maybeKeyboardEvent) {
if (!maybeKeyboardEvent) {
this._openInsertColumnMenu(this.cursor.fieldIndex());
} else {
this.insertColumn(null, {index: this.cursor.fieldIndex()});
}
},
insertFieldAfter: function(maybeKeyboardEvent) {
if (!maybeKeyboardEvent) {
this._openInsertColumnMenu(this.cursor.fieldIndex() + 1);
} else {
this.insertColumn(null, {index: this.cursor.fieldIndex() + 1});
}
},
renameField: function() { this.renameColumn(this.cursor.fieldIndex()); },
hideFields: function() { this.hideFields(this.getSelection()); },
deleteFields: function() {
const selection = this.getSelection();
const count = selection.colIds.length;
this.deleteColumns(selection).then((result) => {
if (result !== false) {
reportUndo(this.gristDoc, `You deleted ${count} column${count > 1 ? 's' : ''}.`);
}
});
},
clearValues: function() { this.clearValues(this.getSelection()); },
clearColumns: function() { this._clearColumns(this.getSelection()); },
convertFormulasToData: function() { this._convertFormulasToData(this.getSelection()); },
copy: function() { return this.copy(this.getSelection()); },
cut: function() { return this.cut(this.getSelection()); },
paste: async function(pasteObj, cutCallback) {
await this.gristDoc.docData.bundleActions(null, () => this.paste(pasteObj, cutCallback));
await this.scrollToCursor(false);
},
sortAsc: function() {
sortBy(this.viewSection.activeSortSpec, this.currentColumn().getRowId(), Sort.ASC);
},
sortDesc: function() {
sortBy(this.viewSection.activeSortSpec, this.currentColumn().getRowId(), Sort.DESC);
},
addSortAsc: function() {
addToSort(this.viewSection.activeSortSpec, this.currentColumn().getRowId(), Sort.ASC);
},
addSortDesc: function() {
addToSort(this.viewSection.activeSortSpec, this.currentColumn().getRowId(), Sort.DESC);
},
toggleFreeze: function() {
// get column selection
const selection = this.getSelection();
// convert it menu option
const options = this._getColumnMenuOptions(selection);
// generate action that is available for freeze toggle
const action = freezeAction(options);
// if no action, do nothing
if (!action) { return; }
// if grist document is in readonly - simply change the value
// without saving
if (this.gristDoc.isReadonly.get()) {
this.viewSection.rawNumFrozen(action.numFrozen);
return;
}
this.viewSection.rawNumFrozen.setAndSave(action.numFrozen);
},
viewAsCard() {
if (!RECORD_CARDS()) { return; }
if (this._isRecordCardDisabled()) { return; }
const selectedRows = this.selectedRows();
const rowId = selectedRows[0];
const sectionId = this.viewSection.tableRecordCard().id();
const anchorUrlState = {hash: {rowId, sectionId, recordCard: true}};
urlState().pushUrl(anchorUrlState, {replace: true}).catch(reportError);
},
};
GridView.prototype.onTableLoaded = function() {
BaseView.prototype.onTableLoaded.call(this);
this.onScroll();
// Initialize scroll position.
this.scrollPane.scrollLeft = this.viewSection.lastScrollPos.scrollLeft;
this.scrolly.scrollToSavedPos(this.viewSection.lastScrollPos);
};
/**
* Update the bounds of the cell selector's selected range for Shift+Direction keyboard shortcuts.
*/
GridView.prototype._shiftSelect = function({step, direction}) {
const type = ['up', 'down'].includes(direction) ? selector.ROW : selector.COL;
const exemptType = type === selector.ROW ? selector.COL : selector.ROW;
if (this.cellSelector.isCurrentSelectType(exemptType)) { return; }
if (this.cellSelector.isCurrentSelectType(selector.NONE)) {
this.cellSelector.currentSelectType(selector.CELL);
}
let selectObs;
let maxVal;
if (type === 'row') {
selectObs = this.cellSelector.row.end;
maxVal = this.getLastDataRowIndex();
} else {
selectObs = this.cellSelector.col.end;
maxVal = this.viewSection.viewFields().peekLength - 1;
}
step = ['up', 'left'].includes(direction) ? -step : step;
const newVal = gutil.clamp(selectObs() + step, 0, maxVal);
selectObs(newVal);
if (type === 'row') {
this.scrolly.scrollRowIntoView(newVal);
} else {
this._scrollColumnIntoView(newVal);
}
};
/**
* Shifts the current selection in the specified `direction` until the first or last
* non-empty cell.
*
* If the current selection ends on an empty cell, the selection will be shifted to
* the first non-empty cell in the specified direction. Otherwise, the selection
* will be shifted to the last non-empty cell.
*/
GridView.prototype._shiftSelectUntilFirstOrLastNonEmptyCell = function({direction}) {
const steps = this._stepsToContent({direction});
if (steps > 0) { this._shiftSelect({step: steps, direction}); }
}
/**
* Gets the number of rows/columns until the first or last non-empty cell in the specified
* `direction`.
*/
GridView.prototype._stepsToContent = function ({direction}) {
const colEnd = this.cellSelector.col.end();
const rowEnd = this.cellSelector.row.end();
const cursorCol = this.cursor.fieldIndex();
const cursorRow = this.cursor.rowIndex();
const type = ['up', 'down'].includes(direction) ? selector.ROW : selector.COL;
const maxVal = type === selector.ROW
? this.getLastDataRowIndex()
: this.viewSection.viewFields().peekLength - 1;
// Get table data for the current selection plus additional data in the specified `direction`.
let selectionData;
switch (direction) {
case 'right': {
if (colEnd + 1 > maxVal) { return 0; }
selectionData = this._selectionData({colStart: colEnd, colEnd: maxVal, rowStart: cursorRow, rowEnd: cursorRow});
break;
}
case 'left': {
if (colEnd - 1 < 0) { return 0; }
selectionData = this._selectionData({colStart: 0, colEnd, rowStart: cursorRow, rowEnd: cursorRow});
break;
}
case 'up': {
if (rowEnd - 1 > maxVal) { return 0; }
selectionData = this._selectionData({colStart: cursorCol, colEnd: cursorCol, rowStart: 0, rowEnd});
break;
}
case 'down': {
if (rowEnd + 1 > maxVal) { return 0; }
selectionData = this._selectionData({colStart: cursorCol, colEnd: cursorCol, rowStart: rowEnd, rowEnd: maxVal});
break;
}
}
const {fields, rowIndices} = selectionData;
if (direction === 'left') {
// When moving selection left, we step through fields in reverse order.
fields.reverse();
}
if (direction === 'up') {
// When moving selection up, we step through rows in reverse order.
rowIndices.reverse();
}
// Prepare a map of field indexes to their respective column values. We'll consult these
// values below when looking for the first (or last) non-empty cell value in the direction
// of the new selection.
const colValuesByIndex = {};
for (const field of fields) {
const displayColId = field.displayColModel.peek().colId.peek();
colValuesByIndex[field._index()] = this.tableModel.tableData.getColValues(displayColId);
}
// Count the number of steps until the first or last non-empty cell.
let steps = 0;
if (type === selector.COL) {
// The selection is changing on the x-axis (i.e. the selected columns changed).
const rowIndex = rowIndices[0];
const isLastColEmpty = this._isCellValueEmpty(colValuesByIndex[colEnd][rowIndex]);
const isNextColEmpty = this._isCellValueEmpty(
colValuesByIndex[colEnd + (direction === 'right' ? 1 : -1)][rowIndex]);
const shouldStopOnEmptyValue = !isLastColEmpty && !isNextColEmpty;
for (let i = 1; i < fields.length; i++) {
const hasEmptyValues = this._isCellValueEmpty(colValuesByIndex[fields[i]._index()][rowIndex]);
if (hasEmptyValues && shouldStopOnEmptyValue) {
return steps;
} else if (!hasEmptyValues && !shouldStopOnEmptyValue) {
return steps + 1;
}
steps += 1;
}
} else {
// The selection is changing on the y-axis (i.e. the selected rows changed).
const colValues = colValuesByIndex[fields[0]._index()];
const isLastRowEmpty = this._isCellValueEmpty(colValues[rowIndices[0]]);
const isNextRowEmpty = this._isCellValueEmpty(colValues[rowIndices[1]]);
const shouldStopOnEmptyValue = !isLastRowEmpty && !isNextRowEmpty;
for (let i = 1; i < rowIndices.length; i++) {
const hasEmptyValues = this._isCellValueEmpty(colValues[rowIndices[i]]);
if (hasEmptyValues && shouldStopOnEmptyValue) {
return steps;
} else if (!hasEmptyValues && !shouldStopOnEmptyValue) {
return steps + 1;
}
steps += 1;
}
}
return steps;
}
GridView.prototype._selectionData = function({colStart, colEnd, rowStart, rowEnd}) {
const fields = [];
for (let i = colStart; i <= colEnd; i++) {
const field = this.viewSection.viewFields().at(i);
if (!field) { continue; }
fields.push(field);
}
const rowIndices = [];
for (let i = rowStart; i <= rowEnd; i++) {
const rowId = this.viewData.getRowId(i);
if (!rowId) { continue; }
rowIndices.push(this.tableModel.tableData.getRowIdIndex(rowId));
}
return {fields, rowIndices};
}
GridView.prototype._isCellValueEmpty = function(value) {
return value === null || value === undefined || value === '' || value === 'false';
}
/**
* Pastes the provided data at the current cursor.
*
* TODO: Handle the edge case where more columns are pasted than available.
*
* @param {Array} data - Array of arrays of data to be pasted. Each array represents a row.
* i.e. [["1-1", "1-2", "1-3"],
* ["2-1", "2-2", "2-3"]]
* @param {Function} cutCallback - If provided returns the record removal action needed for
* a cut.
*/
GridView.prototype.paste = async function(data, cutCallback) {
// TODO: If pasting into columns by which this view is sorted, rows may jump. It is still better
// to allow it, but we should "freeze" the affected rows to prevent them from jumping, until the
// user re-applies the sort manually. (This is a particularly bad experience when rows get
// dispersed by the sorting after paste.) We do attempt to keep the cursor in the same row as
// before even if it jumped. Note when addressing it: currently selected rows should be treated
// as frozen (and get marked as unsorted if necessary) for any update even if the update comes
// from a different peer.
// convert row-wise data to column-wise so that it better resembles a user action
let pasteData = _.unzip(data);
let pasteHeight = pasteData[0].length;
let pasteWidth = pasteData.length;
// figure out the size of the paste area
let outputHeight = Math.max(gutil.roundDownToMultiple(this.cellSelector.rowCount(), pasteHeight), pasteHeight);
let outputWidth = Math.max(gutil.roundDownToMultiple(this.cellSelector.colCount(), pasteWidth), pasteWidth);
// get the row ids that cover the paste
let topIndex = this.cellSelector.rowLower();
let updateRowIndices = _.range(topIndex, topIndex + outputHeight);
let updateRowIds = updateRowIndices.map(r => this.viewData.getRowId(r));
// get the col ids that cover the paste
let leftIndex = this.cellSelector.colLower();
let updateColIndices = _.range(leftIndex, leftIndex + outputWidth);
pasteData = gutil.growMatrix(pasteData, updateColIndices.length, updateRowIds.length);
let fields = this.viewSection.viewFields().peek();
let pasteFields = updateColIndices.map(i => fields[i] || null);
const richData = await parsePasteForView(pasteData, pasteFields, this.gristDoc);
let actions = this._createBulkActionsFromPaste(updateRowIds, richData);
if (actions.length > 0) {
let cursorPos = this.cursor.getCursorPos();
return this.sendPasteActions(cutCallback, actions)
.then(results => {
// If rows were added, get their rowIds from the action results.
let addRowIds = (actions[0][0] === 'BulkAddRecord' ? results[0] : []);
console.assert(addRowIds.length <= updateRowIds.length,
`Unexpected number of added rows: ${addRowIds.length} of ${updateRowIds.length}`);
let newRowIds = updateRowIds.slice(0, updateRowIds.length - addRowIds.length)
.concat(addRowIds);
// Restore the cursor to the right rowId, even if it jumped.
this.cursor.setCursorPos({rowId: cursorPos.rowId === 'new' ? addRowIds[0] : cursorPos.rowId});
// Restore the selection if it would select the correct rows.
let topRowIndex = this.viewData.getRowIndex(newRowIds[0]);
if (newRowIds.every((r, i) => r === this.viewData.getRowId(topRowIndex + i))) {
this.cellSelector.selectArea(topRowIndex, leftIndex,
topRowIndex + outputHeight - 1, leftIndex + outputWidth - 1);
}
commands.allCommands.clearCopySelection.run();
});
}
};
/**
* Given a matrix of values, and an array of colIds and rowId targets, this function returns
* an array of user actions needed to update the targets to the values in the matrix
* @param {Array} rowIds - An array of numbers, 'new' or null corresponding to the row ids will
* be updated or added. Numerical (proper) rowIds must come before special ones.
* @param {Object<string, Array<string>} bulkUpdate - Object from colId to array of column values.
*/
GridView.prototype._createBulkActionsFromPaste = function(rowIds, bulkUpdate) {
if (_.isEmpty(bulkUpdate)) {
return [];
}
let addRows = rowIds.filter(rowId => rowId === null || rowId === 'new').length;
let updateRows = rowIds.length - addRows;
let actions = [];
if (addRows > 0) {
actions.push(['BulkAddRecord', gutil.arrayRepeat(addRows, null),
_.mapObject(bulkUpdate, values => values.slice(-addRows))
]);
}
if (updateRows > 0) {
actions.push(['BulkUpdateRecord', rowIds.slice(0, updateRows),
_.mapObject(bulkUpdate, values => values.slice(0, updateRows))
]);
}
return this.prepTableActions(actions);
};
/**
* Fills currently selected grid with the contents of the top row in that selection.
*/
GridView.prototype.fillSelectionDown = function() {
var rowLower = this.cellSelector.rowLower();
var rowIds = _.times(this.cellSelector.rowCount(), i => this.viewData.getRowId(rowLower + i));
if (rowIds.length <= 1) {
return;
}
var colLower = this.cellSelector.colLower();
var fields = this.viewSection.viewFields().peek();
var colIds = _.times(this.cellSelector.colCount(), i => {
if (!fields[colLower + i].column().isFormula()) {
return fields[colLower + i].colId();
}
}).filter(colId => colId);
var colInfo = _.object(colIds, colIds.map(colId => {
var val = this.tableModel.tableData.getValue(rowIds[0], colId);
return rowIds.map(() => val);
}));
this.tableModel.sendTableAction(["BulkUpdateRecord", rowIds, colInfo]);
};
/**
* Returns a CopySelection of the selected rows and cols
* @returns {Object} CopySelection
*/
GridView.prototype.getSelection = function() {
let rowIds = [], fields = [], rowStyle = {}, colStyle = {};
let colStart = this.cellSelector.colLower();
let colEnd = this.cellSelector.colUpper();
let rowStart = this.cellSelector.rowLower();
let rowEnd = this.cellSelector.rowUpper();
// If there is no selection, just copy/paste the cursor cell
if (this.cellSelector.isCurrentSelectType(selector.NONE)) {
rowStart = rowEnd = this.cursor.rowIndex();
colStart = colEnd = this.cursor.fieldIndex();
}
// Get all the cols if rows are selected, and viceversa
if (this.cellSelector.isCurrentSelectType(selector.ROW)) {
colStart = 0;
colEnd = this.viewSection.viewFields().peekLength - 1;
} else if(this.cellSelector.isCurrentSelectType(selector.COL)) {
rowStart = 0;
rowEnd = this.getLastDataRowIndex();
}
// Start or end will be null if no fields are visible.
if (colStart !== null && colEnd !== null) {
for(var i = colStart; i <= colEnd; i++) {
let field = this.viewSection.viewFields().at(i);
fields.push(field);
colStyle[field.colId()] = this._getColStyle(i);
}
}
var rowId;
for(var j = rowStart; j <= rowEnd; j++) {
rowId = this.viewData.getRowId(j);
rowIds.push(rowId);
rowStyle[rowId] = this._getRowStyle(j);
}
return new CopySelection(this.tableModel.tableData, rowIds, fields, {
rowStyle: rowStyle,
colStyle: colStyle
});
};
/**
* Deselects the currently selected cells.
*/
GridView.prototype.clearSelection = function() {
this.copySelection(null); // Unset the selection observable
this.cellSelector.setToCursor();
};
/**
* Given a selection object, sets all cells referred to by the selection to the empty string. If
* only formula columns are selected, only open the formula editor to the empty formula.
* @param {CopySelection} selection
*/
GridView.prototype.clearValues = function(selection) {
const options = this._getColumnMenuOptions(selection);
if (options.isFormula === true) {
this.activateEditorAtCursor({ init: ''});
} else {
let clearAction = tableUtil.makeDeleteAction(selection);
if (clearAction) {
this.gristDoc.docData.sendAction(clearAction);
}
}
};
GridView.prototype._clearColumns = function(selection) {
const fields = selection.fields;
return this.gristDoc.clearColumns(fields.map(f => f.colRef.peek()));
};
GridView.prototype._convertFormulasToData = function(selection) {
// Convert all isFormula columns to data, including empty columns. This is sometimes useful
// (e.g. since a truly empty column undergoes a conversion on first data entry, which may be
// prevented by ACL rules).
const fields = selection.fields.filter(f => f.column.peek().isFormula.peek());
if (!fields.length) { return null; }
return this.gristDoc.convertIsFormula(fields.map(f => f.colRef.peek()), {toFormula: false});
};
GridView.prototype.selectAll = function() {
this.cellSelector.selectArea(0, 0, this.getLastDataRowIndex(),
this.viewSection.viewFields().peekLength - 1);
};
// End of actions
// ======================================================================================
// GRIDVIEW PRIMITIVES (for manipulating grid, rows/cols, selections)
/**
* Assigns the cursor.rowIndex and cursor.fieldIndex observable to the correct row/column/cell
* depending on the supplied dom element.
* @param {DOM element} elem - extract the col/row index from the element
* @param {Selector.ROW/COL/CELL} elemType - denotes whether the clicked element was
* a row header, col header or cell
*/
GridView.prototype.assignCursor = function(elem, elemType) {
// Change focus before running command so that the correct viewsection's cursor is moved.
this.viewSection.hasFocus(true);
try {
let row = this.domToRowModel(elem, elemType);
let col = this.domToColModel(elem, elemType);
commands.allCommands.setCursor.run(row, col);
} catch(e) {
console.error(e);
console.error("GridView.assignCursor expects a row/col header, or cell as an input.");
}
/* CellSelector already updates the selection whenever rowIndex/fieldIndex is changed, but
* since those observables don't currently notify subscribers when an unchanged value is
* written, there are cases where the selection doesn't get updated. For example, when doing
* a click and drag to select cells and then clicking the "selected" cell that's outlined in
* green, the row/column numbers remain highlighted as if they are still selected, while
* GridView indicates the cells are not selected. This causes bugs that range from the
* aformentioned visual discrepancy to incorrect copy/paste behavior due to out-of-date
* selection ranges.
*
* We address this by calling setToCursor here unconditionally, but another possible approach
* might be to extend rowIndex/fieldIndex to always notify their subscribers. Always notifying
* currently introduces some bugs, and we'd also need to check that it doesn't cause too
* much unnecessary UI recomputation elsewhere, so in the interest of time we use the first
* approach. */
this.cellSelector.setToCursor(elemType);
};
/**
* Schedules cursor assignment 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.selectedRows = function() {
const selection = this.getSelection();
return _.without(selection.rowIds, 'new');
};
GridView.prototype.deleteRows = async function(rowIds) {
const saved = this.cursor.getCursorPos();
this.cursor.setLive(false);
try {
await BaseView.prototype.deleteRows.call(this, rowIds);
} finally {
this.cursor.setCursorPos(saved);
this.cursor.setLive(true);
this.clearSelection();
}
};
GridView.prototype.insertColumn = async function(colId = null, options = {}) {
const {
colInfo = {},
index = this.viewSection.viewFields().peekLength,
skipPopup = false
} = options;
const newColInfo = await this.viewSection.insertColumn(colId, {colInfo, index});
this.selectColumn(index);
if (!skipPopup) { this.currentEditingColumnIndex(index); }
return newColInfo;
};
GridView.prototype.renameColumn = function(index) {
this.currentEditingColumnIndex(index);
};
GridView.prototype.scrollPaneLeft = function() {
this.scrollPane.scrollLeft = 0;
};
GridView.prototype.scrollPaneRight = function() {
this.scrollPane.scrollLeft = this.scrollPane.scrollWidth;
};
GridView.prototype.selectColumn = function(colIndex) {
this.cursor.fieldIndex(colIndex);
this.cellSelector.currentSelectType(selector.COL);
};
GridView.prototype.showColumn = async function(
colRef,
index = this.viewSection.viewFields().peekLength
) {
await this.viewSection.showColumn(colRef, index);
this.selectColumn(index);
};
// TODO: Replace alerts with custom notifications
GridView.prototype.deleteColumns = function(selection) {
var fields = selection.fields;
if (fields.length === this.viewSection.viewFields().peekLength) {
reportWarning("You can't delete all the columns on the grid.", {
key: 'delete-all-columns',
});
return Promise.resolve(false);
}
(core) Fix error in sandbox when removing multiple summary source columns Summary: Fixes a very specific bug reported here: https://grist.slack.com/archives/C069RUP71/p1694630242765769 The error occurred when: 1. Removing multiple columns simultaneously 2. Those columns were sources of groupby columns for a summary table (so removing them meant recreating the summary table and thus deleting its columns) 3. There was a display column for one of the columns that got deleted (either directly or indirectly) which was set to be automatically removed since it was no longer needed, but this failed because the column was already deleted as part of earlier table removal. I fixed this by making `apply_auto_removes` remove table records last, so removing the display column wouldn't be a problem. That fixed the original error, but then I noticed that trying to undo the removal could lead to another error (under even more specific circumstances). It's hard to see exactly why, but I can see that just 3 `RemoveColumn` user actions generated over 100 doc actions and corresponding undo actions, hence the difficulty in narrowing the problem down. This is partly because removing a single column would recreate a summary table, only for that table to be immediately replaced again when another column was removed. Making the frontend send a single `[BulkRemoveRecord, _grist_Tables_column, ...] ` leads to a more efficient and sensible process with about half as many doc actions and no undo error. I think this alone would also solve the original error, but the data engine change seems more generally helpful and worth keeping. Test Plan: Added a Python test and an nbrowser test Reviewers: jarek Reviewed By: jarek Differential Revision: https://phab.getgrist.com/D4052
8 months ago
const columns = fields.filter(col => !col.disableModify());
const colRefs = columns.map(col => col.colRef.peek());
if (colRefs.length > 0) {
return this.gristDoc.docData.sendAction(
['BulkRemoveRecord', '_grist_Tables_column', colRefs],
`Removed columns ${columns.map(col => col.colId.peek()).join(', ')} ` +
`from ${this.tableModel.tableData.tableId}.`
).then(() => this.clearSelection());
}
return Promise.resolve(false);
};
GridView.prototype.hideFields = function(selection) {
var actions = selection.fields.map(field => ['RemoveRecord', field.id()]);
return this.gristDoc.docModel.viewFields.sendTableActions(actions, `Hide columns ${actions.map(a => a[1]).join(', ')} ` +
`from ${this.tableModel.tableData.tableId}.`);
};
GridView.prototype.moveColumns = function(oldIndices, newIndex) {
if (oldIndices.length === 0) return;
if (oldIndices[0] === newIndex || oldIndices[0] + 1 === newIndex) return;
var newPositions = tableUtil.fieldInsertPositions(this.viewSection.viewFields(), newIndex,
oldIndices.length);
var vsfRowIds = oldIndices.map(function(i) {
return this.viewSection.viewFields().at(i).id();
}, this);
var colInfo = { 'parentPos': newPositions };
var vsfAction = ['BulkUpdateRecord', vsfRowIds, colInfo];
var viewFieldsTable = this.gristDoc.docModel.viewFields;
var numCols = oldIndices.length;
const newPos = newIndex < this.cellSelector.colLower() ? newIndex : newIndex - numCols;
viewFieldsTable.sendTableAction(vsfAction).then(() => {
this.cursor.fieldIndex(newPos);
this.cellSelector.currentSelectType(selector.COL);
this.cellSelector.col.start(newPos);
this.cellSelector.col.end(newPos + numCols - 1);
});
};
GridView.prototype.moveRows = function(oldIndices, newIndex) {
if (oldIndices.length === 0) return;
if (oldIndices[0] === newIndex || oldIndices[0] + 1 === newIndex) return;
var newPositions = this._getRowInsertPos(newIndex, oldIndices.length);
var rowIds = oldIndices.map(function(i) {
return this.viewData.getRowId(i);
}, this);
var colInfo = { 'manualSort': newPositions };
var action = ['BulkUpdateRecord', rowIds, colInfo];
var numRows = oldIndices.length;
const newPos = newIndex < this.cellSelector.rowLower() ? newIndex : newIndex - numRows;
this.tableModel.sendTableAction(action).then(() => {
this.cursor.rowIndex(newPos);
this.cellSelector.currentSelectType(selector.ROW);
this.cellSelector.row.start(newPos);
this.cellSelector.row.end(newPos + numRows - 1);
});
};
// ======================================================================================
// MISC HELPERS
/**
* Returns the row index of the row whose top offset is closest to and
* no greater than given y-position.
* param{yCoord}: The mouse y-position (including any scroll top amount).
* Assumes that scrolly.rowOffsetTree is up to date.
* See the given examples in GridView.getMousePosCol.
**/
GridView.prototype.getMousePosRow = function (yCoord) {
var headerOffset = this.header.getBoundingClientRect().bottom;
return this.scrolly.rowOffsetTree.getIndex(yCoord - headerOffset);
};
/**
* Returns the row index of the row whose top offset is closest to and
* no greater than given y-position excluding addRows.
* param{yCoord}: The mouse y-position on the screen.
**/
GridView.prototype.currentMouseRow = function(yCoord) {
return Math.min(this.getMousePosRow(this.scrollTop() + yCoord), this.getLastDataRowIndex());
};
/**
* Returns the column index of the column whose left position is closest to and
* no greater than given x-position.
* param{xCoord}: The mouse x-position (absolute position on a page).
* Grid scroll offset and frozen columns are taken into account.
* Assumes that this.colRightOffsets is up to date
* In the following examples, let * denote the current mouse position.
* * |0____|1____|2____|3____| Returns 0
* |0__*_|1____|2____|3____| Returns 0
* |0____|1__*_|2____|3____| Returns 1
* |0____|1____|2__*_|3____| Returns 2
* |0____|1____|2____|3__*_| Returns 3
* |0____|1____|2____|3____| * Returns 4
*
* For frozen columns and a scrolled view:
* * |0____|1____|..5|6____| Returns 0
* |0__*_|1____|..5|6____| Returns 0
* |0____|1__*_|..5|6____| Returns 1
* |0____|1____|*.5|6____| Returns 5
* |0____|1____|..5|6__*_| Returns 6
* |0____|1____|..5|6____| * Returns 6
**/
GridView.prototype.getMousePosCol = function (mouseX) {
const scrollLeft = this.scrollLeft();
// Offset to left edge of gridView viewports
const headerOffset = this._cornerDom.getBoundingClientRect().right;
// Convert mouse x to grid x (not including scroll yet).
// GridX now has x position as if the grid pane is covering
// the whole screen, it still can be scrolled, so 0px is not equal to A column yet.
const gridX = mouseX - headerOffset;
// Total width of frozen columns (if zero, no frozen column set)
const frozenWidth = this.frozenWidth.peek();
// Frozen columns can be scrolled also, but not more then frozenOffset.
const frozenScroll = Math.min(this.frozenOffset.peek(), scrollLeft);
// If gridX is in frozen section or outside. Frozen section can be scrolled also
// on narrow screens so take this into account.
const inFrozen = this.numFrozen.peek() && gridX <= (frozenWidth - frozenScroll);
// If grid x (mouse converted to grid pane coordinates) is in frozen area
// we need to use frozenScroll value (how much frozen area is scrolled),
// but if it is outside we want to take the scroll offset into account.
// Here we wil calculate where exactly is mouse (over which column),
// to do that, we will pretend that nothing is scrolled - so we need
// to move gridX a little to the right, either by grid offset (how much whole grid
// is scrolled to the left) or a frozen set offset (how much frozen columns
// are scrolled to the left).
const scrollX = gridX + (inFrozen ? frozenScroll : scrollLeft);
return this.colRightOffsets.peek().getIndex(scrollX);
};
// Used for styling the paste data the same way the col/row is styled in the GridView.
GridView.prototype._getRowStyle = function(rowIndex) {
return { 'height': this.scrolly.rowOffsetTree.getValue(rowIndex) + 'px' };
};
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) {
case selector.COL:
return undefined;
case selector.ROW: // row > row num: row has record model
return ko.utils.domData.get(elem.parentNode, 'itemModel');
case selector.NONE:
case selector.CELL: // cell: row > .record > .field, row holds row model
return ko.utils.domData.get(elem.parentNode.parentNode, 'itemModel');
default:
throw Error("Unknown elemType in domToRowModel:" + elemType);
}
};
GridView.prototype.domToColModel = function(elem, elemType) {
switch (elemType) {
case selector.ROW:
return undefined;
case selector.NONE:
case selector.CELL: // cell: .field has col model
case selector.COL: // col: .column_name I think
return ko.utils.domData.get(elem, 'itemModel');
default:
throw Error("Unknown elemType in domToRowModel");
}
};
// ======================================================================================
// DOM STUFF
/**
* Recalculate various positioning variables.
*/
//TODO : is this necessary? make passive. Also this could be removed soon I think
GridView.prototype.onScroll = function() {
var pane = this.scrollPane;
this.scrollLeft(pane.scrollLeft);
this.scrollTop(pane.scrollTop);
this.width(pane.clientWidth);
};
GridView.prototype.buildDom = function() {
var self = this;
var data = this.viewData;
var v = this.viewSection;
var editIndex = this.currentEditingColumnIndex;
//each row has toggle classes on these props, so grab them once to save on lookups
let vHorizontalGridlines = v.optionsObj.prop('horizontalGridlines');
let vVerticalGridlines = v.optionsObj.prop('verticalGridlines');
let vZebraStripes = v.optionsObj.prop('zebraStripes');
var renameCommands = {
nextField: function() {
if (editIndex() === v.viewFields().peekLength - 1) {
// Turn off editing if we're on the last field.
editIndex(-1);
} else {
editIndex(editIndex() + 1);
self.selectColumn(editIndex.peek());
}
},
prevField: function() {
editIndex(editIndex() - 1);
self.selectColumn(editIndex.peek());
}
};
return dom(
'div.gridview_data_pane.flexvbox',
// offset for frozen columns - how much move them to the left
kd.style('--frozen-offset', this.frozenOffset),
// total width of frozen columns
kd.style('--frozen-width', this.frozenWidth),
// Corner, bars and shadows
// Corner and shadows (so it's fixed to the grid viewport)
self._cornerDom = dom(
'div.gridview_data_corner_overlay',
dom.on('click', () => this.selectAll()),
),
dom('div.scroll_shadow_top', kd.show(this.scrollShadow.top)),
dom('div.scroll_shadow_left',
kd.show(this.scrollShadow.left),
// pass current scroll position
kd.style('--frozen-scroll-offset', this.frozenScrollOffset)),
dom('div.frozen_line', kd.show(this.frozenLine)),
dom('div.gridview_header_backdrop_left'), //these hide behind the actual headers to keep them from flashing
dom('div.gridview_header_backdrop_top'),
// When there are frozen columns, right border for number row will not be visible (as actually there is no border,
// it comes from the first cell in the grid) making a gap between row-number and actual column. So when we scroll
// the content of the scrolled columns will be visible to the user (as there is blank space there).
// This line fills the gap. NOTE that we are using number here instead of a boolean.
dom('div.gridview_left_border', kd.show(this.numFrozen),
kd.style("left", ROW_NUMBER_WIDTH + 'px')
),
// left shadow that will be visible on top of frozen columns
dom('div.scroll_shadow_frozen', kd.show(this.frozenShadow)),
// When cursor leaves the GridView, remove hover immediately (without debounce).
// This guards mouse leaving gridView from the top, as leaving from bottom or left, right, is
// guarded on the row level.
dom.on("mouseleave", () => !this.isDisposed() && this.hoverColumn(-1)),
// Drag indicators
self.colLine = dom(
'div.col_indicator_line',
kd.show(function() { return self.cellSelector.isCurrentDragType(selector.COL); }),
kd.style('left', self.cellSelector.col.linePos)
),
self.colShadow = dom(
'div.column_shadow',
kd.show(function() { return self.cellSelector.isCurrentDragType(selector.COL); }),
kd.style('left', function() { return (self.dragX() - self.colShadowAdjust) + 'px'; })
),
self.rowLine = dom(
'div.row_indicator_line',
kd.show(function() { return self.cellSelector.isCurrentDragType(selector.ROW); }),
kd.style('top', self.cellSelector.row.linePos)
),
self.rowShadow = dom(
'div.row_shadow',
kd.show(function() { return self.cellSelector.isCurrentDragType(selector.ROW); }),
kd.style('top', function() { return (self.dragY() - self.rowShadowAdjust) + 'px'; })
),
self.scrollPane =
dom('div.grid_view_data.gridview_data_scroll.show_scrollbar',
kd.scrollChildIntoView(self.visibleRowIndex),
dom.onDispose(() => {
// Save the previous scroll values to the section.
self.viewSection.lastScrollPos = _.extend({
scrollLeft: self.scrollPane.scrollLeft
}, self.scrolly.getScrollPos());
}),
// COL HEADER BOX
dom('div.gridview_stick-top.flexhbox', // Sticks to top, flexbox makes child enclose its contents
dom('div.gridview_corner_spacer'),
self.header = dom('div.gridview_data_header.flexhbox', // main header, flexbox floats contents onto a line
dom('div.column_names.record',
kd.style('minWidth', '100%'),
kd.style('borderLeftWidth', v.borderWidthPx),
kd.foreach(v.viewFields(), field => {
const isEditingLabel = koUtil.withKoUtils(ko.pureComputed({
read: () => {
const goodIndex = () => editIndex() === field._index();
const isReadonly = () => this.gristDoc.isReadonlyKo() || self.isPreview;
const isSummary = () => Boolean(field.column().disableEditData());
return goodIndex() && !isReadonly() && !isSummary();
},
write: val => {
if (val) {
// Turn on editing.
editIndex(field._index());
} else {
// Turn off editing only if it wasn't changed to another field (e.g. by tabbing).
const isCurrent = editIndex.peek() === field._index.peek();
if (isCurrent) {
editIndex(-1);
}
}
}
}).extend({ rateLimit: 0 })).onlyNotifyUnequal();
let filterTriggerCtl;
const isTooltip = ko.pureComputed(() =>
self.editingFormula() &&
ko.unwrap(self.hoverColumn) === field._index()
);
const headerTextColor = ko.computed(() => field.headerTextColor() || '');
const headerFillColor = ko.computed(() => field.headerFillColor() || '');
const headerFontBold = ko.computed(() => field.headerFontBold());
const headerFontItalic = ko.computed(() => field.headerFontItalic());
const headerFontUnderline = ko.computed(() => field.headerFontUnderline());
const headerFontStrikethrough = ko.computed(() => field.headerFontStrikethrough());
return dom(
'div.column_name.field',
dom.autoDispose(headerTextColor),
dom.autoDispose(headerFillColor),
dom.autoDispose(headerFontBold),
dom.autoDispose(headerFontItalic),
dom.autoDispose(headerFontUnderline),
dom.autoDispose(headerFontStrikethrough),
kd.style('--grist-header-color', headerTextColor),
kd.style('--grist-header-background-color', headerFillColor),
kd.toggleClass('font-bold', headerFontBold),
kd.toggleClass('font-italic', headerFontItalic),
kd.toggleClass('font-underline', headerFontUnderline),
kd.toggleClass('font-strikethrough', headerFontStrikethrough),
kd.style('--frozen-position', () => ko.unwrap(this.frozenPositions.at(field._index()))),
kd.toggleClass("frozen", () => ko.unwrap(this.frozenMap.at(field._index()))),
dom.autoDispose(isEditingLabel),
dom.autoDispose(isTooltip),
dom.testId("GridView_columnLabel"),
(el) => {
const tooltip = new HoverColumnTooltip(el);
return [
dom.autoDispose(tooltip),
dom.autoDispose(isTooltip.subscribe((show) => {
if (show) {
tooltip.show(t(`Click to insert`) + ` $${field.origCol.peek().colId.peek()}`);
} else {
tooltip.hide();
}
})),
]
},
kd.style('width', field.widthPx),
kd.style('borderRightWidth', v.borderWidthPx),
viewCommon.makeResizable(field.width, {shouldSave: !this.gristDoc.isReadonly.get()}),
kd.toggleClass('selected', () => ko.unwrap(this.isColSelected.at(field._index()))),
dom.on('contextmenu', ev => {
// This is a little hack to position the menu the same way as with a click
ev.preventDefault();
const btn = ev.currentTarget.querySelector('.g-column-menu-btn');
if (btn) { btn.click(); }
}),
dom('div.g-column-label',
kd.scope(field.description, desc => desc ? descriptionInfoTooltip(desc, "column") : null),
dom.on('mousedown', ev => isEditingLabel() ? ev.stopPropagation() : true),
// We are using editableLabel here, but we don't use it for editing.
kf.editableLabel(self.isPreview ? field.label : field.displayLabel, ko.observable(false)),
kd.scope(field.description, desc => desc ? dom('div.g-column-label-spacer') : null),
buildRenameColumn({
field,
isEditing: isEditingLabel,
optCommands: renameCommands
}),
),
self._showTooltipOnHover(field, isTooltip),
self.isPreview ? null : menuToggle(null,
kd.cssClass('g-column-main-menu'),
kd.cssClass('g-column-menu-btn'),
// Prevent mousedown on the dropdown triangle from initiating column drag.
dom.on('mousedown', () => false),
// Select the column if it's not part of a multiselect.
dom.on('click', (ev) => this.maybeSelectColumn(ev.currentTarget.parentNode, field)),
(elem) => {
filterTriggerCtl = setPopupToCreateDom(
elem,
ctl => this._columnFilterMenu(ctl, field, {showAllFiltersButton: true}),
{
attach: 'body',
placement: 'bottom-start',
boundaries: 'viewport',
trigger: [],
}
);
},
menu(ctl => this.columnContextMenu(ctl, this.getSelection(), field, filterTriggerCtl)),
testId('column-menu-trigger'),
),
dom('div.selection'),
this._buildInsertColumnMenu({field}),
);
}),
this.isPreview ? null : kd.maybe(() => !this.gristDoc.isReadonlyKo(), () => (
this._modField = dom('div.column_name.mod-add-column.field',
'+',
kd.style("width", PLUS_WIDTH + 'px'),
this._buildInsertColumnMenu(),
)
))
)
) //end hbox
), // END COL HEADER BOX
koDomScrolly.scrolly(data, { paddingBottom: 80, paddingRight: 28 }, renderRow),
kd.maybe(this._isPrinting, () =>
renderAllRows(this.tableModel, this.sortedRows.getKoArray().peek(), renderRow)
),
) // end scrollpane
);// END MAIN VIEW BOX
function renderRow(row) {
// TODO. There are several ways to implement a cursor; similar concerns may arise
// when implementing selection and cell editor.
// (1) Class on 'div.field.field_clip'. Fewest elements, seems possibly best for
// performance. Problem is: it's impossible to get cursor exactly right with a
// one-sided border. Attaching a cursor as additional element inside the cell
// truncates the cursor to the cell's inside because of 'overflow: hidden'.
// (2) 'div.field' with 'div.field_clip' inside, on which a class is toggled. This
// works well. The only concern is whether this slows down rendering. Would be
// good to measure and compare rendering speed.
// Related: perhaps the fastest rendering would be for a table.
// (3) Separate element attached to the row, absolutely positioned at left
// position and width of the selected cell. This works too. Requires
// maintaining a list of leftOffsets (or measuring the cell's), and feels less
// clean and more complicated than (2).
// IsRowActive and isCellActive are a significant optimization. IsRowActive is called
// for all rows when cursor.rowIndex changes, but the value only changes for two of the
// rows. IsCellActive is only subscribed to columns for the active row. This way, when
// the cursor moves, there are (rows+2*columns) calls rather than rows*columns.
var isRowActive = ko.computed(() => row._index() === self.cursor.rowIndex());
const computedFlags = ko.pureComputed(() => {
return self.viewSection.rulesColsIds().map(colRef => {
if (row.cells[colRef]) { return row.cells[colRef]() || false; }
return false;
});
});
const computedRule = koUtil.withKoUtils(ko.pureComputed(() => {
if (row._isAddRow() || !row.id()) { return null; }
const flags = computedFlags();
if (flags.length === 0) { return null; }
const styles = self.viewSection.rulesStyles() || [];
return { style : new CombinedStyle(styles, flags) };
}, this).extend({deferred: true}));
const fillColor = buildStyleOption(self, computedRule, 'fillColor');
const zebraColor = ko.pureComputed(() => calcZebra(fillColor()));
const textColor = buildStyleOption(self, computedRule, 'textColor');
const fontBold = buildStyleOption(self, computedRule, 'fontBold');
const fontItalic = buildStyleOption(self, computedRule, 'fontItalic');
const fontUnderline = buildStyleOption(self, computedRule, 'fontUnderline');
const fontStrikethrough = buildStyleOption(self, computedRule, 'fontStrikethrough');
return dom('div.gridview_row',
dom.autoDispose(isRowActive),
dom.autoDispose(computedFlags),
dom.autoDispose(computedRule),
dom.autoDispose(textColor),
dom.autoDispose(fillColor),
dom.autoDispose(zebraColor),
dom.autoDispose(fontBold),
dom.autoDispose(fontItalic),
dom.autoDispose(fontUnderline),
dom.autoDispose(fontStrikethrough),
kd.toggleClass('link_selector_row', () => self.isLinkSource() && isRowActive()),
// rowid dom
dom('div.gridview_data_row_num',
kd.style("width", ROW_NUMBER_WIDTH + 'px'),
dom('div.gridview_data_row_info',
kd.toggleClass('linked_dst', () => {
const myRowId = row.id();
const linkedRowId = self.linkedRowId();
// Must ensure that linkedRowId is not null to avoid drawing on rows whose
// row ids are null.
return linkedRowId && linkedRowId === myRowId;
})
),
kd.text(function() { return row._index() + 1; }),
kd.scope(row._validationFailures, function(failures) {
if (!row._isAddRow() && failures.length > 0) {
return dom('div.validation_error_number', failures.length,
kd.attr('title', function() {
return "Validation failed: " +
failures.map(function(val) { return val.name(); }).join(", ");
})
);
}
}),
dom.on('contextmenu', ev => {
// This is a little hack to position the menu the same way as with a click,
// the same hack as on a column menu.
ev.preventDefault();
ev.currentTarget.querySelector('.menu_toggle').click();
}),
self.isPreview ? null : menuToggle(null,
dom.on('click', ev => self.maybeSelectRow(ev.currentTarget.parentNode, row.getRowId())),
menu((ctx) => {
ctx.autoDispose(isRowActive.subscribe(() => ctx.close()));
return self.rowContextMenu();
}, { trigger: ['click'] }),
// Prevent mousedown on the dropdown triangle from initiating row drag.
dom.on('mousedown', () => false),
testId('row-menu-trigger'),
),
kd.toggleClass('selected', () =>
!row._isAddRow() && self.cellSelector.isRowSelected(row._index())),
),
dom('div.record',
kd.toggleClass('record-add', row._isAddRow),
kd.style('borderLeftWidth', v.borderWidthPx),
kd.style('borderBottomWidth', v.borderWidthPx),
kd.toggleClass('font-bold', fontBold),
kd.toggleClass('font-underline', fontUnderline),
kd.toggleClass('font-italic', fontItalic),
kd.toggleClass('font-strikethrough', fontStrikethrough),
kd.style('--grist-row-rule-background-color', fillColor),
kd.style('--grist-row-rule-background-color-zebra', zebraColor),
kd.style('--grist-row-color', textColor),
//These are grabbed from v.optionsObj at start of GridView buildDom
kd.toggleClass('record-hlines', vHorizontalGridlines),
kd.toggleClass('record-vlines', vVerticalGridlines),
kd.toggleClass('record-zebra', vZebraStripes),
// even by 1-indexed rownum, so +1 (makes more sense for user-facing display stuff)
kd.toggleClass('record-even', () => (row._index()+1) % 2 === 0 ),
dom.on("mouseleave", (ev) => {
// Leave only when leaving record row.
if (!ev.relatedTarget || !ev.relatedTarget.classList.contains("record")){
self.changeHover(-1);
}
}),
self.isPreview ? null : 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(() => {
const rowType = self.extraRows.getRowType(row.id());
return rowType && `diff-${rowType}` || '';
}) : null,
kd.foreach(v.viewFields(), function(field) {
// Whether the cell has a cursor (possibly in an inactive view section).
var isCellSelected = ko.computed(() =>
isRowActive() &&
field._index() === self.cursor.fieldIndex() &&
self._insertColumnIndex() === null
);
// Whether the cell is active: has the cursor in the active section.
var isCellActive = ko.computed(() => isCellSelected() && v.hasFocus());
// Whether the cell is part of an active copy-paste operation.
var isCopyActive = ko.computed(function() {
return self.copySelection() &&
self.copySelection().isCellSelected(row.id(), field.colId());
});
var fieldBuilder = self.fieldBuilders.at(field._index());
var isSelected = ko.computed(() => {
return !row._isAddRow() &&
!self.cellSelector.isCurrentSelectType(selector.NONE) &&
ko.unwrap(self.isColSelected.at(field._index())) &&
self.cellSelector.isRowSelected(row._index());
});
var isTooltip = ko.pureComputed(() =>
self.editingFormula() &&
ko.unwrap(self.hoverColumn) === field._index()
);
return dom(
'div.field',
kd.toggleClass('field-insert-before', () =>
self._insertColumnIndex() === field._index()),
kd.style('--frozen-position', () => ko.unwrap(self.frozenPositions.at(field._index()))),
kd.toggleClass("frozen", () => ko.unwrap(self.frozenMap.at(field._index()))),
kd.toggleClass('scissors', isCopyActive),
dom.autoDispose(isCopyActive),
dom.autoDispose(isCellSelected),
dom.autoDispose(isCellActive),
dom.autoDispose(isSelected),
self._showTooltipOnHover(field, isTooltip),
kd.style('width', field.widthPx),
//TODO: Ensure that fields in a row resize when
//a cell in that row becomes larger
kd.style('borderRightWidth', v.borderWidthPx),
kd.toggleClass('selected', isSelected),
// Optional icon. Currently only use to show formula icon.
dom('div.field-icon'),
fieldBuilder.buildDomWithCursor(row, isCellActive, isCellSelected),
dom('div.selection'),
);
})
)
);
}
};
/** @inheritdoc */
GridView.prototype.onResize = function() {
const activeFieldBuilder = this.activeFieldBuilder();
let height = null;
if (isNarrowScreen()) {
height = window.outerHeight;
}
if (activeFieldBuilder && activeFieldBuilder.isEditorActive()) {
// When the editor is active, the common case for a resize is if the virtual keyboard is being
// shown on mobile device. In that case, we need to scroll active cell into view, and need to
// do it synchronously, to allow repositioning the editor to it in response to the same event.
this.scrolly.updateSize(height);
this.scrolly.scrollRowIntoView(this.cursor.rowIndex.peek());
} else {
this.scrolly.scheduleUpdateSize(height);
}
this.width(this.scrollPane.clientWidth)
};
/** @inheritdoc */
GridView.prototype.onRowResize = function(rowModels) {
this.scrolly.resetItemHeights(rowModels);
};
GridView.prototype.onLinkFilterChange = function(rowId) {
BaseView.prototype.onLinkFilterChange.call(this, rowId);
this.clearSelection();
};
GridView.prototype.onCellContextMenu = function(ev, elem) {
let row = this.domToRowModel(elem, selector.CELL);
let col = this.domToColModel(elem, selector.CELL);
if (this.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.
this.preventAssignCursor();
} else {
this.assignCursor(elem, selector.NONE);
}
}
// ======================================================================================
// SELECTOR STUFF
/**
* Returns a pure computed boolean that determines whether the given column is selected.
* @param {view field object} col - the column to create an observable for
**/
GridView.prototype._createColSelectedObs = function(col) {
return ko.pureComputed(function() {
return this.cellSelector.isCurrentSelectType(selector.ROW) ||
gutil.between(col._index(), this.cellSelector.col.start(),
this.cellSelector.col.end());
}, this);
};
// Callbacks for mouse events for the selector object
GridView.prototype.cellMouseDown = function(elem, event) {
let col = this.domToColModel(elem, selector.CELL);
if (this.hoverColumn() === col._index()) {
return this._tooltipMouseDown(elem, selector.CELL);
}
if (event.shiftKey) {
// Change focus before running command so that the correct viewsection's cursor is moved.
this.viewSection.hasFocus(true);
let row = this.domToRowModel(elem, selector.CELL);
this.cellSelector.selectArea(this.cursor.rowIndex(), this.cursor.fieldIndex(),
row._index(), col._index());
} else {
this.assignCursor(elem, selector.NONE);
}
};
GridView.prototype.colMouseDown = function(elem, event) {
let col = this.domToColModel(elem, selector.COL);
if (this.hoverColumn() === col._index()) {
return this._tooltipMouseDown(elem, selector.COL);
}
this._colClickTime = Date.now();
this.assignCursor(elem, selector.COL);
// Clicking the column header selects all rows except the add row.
this.cellSelector.row.end(this.getLastDataRowIndex());
};
GridView.prototype._tooltipMouseDown = function(elem, elemType) {
let row = this.domToRowModel(elem, elemType);
let col = this.domToColModel(elem, elemType);
// FormulaEditor.ts overrides this command to insert the column id of the clicked column.
commands.allCommands.setCursor.run(row, col);
};
GridView.prototype.rowMouseDown = function(elem, event) {
if (event.shiftKey) {
this.cellSelector.currentSelectType(selector.ROW);
this.cellSelector.row.end(this.currentMouseRow(event.pageY));
} else {
this.assignCursor(elem, selector.ROW);
}
};
GridView.prototype.rowMouseMove = function(event) {
this.cellSelector.row.end(this.currentMouseRow(event.pageY));
};
GridView.prototype.colMouseMove = function(event) {
if (this.editingFormula()) { return; }
var currentCol = Math.min(this.getMousePosCol(event.pageX),
this.viewSection.viewFields().peekLength - 1);
this.cellSelector.col.end(currentCol);
};
GridView.prototype.cellMouseMove = function(event) {
if (this.editingFormula()) { return; }
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);
} else {
this.cellSelector.currentSelectType(selector.CELL);
}
};
GridView.prototype.createSelector = function() {
this.cellSelector = new selector.CellSelector(this);
};
// buildDom needs some of the row/col/cell selector observables to exist beforehand
// 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 () {
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(),
};
}
}
}));
// 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.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
// ============================================================================
// DRAGGING LOGIC
GridView.prototype.styleRowDragElements = function(elem, event) {
var rowStart = this.cellSelector.rowLower();
var rowEnd = this.cellSelector.rowUpper();
var shadowHeight = this.scrolly.rowOffsetTree.getCumulativeValueRange(rowStart, rowEnd+1);
var shadowTop = (this.header.getBoundingClientRect().height +
this.scrolly.rowOffsetTree.getSumTo(rowStart) - this.scrollTop());
this.rowLine.style.top = shadowTop + 'px';
this.rowShadow.style.top = shadowTop + 'px';
this.rowShadow.style.height = shadowHeight + 'px';
this.rowShadowAdjust = event.pageY - shadowTop;
this.cellSelector.currentDragType(selector.ROW);
this.cellSelector.row.dropIndex(this.cellSelector.rowLower());
};
GridView.prototype.styleColDragElements = function(elem, event) {
this._colClickTime = Date.now();
var colStart = this.cellSelector.colLower();
var colEnd = this.cellSelector.colUpper();
var shadowWidth = this.colRightOffsets.peek().getCumulativeValueRange(colStart, colEnd+1);
var viewDataNumsWidth = $('.gridview_corner_spacer').width();
var shadowLeft = (viewDataNumsWidth + this.colRightOffsets.peek().getSumTo(colStart) - this.scrollLeft());
this.colLine.style.left = shadowLeft + 'px';
this.colShadow.style.left = shadowLeft + 'px';
this.colShadow.style.width = shadowWidth + 'px';
this.colShadowAdjust = event.pageX - shadowLeft;
this.cellSelector.currentDragType(selector.COL);
this.cellSelector.col.dropIndex(this.cellSelector.colLower());
};
/**
* GridView.dragRows/dragCols update the row/col shadow and row/col indicator line on mousemove events.
* Rules for determining where the indicator line should show while dragging cols/rows:
* 0) The indicator line should not appear after the special add-row.
* 1) If the mouse position is within the selected range -> the indicator line should show
* at the left offset of the start of the select range
* 2) If the mouse position comes after the select range -> increment the computed dropIndex by 1
* 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(event) {
var dropIndex = Math.min(this.getMousePosRow(event.pageY + this.scrollTop()),
this.getLastDataRowIndex());
if (this.cellSelector.containsRow(dropIndex)) {
dropIndex = this.cellSelector.rowLower();
} else if (dropIndex > this.cellSelector.rowUpper()) {
dropIndex += 1;
}
if (this.cellSelector.rowUpper() === this.viewData.peekLength - 1) {
dropIndex = Math.min(dropIndex, this.cellSelector.rowLower());
}
var linePos = this.scrolly.rowOffsetTree.getSumTo(dropIndex) +
this.header.getBoundingClientRect().height - this.scrollTop();
this.cellSelector.row.linePos(linePos + 'px');
this.cellSelector.row.dropIndex(dropIndex);
this.dragY(event.pageY);
};
GridView.prototype.dragCols = function(event) {
let dropIndex = Math.min(this.getMousePosCol(event.pageX),
this.viewSection.viewFields().peekLength - 1);
if (this.cellSelector.containsCol(dropIndex)) {
dropIndex = this.cellSelector.colLower();
} else if (dropIndex > this.cellSelector.colUpper()) {
dropIndex += 1;
}
if (this.cellSelector.colUpper() === this.viewSection.viewFields().peekLength - 1) {
dropIndex = Math.min(dropIndex, this.cellSelector.colLower());
}
const viewDataNumsWidth = $('.gridview_corner_spacer').width();
let linePos = viewDataNumsWidth + this.colRightOffsets.peek().getSumTo(dropIndex);
// If there are frozen columns and dropIndex (column index) is inside the frozen set.
const frozenCount = this.numFrozen();
const inFrozen = frozenCount > 0 && dropIndex < frozenCount;
const scrollLeft = this.scrollLeft();
// Move line left by the number of pixels the frozen set is scrolled.
if (inFrozen) {
linePos -= Math.min(this.frozenOffset.peek(), scrollLeft);
} else {
// Else move left by the whole amount.
linePos -= scrollLeft;
}
this.cellSelector.col.linePos(linePos + 'px');
this.cellSelector.col.dropIndex(dropIndex);
this.dragX(event.pageX);
};
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() {
var oldIndices = _.range(this.cellSelector.colLower(), this.cellSelector.colUpper() + 1);
const idx = this.cellSelector.col.dropIndex();
this.moveColumns(oldIndices, idx);
// If this was a short click on a single already-selected column that results in no
// column movement, propose renaming the column.
if (Date.now() - this._colClickTime < SHORT_CLICK_IN_MS && oldIndices.length === 1 &&
idx === oldIndices[0]) {
commands.allCommands.renameField.run();
}
this._colClickTime = 0;
this.cellSelector.currentDragType(selector.NONE);
};
// End of Dragging logic
// ===========================================================================
// CONTEXT MENUS
GridView.prototype.columnContextMenu = function(ctl, copySelection, field, filterTriggerCtl) {
const selectedColIds = copySelection.colIds;
this.ctxMenuHolder.autoDispose(ctl);
const options = this._getColumnMenuOptions(copySelection);
if (selectedColIds.length > 1 && selectedColIds.includes(field.column().colId())) {
return buildMultiColumnMenu(options);
} else {
return buildColumnContextMenu({
filterOpenFunc: () => filterTriggerCtl.open(),
sortSpec: this.gristDoc.viewModel.activeSection.peek().activeSortSpec.peek(),
colId: field.column.peek().id.peek(),
...options,
});
}
};
GridView.prototype._getColumnMenuOptions = function(copySelection) {
return {
columnIndices: copySelection.fields.map(f => f._index()),
totalColumnCount : this.viewSection.viewFields.peek().peekLength,
numColumns: copySelection.fields.length,
numFrozen: this.viewSection.numFrozen.peek(),
disableModify: calcFieldsCondition(copySelection.fields, f => f.disableModify.peek()),
isReadonly: this.gristDoc.isReadonly.get() || this.isPreview,
(core) Initial data tables page Summary: - Added a new special page for viewing raw data widgets: - Implemented in DataTables.ts - Accessible only via the special URL path `/p/data` - Future diffs should make this page prettier and easily accessible - Shows a list of user tables - Clicking on a table name shows its `rawViewSection` by setting `GristDoc.viewModel.activeSectionId`. Note that in this case `GristDoc.viewModel` is an empty record, so this is a bit of a hack, but it works well and causes no known issues. - Added `ViewSectionRec.isRaw` to know if the record represents a raw data widget. - Added various restrictions in the UI for raw data widgets: - 'Delete widget' is disabled in the 3-dot widget menu. - Prevent hiding columns: - "Hide column" in the column context menu is disabled - The "VISIBLE/HIDDEN COLUMNS" section of the right panel > Table > Widget is hidden - The toggle bar isn't configurable to ensure that users know when raw data is filtered: - The filter bar always shows if and only if some filters are present - "Toggle Filter Bar" is hidden in: - Right panel > Table > Sort & Filter - The sort/filter menu next to the three-dot menu for widgets. - Other restrictions in the right panel: - In the Column tab: - 'Use separate settings' is disabled - In the Table tab: - In the Widget subtab: - 'Change Widget' is hidden - In the Data subtab: - 'Edit Data Selection' is hidden - 'SELECT BY' is hidden Test Plan: Tested manually. The behaviour of raw data widgets may still change and they aren't easily visible to users yet. Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D3248
2 years ago
isRaw: this.viewSection.isRaw(),
isFiltered: this.isFiltered(),
isFormula: calcFieldsCondition(copySelection.fields, f => f.column.peek().isRealFormula.peek()),
};
}
GridView.prototype._columnFilterMenu = function(ctl, field, options) {
this.ctxMenuHolder.autoDispose(ctl);
const filterInfo = this.viewSection.filters()
.find(({fieldOrColumn}) => fieldOrColumn.origCol().origColRef() === field.column().origColRef());
if (!filterInfo.isFiltered.peek()) {
// This is a new filter - initialize its spec and pin it.
this.viewSection.setFilter(filterInfo.fieldOrColumn.origCol().origColRef(), {
filter: NEW_FILTER_JSON,
pinned: true,
});
}
return this.createFilterMenu(ctl, filterInfo, options);
};
GridView.prototype.maybeSelectColumn = function (elem, field) {
// Change focus before running command so that the correct viewsection's cursor is moved.
this.viewSection.hasFocus(true);
const selectedColIds = this.getSelection().colIds;
if (selectedColIds.length > 1 && selectedColIds.includes(field.column().colId())) {
return; // No need to select the column because it's included in the multi-selection
}
this.assignCursor(elem, selector.COL);
};
GridView.prototype.maybeSelectRow = function(elem, rowId) {
// Change focus before running command so that the correct viewsection's cursor is moved.
this.viewSection.hasFocus(true);
// If the clicked row was not already in the selection, move the selection to the row.
if (!this.getSelection().rowIds.includes(rowId)) {
this.assignCursor(elem, selector.ROW);
}
};
GridView.prototype.rowContextMenu = function() {
return RowContextMenu(this._getRowContextMenuOptions());
};
GridView.prototype._getRowContextMenuOptions = function() {
return {
...this._getCellContextMenuOptions(),
disableShowRecordCard: this._isRecordCardDisabled(),
};
};
GridView.prototype._isRecordCardDisabled = function() {
return this.getSelection().onlyAddRowSelected() ||
this.viewSection.isTableRecordCardDisabled() ||
this.viewSection.table().summarySourceTable() !== 0;
}
GridView.prototype.cellContextMenu = function() {
return CellContextMenu(
this._getCellContextMenuOptions(),
this._getColumnMenuOptions(this.getSelection())
);
};
GridView.prototype._getCellContextMenuOptions = 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,
};
};
// End Context Menus
GridView.prototype.scrollToCursor = function(sync = true) {
return kd.doScrollChildIntoView(this.scrollPane, this.cursor.rowIndex(), sync);
}
GridView.prototype._duplicateRows = async function() {
const addRowIds = await BaseView.prototype._duplicateRows.call(this);
// Highlight duplicated rows if the grid is not sorted (or the sort doesn't affect rowIndex).
const topRowIndex = this.viewData.getRowIndex(addRowIds[0]);
// Set row on the first record added.
this.setCursorPos({rowId: addRowIds[0]});
// Highlight inserted area (if we inserted rows in correct order)
if (addRowIds.every((r, i) => r === this.viewData.getRowId(topRowIndex + i))) {
this.cellSelector.selectArea(topRowIndex, 0,
topRowIndex + addRowIds.length - 1, this.viewSection.viewFields().peekLength - 1);
}
}
GridView.prototype._clearCopySelection = function() {
this.copySelection(null);
};
GridView.prototype._showTooltipOnHover = function(field, isShowingTooltip) {
return [
kd.toggleClass("hover-column", isShowingTooltip),
dom.on('mouseenter', () => {
this.changeHover(field._index());
}),
dom.on('mousedown', (ev) => {
if (isShowingTooltip()) {
ev.preventDefault();
}
}),
];
};
GridView.prototype._scrollColumnIntoView = function(colIndex) {
// If there are some frozen columns.
if (this.numFrozen.peek() && colIndex < this.numFrozen.peek()) { return; }
if (colIndex === 0) {
this.scrollPaneLeft();
} else if (colIndex === this.viewSection.viewFields().peekLength - 1) {
this.scrollPaneRight();
} else {
const offset = this.colRightOffsets.peek().getSumTo(colIndex);
const rowNumsWidth = this._cornerDom.clientWidth;
const viewWidth = this.scrollPane.clientWidth - rowNumsWidth;
const fieldWidth = this.colRightOffsets.peek().getValue(colIndex) + 1; // +1px border
// Left and right pixel edge of 'viewport', starting from edge of row nums.
const frozenWidth = this.frozenWidth.peek();
const leftEdge = this.scrollPane.scrollLeft + frozenWidth;
const rightEdge = leftEdge + (viewWidth - frozenWidth);
// If cell doesn't fit onscreen, scroll to fit.
const scrollShift = offset - gutil.clamp(offset, leftEdge, rightEdge - fieldWidth);
this.scrollPane.scrollLeft = this.scrollPane.scrollLeft + scrollShift;
}
}
/**
* Attaches the Add Column menu.
*
* The menu can be triggered in two ways, depending on the presence of a `field`
* in `options`.
*
* If a field is present, the menu is triggered only when `_insertColumnIndex` is set
* to the index of the field the menu is attached to.
*
* If a field is not present, the menu is triggered either when `_insertColumnIndex`
* is set to `-1` or when the attached element is clicked. In practice, there will
* only be one element attached this way: the "+" field, which appears at the end of
* the GridView.
*/
GridView.prototype._buildInsertColumnMenu = function(options = {}) {
const {field} = options;
const triggers = [];
if (!field) { triggers.push('click'); }
return [
field ? kd.toggleClass('field-insert-before', () =>
this._insertColumnIndex() === field._index()) : null,
menu(
ctl => {
ctl.onDispose(() => this._insertColumnIndex(null));
let index = this._insertColumnIndex.peek();
if (index === null || index === -1) {
index = undefined;
}
return [
buildAddColumnMenu(this, index),
elem => { FocusLayer.create(ctl, {defaultFocusElem: elem, pauseMousetrap: true}); },
testId('new-columns-menu'),
];
},
{
modifiers: {
offset: {
offset: '8,8',
},
},
selectOnOpen: true,
trigger: [
...triggers,
(_, ctl) => {
ctl.autoDispose(this._insertColumnIndex.subscribe((index) => {
if (field?._index() === index || (!field && index === -1)) {
ctl.open();
} else if (!ctl.isDisposed()) {
ctl.close();
}
}));
},
],
}
),
];
}
GridView.prototype._openInsertColumnMenu = function(columnIndex) {
if (columnIndex < this.viewSection.viewFields().peekLength) {
this._scrollColumnIntoView(columnIndex);
this._insertColumnIndex(columnIndex);
} else {
this.scrollPaneRight();
this._insertColumnIndex(-1);
}
}
function buildStyleOption(owner, computedRule, optionName) {
return ko.computed(() => {
if (owner.isDisposed()) { return null; }
const rule = computedRule();
if (!rule || !rule.style) { return ''; }
return rule.style[optionName] || '';
});
}
// Helper to show tooltip over column selection in the full edit mode.
class HoverColumnTooltip {
constructor(el) {
this.el = el;
}
show(text) {
this.hide();
this.tooltip = showTooltip(this.el, () => dom("span", text, testId("column-formula-tooltip")))
}
hide() {
if (this.tooltip ) {
this.tooltip.close();
this.tooltip = null;
}
}
dispose() {
this.hide();
}
}
// Simple function that calculates good color for zebra stripes.
function calcZebra(hex) {
if (!hex || hex.length !== 7) { return hex; }
// HSL: [HUE, SATURATION, LIGHTNESS]
const hsl = convert.hex.hsl(hex.substr(1));
// For bright color, we will make it darker. Value was picked by hand, to
// produce #f8f8f8f out of #ffffff.
if (hsl[2] > 50) { hsl[2] -= 2.6; }
// For darker color, we will make it brighter. Value was picked by hand to look
// good for the darkest colors in our palette.
else if (hsl[2] > 1) { hsl[2] += 11; }
// For very dark colors
else { hsl[2] += 16; }
return `#${convert.hsl.hex(hsl)}`;
}
module.exports = GridView;