mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
b13fb1d97e
Summary: Column description and new renaming popup for the GridView. Test Plan: Updated Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D3838
1787 lines
75 KiB
JavaScript
1787 lines
75 KiB
JavaScript
/* 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');
|
|
|
|
// 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, isNarrowScreen} = 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 {columnInfoTooltip, 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');
|
|
|
|
// 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 => {
|
|
// If there are some frozen columns.
|
|
if (this.numFrozen.peek() && idx < this.numFrozen.peek()) { return; }
|
|
|
|
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 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;
|
|
}));
|
|
|
|
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: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 = {
|
|
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(); },
|
|
|
|
insertFieldBefore: function() { this.insertColumn(this.cursor.fieldIndex()); },
|
|
insertFieldAfter: function() { this.insertColumn(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);
|
|
}
|
|
};
|
|
|
|
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<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 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();
|
|
}
|
|
|
|
// 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.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.renameColumn = function(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) {
|
|
reportWarning("You can't delete all the columns on the grid.", {
|
|
key: 'delete-all-columns',
|
|
});
|
|
return Promise.resolve(false);
|
|
}
|
|
let actions = fields.filter(col => !col.disableModify()).map(col => ['RemoveColumn', col.colId()]);
|
|
if (actions.length > 0) {
|
|
return this.tableModel.sendTableActions(actions, `Removed columns ${actions.map(a => a[1]).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() {
|
|
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.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',
|
|
kd.scope(field.description, desc => desc ? columnInfoTooltip(kd.text(field.description)) : 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
|
|
}),
|
|
),
|
|
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, {showAllFiltersButton: true}),
|
|
{
|
|
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();
|
|
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) {
|
|
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, 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 {
|
|
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;
|