/* globals alert, $ */ 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 {reportError} = require('app/client/models/AppModel'); const {onDblClickMatchElem} = require('app/client/lib/dblclick'); // Grist UI Components const {dom: grainjsDom, Holder, Computed} = require('grainjs'); const {closeRegisteredMenu, menu} = require('../ui2018/menus'); const {calcFieldsCondition} = require('../ui/GridViewMenus'); const {ColumnAddMenu, ColumnContextMenu, MultiColumnMenu, freezeAction} = require('../ui/GridViewMenus'); const {RowContextMenu} = require('../ui/RowContextMenu'); const {setPopupToCreateDom} = require('popweasel'); const {CellContextMenu} = require('app/client/ui/CellContextMenu'); const {testId} = require('app/client/ui2018/cssVars'); const {contextMenu} = require('app/client/ui/contextMenu'); const {mouseDragMatchElem} = require('app/client/ui/mouseDrag'); const {menuToggle} = require('app/client/ui/MenuToggle'); const {showTooltip} = require('app/client/ui/tooltips'); const {parsePasteForView} = require("./BaseView2"); const {CombinedStyle} = require("app/client/models/Styles"); // 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); this.selectionSummary = SelectionSummary.create(this, this.cellSelector, this.tableModel.tableData, this.sortedRows, this.viewSection.viewFields); this.colMenuTargets = {}; // Reference from column ref to its menu target dom // Cache of column right offsets, used to determine the col select range 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 => { const offset = this.colRightOffsets.peek().getSumTo(idx); const rowNumsWidth = this._cornerDom.clientWidth; const viewWidth = this.scrollPane.clientWidth - rowNumsWidth; const fieldWidth = this.colRightOffsets.peek().getValue(idx) + 1; // +1px border // Left and right pixel edge of 'viewport', starting from edge of row nums const leftEdge = this.scrollPane.scrollLeft; const rightEdge = leftEdge + viewWidth; //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; })); 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); // 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.gristDoc.docModel.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', () => 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 = { 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(1, this.cellSelector.row.end, selector.COL, this.getLastDataRowIndex()); }, shiftUp: function() { this._shiftSelect(-1, this.cellSelector.row.end, selector.COL, this.getLastDataRowIndex()); }, shiftRight: function() { this._shiftSelect(1, this.cellSelector.col.end, selector.ROW, this.viewSection.viewFields().peekLength - 1); }, shiftLeft: function() { this._shiftSelect(-1, this.cellSelector.col.end, selector.ROW, this.viewSection.viewFields().peekLength - 1); }, 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(); }, deleteRecords: function() { const saved = this.cursor.getCursorPos(); this.cursor.setLive(false); // Don't return a promise. Nothing will use it, and the Command implementation will not // prevent default browser behavior if we return a truthy value. this.deleteRows(this.getSelection()) .finally(() => { this.cursor.setCursorPos(saved); this.cursor.setLive(true); this.clearSelection(); }) .catch(reportError); }, insertFieldBefore: function() { this.insertColumn(this.cursor.fieldIndex()); }, insertFieldAfter: function() { this.insertColumn(this.cursor.fieldIndex() + 1); }, renameField: function() { this.currentEditingColumnIndex(this.cursor.fieldIndex()); }, hideFields: function() { this.hideFields(this.getSelection()); }, deleteFields: function() { this.deleteColumns(this.getSelection()); }, 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); } }; 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. * @param {integer} step - amount to increase/decrease the select bound * @param {Observable} selectObs - observable to change * @exemptType {Selector type string} - selector type to noop on IE: Shift + Up/Down should noop if columns are selected. And vice versa for rows. * @param {integer} maxVal - maximum value allowed for the selectObs **/ GridView.prototype._shiftSelect = function(step, selectObs, exemptType, maxVal) { console.assert(exemptType === selector.ROW || exemptType === selector.COL); if (this.cellSelector.isCurrentSelectType(exemptType)) return; if (this.cellSelector.isCurrentSelectType(selector.NONE)) { this.cellSelector.currentSelectType(selector.CELL); } var newVal = gutil.clamp(selectObs() + step, 0, maxVal); selectObs(newVal); }; /** * 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); } this.copySelection(null); }); } }; /** * 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} 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 GridSelection of the selected rows and cols * @returns {Object} CopySelection */ GridView.prototype.getSelection = function() { var rowIds = [], fields = [], rowStyle = {}, colStyle = {}; var colStart = this.cellSelector.colLower(); var colEnd = this.cellSelector.colUpper(); var rowStart = this.cellSelector.rowLower(); var 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(); } var rowId; for(var i = colStart; i <= colEnd; i++) { let field = this.viewSection.viewFields().at(i); fields.push(field); colStyle[field.colId()] = this._getColStyle(i); } 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.deleteRows = function(selection) { if (!this.viewSection.disableAddRemoveRows()) { var rowIds = _.without(selection.rowIds, 'new'); if (rowIds.length > 0) { return this.tableModel.sendTableAction(['BulkRemoveRecord', rowIds]); } } return Promise.resolve(); }; GridView.prototype.addNewColumn = function() { this.insertColumn(this.viewSection.viewFields().peekLength) .then(() => this.scrollPaneRight()); }; GridView.prototype.insertColumn = async function(index) { const pos = tableUtil.fieldInsertPositions(this.viewSection.viewFields(), index)[0]; var action = ['AddColumn', null, {"_position": pos}]; await this.gristDoc.docData.bundleActions('Insert column', async () => { const colInfo = await this.tableModel.sendTableAction(action); if (!this.viewSection.isRaw.peek()){ const fieldInfo = { colRef: colInfo.colRef, parentPos: pos, parentId: this.viewSection.id.peek() }; await this.gristDoc.docModel.viewFields.sendTableAction(['AddRecord', null, fieldInfo]); } }); this.selectColumn(index); this.currentEditingColumnIndex(index); }; GridView.prototype.scrollPaneRight = function() { this.scrollPane.scrollLeft = Number.MAX_SAFE_INTEGER; }; GridView.prototype.selectColumn = function(colIndex) { this.cursor.fieldIndex(colIndex); this.cellSelector.currentSelectType(selector.COL); }; GridView.prototype.showColumn = function(colId, index) { let fieldPos = tableUtil.fieldInsertPositions(this.viewSection.viewFields(), index, 1)[0]; let colInfo = { parentId: this.viewSection.id(), colRef: colId, parentPos: fieldPos }; return this.gristDoc.docModel.viewFields.sendTableAction(['AddRecord', null, colInfo]) .then(() => this.selectColumn(index)) .then(() => this.scrollPaneRight()); }; // TODO: Replace alerts with custom notifications GridView.prototype.deleteColumns = function(selection) { var fields = selection.fields; if (fields.length === this.viewSection.viewFields().peekLength) { alert("You can't delete all the columns on the grid."); return; } let actions = fields.filter(col => !col.disableModify()).map(col => ['RemoveColumn', col.colId()]); if (actions.length > 0) { this.tableModel.sendTableActions(actions, `Removed columns ${actions.map(a => a[1]).join(', ')} ` + `from ${this.tableModel.tableData.tableId}.`).then(() => this.clearSelection()); } }; 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 0; 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 0; 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() { 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 => { var isEditingLabel = 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 => editIndex(val ? field._index() : -1) }).extend({ rateLimit: 0 }); let filterTriggerCtl; const isTooltip = ko.pureComputed(() => self.gristDoc.docModel.editingFormula() && ko.unwrap(self.hoverColumn) === field._index()); return dom( 'div.column_name.field', kd.style('--frozen-position', () => ko.unwrap(this.frozenPositions.at(field._index()))), kd.toggleClass("frozen", () => ko.unwrap(this.frozenMap.at(field._index()))), kd.toggleClass("hover-column", isTooltip), 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(`Click to insert $${field.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', kf.editableLabel(self.isPreview ? field.label : field.displayLabel, isEditingLabel, renameCommands), dom.on('mousedown', ev => isEditingLabel() ? ev.stopPropagation() : true) ), dom.on("mouseenter", () => self.changeHover(field._index())), dom.on("mouseleave", () => self.changeHover(-1)), 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), { attach: 'body', placement: 'bottom-start', boundaries: 'viewport', trigger: [], }); }, menu(ctl => this.columnContextMenu(ctl, this.getSelection(), field, filterTriggerCtl)), testId('column-menu-trigger'), ) ); }), this.isPreview ? null : kd.maybe(() => !this.gristDoc.isReadonlyKo(), () => ( this._modField = dom('div.column_name.mod-add-column.field', '+', kd.style("width", PLUS_WIDTH + 'px'), dom.on('click', ev => { // If there are no hidden columns, clicking the plus just adds a new column. // If there are hidden columns, display a dropdown menu. if (this.viewSection.hiddenColumns().length === 0) { ev.stopImmediatePropagation(); // Don't open the menu defined below this.addNewColumn(); } }), menu((ctl => ColumnAddMenu(this, this.viewSection))) ) )) ) ) //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), // 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', () => { // Must ensure that linkedRowId is not null to avoid drawing on rows whose // row ids are null. return self.linkedRowId() && self.linkedRowId() === row.getRowId(); }) ), 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()); // 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()); }); return dom( 'div.field', 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), dom.on("mouseenter", () => self.changeHover(field._index())), kd.toggleClass("hover-column", () => self.gristDoc.docModel.editingFormula() && ko.unwrap(self.hoverColumn) === (field._index())), 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), fieldBuilder.buildDomWithCursor(row, isCellActive, isCellSelected), dom('div.field_selection') ); }) ) ); } }; /** @inheritdoc */ GridView.prototype.onResize = function() { const activeFieldBuilder = this.activeFieldBuilder(); 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(); this.scrolly.scrollRowIntoView(this.cursor.rowIndex.peek()); } else { this.scrolly.scheduleUpdateSize(); } 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) { 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); let col = this.domToColModel(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) { 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.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) { var currentCol = Math.min(this.getMousePosCol(event.pageX), this.viewSection.viewFields().peekLength - 1); this.cellSelector.col.end(currentCol); }; GridView.prototype.cellMouseMove = function(event) { this.colMouseMove(event); this.rowMouseMove(event); // Maintain single cells cannot be selected invariant if (this.cellSelector.onlyCellSelected(this.cursor.rowIndex(), this.cursor.fieldIndex())) { this.cellSelector.currentSelectType(selector.NONE); } 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]) { this.currentEditingColumnIndex(idx); } 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 MultiColumnMenu(options); } else { return ColumnContextMenu({ 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, isRaw: this.viewSection.isRaw(), isFiltered: this.isFiltered(), isFormula: calcFieldsCondition(copySelection.fields, f => f.column.peek().isRealFormula.peek()), }; } GridView.prototype._columnFilterMenu = function(ctl, field) { this.ctxMenuHolder.autoDispose(ctl); const filterInfo = this.viewSection.filters() .find(({fieldOrColumn}) => fieldOrColumn.origCol().origColRef() === field.column().origColRef()); return this.createFilterMenu(ctl, filterInfo); }; 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 { 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, }; }; GridView.prototype.cellContextMenu = function() { return CellContextMenu( this._getRowContextMenuOptions(), this._getColumnMenuOptions(this.getSelection()) ); }; // 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); } } 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;