mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Freezing columns on a GridView
Summary: User can freeze any number of columns, which will not move when a user scrolls grid horizontally. Main use cases: - Frozen columns don't move when a user scrolls horizontally - The number of frozen columns is automatically persisted - Readonly viewers see frozen columns and can modify them - but the change is not persisted - On a small screen - frozen columns still moves to the left when scrolled, to reveal at least one column - There is a single menu option - Toggle freeze - which offers the best action considering selected columns - When a user clicks a single column - action to freeze/unfreeze is always there - When a user clicks multiple columns - action is offered only where it makes sens (columns are near the frozen border) Test Plan: Browser tests Reviewers: dsagal, paulfitz Reviewed By: dsagal Differential Revision: https://phab.getgrist.com/D2852
This commit is contained in:
parent
698c9d4e40
commit
bdd4d3c46e
@ -149,10 +149,21 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Left most shadow - displayed next to row numbers or when columns are frozen - after last frozen column */
|
||||||
.scroll_shadow_left {
|
.scroll_shadow_left {
|
||||||
height: 100%; /* Just needs to be tall enough to flow off the bottom*/
|
height: 100%;
|
||||||
width: 0px;
|
width: 0px;
|
||||||
left: 4rem;
|
/* Unfortunately we need to calculate this using scroll position.
|
||||||
|
We could use sticky position here, but we would need to move this component inside the
|
||||||
|
scroll pane. We don't want to do this, because we want the scroll shadow to be render
|
||||||
|
on top of the scroll bar. Fortunately it doesn't jitter on firefox - where scroll event is asynchronous.
|
||||||
|
Variables used here:
|
||||||
|
- frozen-width : total width of frozen columns plus row numbers width
|
||||||
|
- scroll-offset: current left offset of the scroll pane
|
||||||
|
- frozen-offset: when frozen columns are wider then the screen, we want them to move left initially,
|
||||||
|
this value is the position where this movement should stop.
|
||||||
|
*/
|
||||||
|
left: calc(4em + (var(--frozen-width, 0) - min(var(--frozen-scroll-offset, 0), var(--frozen-offset, 0))) * 1px);
|
||||||
box-shadow: -6px 0 6px 6px #444;
|
box-shadow: -6px 0 6px 6px #444;
|
||||||
/* shadow should only show to the right of it (10px should be enough) */
|
/* shadow should only show to the right of it (10px should be enough) */
|
||||||
-webkit-clip-path: polygon(0 0, 10px 0, 10px 100%, 0 100%);
|
-webkit-clip-path: polygon(0 0, 10px 0, 10px 100%, 0 100%);
|
||||||
@ -160,6 +171,33 @@
|
|||||||
z-index: 3;
|
z-index: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Right shadow - normally not displayed - activated when grid has frozen columns */
|
||||||
|
.scroll_shadow_frozen {
|
||||||
|
height: 100%;
|
||||||
|
width: 0px;
|
||||||
|
left: 4em;
|
||||||
|
box-shadow: -8px 0 14px 4px #444;
|
||||||
|
-webkit-clip-path: polygon(0 0, 10px 0, 10px 100%, 0 100%);
|
||||||
|
clip-path: polygon(0 0, 28px 0, 24px 100%, 0 100%);
|
||||||
|
z-index: 3;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* line that indicates where the frozen columns end */
|
||||||
|
.frozen_line {
|
||||||
|
position:absolute;
|
||||||
|
height: 100%;
|
||||||
|
width: 2px;
|
||||||
|
/* this value is the same as for the left shadow - but doesn't need to really on the scroll offset
|
||||||
|
as this component will be hidden when the scroll starts
|
||||||
|
*/
|
||||||
|
left: calc(4em + var(--frozen-width, 0) * 1px);
|
||||||
|
background-color: #999999;
|
||||||
|
z-index: 3;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none
|
||||||
|
}
|
||||||
|
|
||||||
.scroll_shadow_top {
|
.scroll_shadow_top {
|
||||||
left: 0;
|
left: 0;
|
||||||
height: 0;
|
height: 0;
|
||||||
@ -181,6 +219,17 @@
|
|||||||
border-right: 1px solid lightgray;
|
border-right: 1px solid lightgray;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gridview_left_border {
|
||||||
|
position: absolute;
|
||||||
|
width: 0px; /* Matches rowid width (+border) */
|
||||||
|
height: 100%;
|
||||||
|
z-index: 3;
|
||||||
|
left: calc(4rem);
|
||||||
|
border-right: 1px solid var(--grist-color-dark-grey) !important;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none
|
||||||
|
}
|
||||||
|
|
||||||
.gridview_header_backdrop_top {
|
.gridview_header_backdrop_top {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: calc(var(--gridview-header-height) + 1px); /* matches gridview_data_header height (+border) */
|
height: calc(var(--gridview-header-height) + 1px); /* matches gridview_data_header height (+border) */
|
||||||
@ -243,6 +292,49 @@
|
|||||||
pointer-events: none; /* prevents row drag shadow from stealing row headers clicks */
|
pointer-events: none; /* prevents row drag shadow from stealing row headers clicks */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ================ Freezing columns */
|
||||||
|
|
||||||
|
/* style header and a data field */
|
||||||
|
.record .field.frozen {
|
||||||
|
position: sticky;
|
||||||
|
left: calc(4em + 1px + (var(--frozen-position, 0) - var(--frozen-offset, 0)) * 1px); /* 4em for row number + total width of cells + 1px for border*/
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* for data field we need to reuse color from record (add-row and zebra stripes) */
|
||||||
|
.gridview_row .record .field.frozen {
|
||||||
|
background-color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* HACK: add box shadow to fix outline overflow from active cursor */
|
||||||
|
.gridview_row .record .field.frozen {
|
||||||
|
box-shadow: 0px 1px 0px white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gridview_row .record.record-hlines .field.frozen {
|
||||||
|
box-shadow: 0px 1px 0px var(--grist-color-dark-grey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* selected field has a transparent color - with frozen fields we can't do it */
|
||||||
|
.gridview_row .field.frozen.selected {
|
||||||
|
background-color: var(--grist-color-selection-opaque);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* make room for a frozen line by adding margin to first not frozen field - in header and in data */
|
||||||
|
.field.frozen + .field:not(.frozen) {
|
||||||
|
margin-left: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* printing frozen fields is straightforward - just need to remove transparency */
|
||||||
|
@media print {
|
||||||
|
.field.frozen {
|
||||||
|
background: white !important;
|
||||||
|
}
|
||||||
|
.column_names .column_name.frozen {
|
||||||
|
background: var(--grist-color-light-grey) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Etc */
|
/* Etc */
|
||||||
|
|
||||||
.g-column-main-menu {
|
.g-column-main-menu {
|
||||||
|
@ -29,7 +29,7 @@ const {onDblClickMatchElem} = require('app/client/lib/dblclick');
|
|||||||
const {Holder} = require('grainjs');
|
const {Holder} = require('grainjs');
|
||||||
const {menu} = require('../ui2018/menus');
|
const {menu} = require('../ui2018/menus');
|
||||||
const {calcFieldsCondition} = require('../ui/GridViewMenus');
|
const {calcFieldsCondition} = require('../ui/GridViewMenus');
|
||||||
const {ColumnAddMenu, ColumnContextMenu, MultiColumnMenu, RowContextMenu} = require('../ui/GridViewMenus');
|
const {ColumnAddMenu, ColumnContextMenu, MultiColumnMenu, RowContextMenu, freezeAction} = require('../ui/GridViewMenus');
|
||||||
const {setPopupToCreateDom} = require('popweasel');
|
const {setPopupToCreateDom} = require('popweasel');
|
||||||
const {testId} = require('app/client/ui2018/cssVars');
|
const {testId} = require('app/client/ui2018/cssVars');
|
||||||
|
|
||||||
@ -41,6 +41,10 @@ const {testId} = require('app/client/ui2018/cssVars');
|
|||||||
// it was.
|
// it was.
|
||||||
const SHORT_CLICK_IN_MS = 500;
|
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.
|
* GridView component implements the view of a grid of cells.
|
||||||
@ -59,7 +63,10 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) {
|
|||||||
this.rowShadowAdjust = 0; // pixel dist from mouse click y-coord and the clicked row's top offset
|
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.colShadowAdjust = 0; // ^ for x-coord and clicked col's left offset
|
||||||
this.scrollLeft = ko.observable(0);
|
this.scrollLeft = ko.observable(0);
|
||||||
|
this.isScrolledLeft = this.autoDispose(ko.computed(() => this.scrollLeft() > 0));
|
||||||
this.scrollTop = ko.observable(0);
|
this.scrollTop = ko.observable(0);
|
||||||
|
this.isScrolledTop = this.autoDispose(ko.computed(() => this.scrollTop() > 0));
|
||||||
|
|
||||||
this.cellSelector = this.autoDispose(selector.CellSelector.create(this, {
|
this.cellSelector = this.autoDispose(selector.CellSelector.create(this, {
|
||||||
// This is a bit of a hack to prevent dragging when there's an open column menu
|
// This is a bit of a hack to prevent dragging when there's an open column menu
|
||||||
isDisabled: () => Boolean(!this.ctxMenuHolder.isEmpty())
|
isDisabled: () => Boolean(!this.ctxMenuHolder.isEmpty())
|
||||||
@ -85,7 +92,7 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) {
|
|||||||
const leftEdge = this.scrollPane.scrollLeft;
|
const leftEdge = this.scrollPane.scrollLeft;
|
||||||
const rightEdge = leftEdge + viewWidth;
|
const rightEdge = leftEdge + viewWidth;
|
||||||
|
|
||||||
//If cell doesnt fit onscreen, scroll to fit
|
//If cell doesn't fit onscreen, scroll to fit
|
||||||
const scrollShift = offset - gutil.clamp(offset, leftEdge, rightEdge - fieldWidth);
|
const scrollShift = offset - gutil.clamp(offset, leftEdge, rightEdge - fieldWidth);
|
||||||
this.scrollPane.scrollLeft = this.scrollPane.scrollLeft + scrollShift;
|
this.scrollPane.scrollLeft = this.scrollPane.scrollLeft + scrollShift;
|
||||||
}));
|
}));
|
||||||
@ -94,14 +101,69 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) {
|
|||||||
|
|
||||||
// Some observables for the scroll markers that show that the view is cut off on a side.
|
// Some observables for the scroll markers that show that the view is cut off on a side.
|
||||||
this.scrollShadow = {
|
this.scrollShadow = {
|
||||||
left: ko.observable(false),
|
left: this.isScrolledLeft,
|
||||||
top: ko.observable(false),
|
top: this.isScrolledTop
|
||||||
};
|
};
|
||||||
|
|
||||||
//--------------------------------------------------
|
//--------------------------------------------------
|
||||||
// Set up row and column context menus.
|
// Set up row and column context menus.
|
||||||
this.ctxMenuHolder = Holder.create(this);
|
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));
|
||||||
|
|
||||||
//--------------------------------------------------
|
//--------------------------------------------------
|
||||||
// Create and attach the DOM for the view.
|
// Create and attach the DOM for the view.
|
||||||
|
|
||||||
@ -110,6 +172,8 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) {
|
|||||||
}, this));
|
}, this));
|
||||||
this.header = null;
|
this.header = null;
|
||||||
this._cornerDom = null;
|
this._cornerDom = null;
|
||||||
|
// dom for adding new column - used by freeze calculation
|
||||||
|
this._modField = null;
|
||||||
this.scrollPane = null;
|
this.scrollPane = null;
|
||||||
this.viewPane = this.autoDispose(this.buildDom());
|
this.viewPane = this.autoDispose(this.buildDom());
|
||||||
this.attachSelectorHandlers();
|
this.attachSelectorHandlers();
|
||||||
@ -202,6 +266,23 @@ GridView.gridCommands = {
|
|||||||
},
|
},
|
||||||
addSortDesc: function() {
|
addSortDesc: function() {
|
||||||
addToSort(this.viewSection.activeSortSpec, -this.currentColumn().getRowId());
|
addToSort(this.viewSection.activeSortSpec, -this.currentColumn().getRowId());
|
||||||
|
},
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -701,10 +782,9 @@ GridView.prototype.domToColModel = function(elem, elemType) {
|
|||||||
//TODO : is this necessary? make passive. Also this could be removed soon I think
|
//TODO : is this necessary? make passive. Also this could be removed soon I think
|
||||||
GridView.prototype.onScroll = function() {
|
GridView.prototype.onScroll = function() {
|
||||||
var pane = this.scrollPane;
|
var pane = this.scrollPane;
|
||||||
this.scrollShadow.left(pane.scrollLeft > 0);
|
|
||||||
this.scrollShadow.top(pane.scrollTop > 0);
|
|
||||||
this.scrollLeft(pane.scrollLeft);
|
this.scrollLeft(pane.scrollLeft);
|
||||||
this.scrollTop(pane.scrollTop);
|
this.scrollTop(pane.scrollTop);
|
||||||
|
this.width(pane.clientWidth);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@ -733,7 +813,10 @@ GridView.prototype.buildDom = function() {
|
|||||||
return dom(
|
return dom(
|
||||||
'div.gridview_data_pane.flexvbox',
|
'div.gridview_data_pane.flexvbox',
|
||||||
this.gristDoc.app.addNewUIClass(),
|
this.gristDoc.app.addNewUIClass(),
|
||||||
|
// 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, bars and shadows
|
||||||
// Corner and shadows (so it's fixed to the grid viewport)
|
// Corner and shadows (so it's fixed to the grid viewport)
|
||||||
self._cornerDom = dom(
|
self._cornerDom = dom(
|
||||||
@ -741,9 +824,16 @@ GridView.prototype.buildDom = function() {
|
|||||||
dom.on('click', () => this.selectAll()),
|
dom.on('click', () => this.selectAll()),
|
||||||
),
|
),
|
||||||
dom('div.scroll_shadow_top', kd.show(this.scrollShadow.top)),
|
dom('div.scroll_shadow_top', kd.show(this.scrollShadow.top)),
|
||||||
dom('div.scroll_shadow_left', kd.show(this.scrollShadow.left)),
|
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_left'), //these hide behind the actual headers to keep them from flashing
|
||||||
dom('div.gridview_header_backdrop_top'),
|
dom('div.gridview_header_backdrop_top'),
|
||||||
|
dom('div.gridview_left_border'), //these hide behind the actual headers to keep them from flashing
|
||||||
|
// left shadow that will be visible on top of frozen columns
|
||||||
|
dom('div.scroll_shadow_frozen', kd.show(this.frozenShadow)),
|
||||||
|
|
||||||
// Drag indicators
|
// Drag indicators
|
||||||
self.colLine = dom(
|
self.colLine = dom(
|
||||||
@ -794,6 +884,8 @@ GridView.prototype.buildDom = function() {
|
|||||||
let filterTriggerCtl;
|
let filterTriggerCtl;
|
||||||
return dom(
|
return dom(
|
||||||
'div.column_name.field',
|
'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()))),
|
||||||
dom.autoDispose(isEditingLabel),
|
dom.autoDispose(isEditingLabel),
|
||||||
dom.testId("GridView_columnLabel"),
|
dom.testId("GridView_columnLabel"),
|
||||||
kd.style('width', field.widthPx),
|
kd.style('width', field.widthPx),
|
||||||
@ -829,8 +921,9 @@ GridView.prototype.buildDom = function() {
|
|||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
this.isPreview ? null : kd.maybe(() => !this.gristDoc.isReadonlyKo(), () => (
|
this.isPreview ? null : kd.maybe(() => !this.gristDoc.isReadonlyKo(), () => (
|
||||||
dom('div.column_name.mod-add-column.field',
|
this._modField = dom('div.column_name.mod-add-column.field',
|
||||||
'+',
|
'+',
|
||||||
|
kd.style("width", PLUS_WIDTH + 'px'),
|
||||||
dom.on('click', ev => {
|
dom.on('click', ev => {
|
||||||
// If there are no hidden columns, clicking the plus just adds a new column.
|
// If there are no hidden columns, clicking the plus just adds a new column.
|
||||||
// If there are hidden columns, display a dropdown menu.
|
// If there are hidden columns, display a dropdown menu.
|
||||||
@ -880,6 +973,7 @@ GridView.prototype.buildDom = function() {
|
|||||||
|
|
||||||
// rowid dom
|
// rowid dom
|
||||||
dom('div.gridview_data_row_num',
|
dom('div.gridview_data_row_num',
|
||||||
|
kd.style("width", ROW_NUMBER_WIDTH + 'px'),
|
||||||
dom('div.gridview_data_row_info',
|
dom('div.gridview_data_row_info',
|
||||||
kd.toggleClass('linked_dst', () => {
|
kd.toggleClass('linked_dst', () => {
|
||||||
// Must ensure that linkedRowId is not null to avoid drawing on rows whose
|
// Must ensure that linkedRowId is not null to avoid drawing on rows whose
|
||||||
@ -948,6 +1042,8 @@ GridView.prototype.buildDom = function() {
|
|||||||
});
|
});
|
||||||
return dom(
|
return dom(
|
||||||
'div.field',
|
'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),
|
kd.toggleClass('scissors', isCopyActive),
|
||||||
dom.autoDispose(isCopyActive),
|
dom.autoDispose(isCopyActive),
|
||||||
dom.autoDispose(isCellSelected),
|
dom.autoDispose(isCellSelected),
|
||||||
@ -979,6 +1075,7 @@ GridView.prototype.onResize = function() {
|
|||||||
} else {
|
} else {
|
||||||
this.scrolly.scheduleUpdateSize();
|
this.scrolly.scheduleUpdateSize();
|
||||||
}
|
}
|
||||||
|
this.width(this.scrollPane.clientWidth)
|
||||||
};
|
};
|
||||||
|
|
||||||
/** @inheritdoc */
|
/** @inheritdoc */
|
||||||
@ -1265,7 +1362,10 @@ GridView.prototype.columnContextMenu = function(ctl, copySelection, field, filte
|
|||||||
|
|
||||||
GridView.prototype._getColumnMenuOptions = function(copySelection) {
|
GridView.prototype._getColumnMenuOptions = function(copySelection) {
|
||||||
return {
|
return {
|
||||||
|
columnIndices: copySelection.fields.map(f => f._index()),
|
||||||
|
totalColumnCount : this.viewSection.viewFields.peek().peekLength,
|
||||||
numColumns: copySelection.fields.length,
|
numColumns: copySelection.fields.length,
|
||||||
|
numFrozen: this.viewSection.numFrozen.peek(),
|
||||||
disableModify: calcFieldsCondition(copySelection.fields, f => f.disableModify.peek()),
|
disableModify: calcFieldsCondition(copySelection.fields, f => f.disableModify.peek()),
|
||||||
isReadonly: this.gristDoc.isReadonly.get(),
|
isReadonly: this.gristDoc.isReadonly.get(),
|
||||||
isFiltered: this.isFiltered(),
|
isFiltered: this.isFiltered(),
|
||||||
|
@ -331,6 +331,10 @@ exports.groups = [{
|
|||||||
name: 'hideField',
|
name: 'hideField',
|
||||||
keys: ['Alt+Shift+-'],
|
keys: ['Alt+Shift+-'],
|
||||||
desc: 'Hide the currently selected column'
|
desc: 'Hide the currently selected column'
|
||||||
|
}, {
|
||||||
|
name: 'toggleFreeze',
|
||||||
|
keys: [],
|
||||||
|
desc: 'Freeze or unfreeze selected columns'
|
||||||
}, {
|
}, {
|
||||||
name: 'deleteFields',
|
name: 'deleteFields',
|
||||||
keys: ['Alt+-'],
|
keys: ['Alt+-'],
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
/*
|
||||||
|
record class is used for grid view header and rows
|
||||||
|
*/
|
||||||
.record {
|
.record {
|
||||||
display: -webkit-flex;
|
display: -webkit-flex;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -10,6 +13,13 @@
|
|||||||
border-color: var(--grist-color-dark-grey);
|
border-color: var(--grist-color-dark-grey);
|
||||||
border-left-style: solid; /* left border, against rownumbers div, always on */
|
border-left-style: solid; /* left border, against rownumbers div, always on */
|
||||||
border-bottom-width: 1px; /* style: none, set by record-hlines*/
|
border-bottom-width: 1px; /* style: none, set by record-hlines*/
|
||||||
|
/* Record background is white by default.
|
||||||
|
It gets overridden by the add row, zebra stripes.
|
||||||
|
It also gets overridden by selecting rows - but in that case background comes from
|
||||||
|
selected fields - this still remains white.
|
||||||
|
TODO: consider making this color the single source
|
||||||
|
*/
|
||||||
|
background: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.record.record-hlines { /* Overwrites style, width set on element */
|
.record.record-hlines { /* Overwrites style, width set on element */
|
||||||
|
@ -89,6 +89,11 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section"> {
|
|||||||
isSorted: ko.Computed<boolean>;
|
isSorted: ko.Computed<boolean>;
|
||||||
disableDragRows: ko.Computed<boolean>;
|
disableDragRows: ko.Computed<boolean>;
|
||||||
activeFilterBar: modelUtil.CustomComputed<boolean>;
|
activeFilterBar: modelUtil.CustomComputed<boolean>;
|
||||||
|
// Number of frozen columns
|
||||||
|
rawNumFrozen: modelUtil.CustomComputed<number>;
|
||||||
|
// Number for frozen columns to display.
|
||||||
|
// We won't freeze all the columns on a grid, it will leave at least 1 column unfrozen.
|
||||||
|
numFrozen: ko.Computed<number>;
|
||||||
|
|
||||||
// Save all filters of fields in the section.
|
// Save all filters of fields in the section.
|
||||||
saveFilters(): Promise<void>;
|
saveFilters(): Promise<void>;
|
||||||
@ -133,6 +138,7 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
|
|||||||
zebraStripes: false,
|
zebraStripes: false,
|
||||||
customView: '',
|
customView: '',
|
||||||
filterBar: false,
|
filterBar: false,
|
||||||
|
numFrozen: 0
|
||||||
};
|
};
|
||||||
this.optionsObj = modelUtil.jsonObservable(this.options,
|
this.optionsObj = modelUtil.jsonObservable(this.options,
|
||||||
(obj: any) => defaults(obj || {}, defaultOptions));
|
(obj: any) => defaults(obj || {}, defaultOptions));
|
||||||
@ -276,4 +282,17 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
|
|||||||
this.disableDragRows = ko.pureComputed(() => this.isSorted() || !this.table().supportsManualSort());
|
this.disableDragRows = ko.pureComputed(() => this.isSorted() || !this.table().supportsManualSort());
|
||||||
|
|
||||||
this.activeFilterBar = modelUtil.customValue(this.optionsObj.prop('filterBar'));
|
this.activeFilterBar = modelUtil.customValue(this.optionsObj.prop('filterBar'));
|
||||||
|
|
||||||
|
// Number of frozen columns
|
||||||
|
this.rawNumFrozen = modelUtil.customValue(this.optionsObj.prop('numFrozen'));
|
||||||
|
// Number for frozen columns to display
|
||||||
|
this.numFrozen = ko.pureComputed(() =>
|
||||||
|
Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(
|
||||||
|
this.rawNumFrozen(),
|
||||||
|
this.viewFields().all().length - 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -71,10 +71,14 @@ interface IMultiColumnContextMenu {
|
|||||||
// For multiple selection, true/false means the value applies to all columns, 'mixed' means it's
|
// For multiple selection, true/false means the value applies to all columns, 'mixed' means it's
|
||||||
// true for some columns, but not all.
|
// true for some columns, but not all.
|
||||||
numColumns: number;
|
numColumns: number;
|
||||||
|
numFrozen: number;
|
||||||
disableModify: boolean|'mixed'; // If the columns are read-only.
|
disableModify: boolean|'mixed'; // If the columns are read-only.
|
||||||
isReadonly: boolean;
|
isReadonly: boolean;
|
||||||
isFiltered: boolean; // If this view shows a proper subset of all rows in the table.
|
isFiltered: boolean; // If this view shows a proper subset of all rows in the table.
|
||||||
isFormula: boolean|'mixed';
|
isFormula: boolean|'mixed';
|
||||||
|
columnIndices: number[];
|
||||||
|
totalColumnCount: number;
|
||||||
|
disableFrozenMenu: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IColumnContextMenu extends IMultiColumnContextMenu {
|
interface IColumnContextMenu extends IMultiColumnContextMenu {
|
||||||
@ -94,6 +98,7 @@ export function ColumnContextMenu(options: IColumnContextMenu) {
|
|||||||
const disableForReadonlyView = dom.cls('disabled', isReadonly);
|
const disableForReadonlyView = dom.cls('disabled', isReadonly);
|
||||||
|
|
||||||
const addToSortLabel = getAddToSortLabel(sortSpec, colId);
|
const addToSortLabel = getAddToSortLabel(sortSpec, colId);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
menuItemCmd(allCommands.fieldTabOpen, 'Column Options'),
|
menuItemCmd(allCommands.fieldTabOpen, 'Column Options'),
|
||||||
menuItem(filterOpenFunc, 'Filter Data'),
|
menuItem(filterOpenFunc, 'Filter Data'),
|
||||||
@ -142,9 +147,9 @@ export function ColumnContextMenu(options: IColumnContextMenu) {
|
|||||||
] : null,
|
] : null,
|
||||||
menuItemCmd(allCommands.renameField, 'Rename column', disableForReadonlyColumn),
|
menuItemCmd(allCommands.renameField, 'Rename column', disableForReadonlyColumn),
|
||||||
menuItemCmd(allCommands.hideField, 'Hide column', disableForReadonlyView),
|
menuItemCmd(allCommands.hideField, 'Hide column', disableForReadonlyView),
|
||||||
|
freezeMenuItemCmd(options),
|
||||||
menuDivider(),
|
menuDivider(),
|
||||||
MultiColumnMenu(options),
|
MultiColumnMenu((options.disableFrozenMenu = true, options)),
|
||||||
testId('column-menu'),
|
testId('column-menu'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -165,10 +170,11 @@ export function MultiColumnMenu(options: IMultiColumnContextMenu) {
|
|||||||
(num > 1 ? `Clear ${num} entire columns` : 'Clear entire column') :
|
(num > 1 ? `Clear ${num} entire columns` : 'Clear entire column') :
|
||||||
(num > 1 ? `Clear ${num} columns` : 'Clear column');
|
(num > 1 ? `Clear ${num} columns` : 'Clear column');
|
||||||
const nameDeleteColumns = num > 1 ? `Delete ${num} columns` : 'Delete column';
|
const nameDeleteColumns = num > 1 ? `Delete ${num} columns` : 'Delete column';
|
||||||
|
const frozenMenu = options.disableFrozenMenu ? null : freezeMenuItemCmd(options);
|
||||||
return [
|
return [
|
||||||
// TODO This should be made to work too for multiple columns.
|
// TODO This should be made to work too for multiple columns.
|
||||||
// menuItemCmd(allCommands.hideField, 'Hide column', disableForReadonlyView),
|
// menuItemCmd(allCommands.hideField, 'Hide column', disableForReadonlyView),
|
||||||
|
frozenMenu ? [frozenMenu, menuDivider()]: null,
|
||||||
// Offered only when selection includes formula columns, and converts only those.
|
// Offered only when selection includes formula columns, and converts only those.
|
||||||
(options.isFormula ?
|
(options.isFormula ?
|
||||||
menuItemCmd(allCommands.convertFormulasToData, 'Convert formula to data',
|
menuItemCmd(allCommands.convertFormulasToData, 'Convert formula to data',
|
||||||
@ -183,10 +189,119 @@ export function MultiColumnMenu(options: IMultiColumnContextMenu) {
|
|||||||
|
|
||||||
menuDivider(),
|
menuDivider(),
|
||||||
menuItemCmd(allCommands.insertFieldBefore, 'Insert column to the left', disableForReadonlyView),
|
menuItemCmd(allCommands.insertFieldBefore, 'Insert column to the left', disableForReadonlyView),
|
||||||
menuItemCmd(allCommands.insertFieldAfter, 'Insert column to the right', disableForReadonlyView),
|
menuItemCmd(allCommands.insertFieldAfter, 'Insert column to the right', disableForReadonlyView)
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function freezeAction(options: IMultiColumnContextMenu): { text: string; numFrozen: number; } | null {
|
||||||
|
/**
|
||||||
|
* When user clicks last column - don't offer freezing
|
||||||
|
* When user clicks on a normal column - offer him to freeze all the columns to the
|
||||||
|
* left (inclusive).
|
||||||
|
* When user clicks on a frozen column - offer him to unfreeze all the columns to the
|
||||||
|
* right (inclusive)
|
||||||
|
* When user clicks on a set of columns then:
|
||||||
|
* - If the set of columns contains the last columns that are frozen - offer unfreezing only those columns
|
||||||
|
* - If the set of columns is right after the frozen columns or spans across - offer freezing only those columns
|
||||||
|
*
|
||||||
|
* All of the above are a single command - toggle freeze
|
||||||
|
*/
|
||||||
|
|
||||||
|
const length = options.numColumns;
|
||||||
|
|
||||||
|
// make some assertions - number of columns selected should always be > 0
|
||||||
|
if (length === 0) { return null; }
|
||||||
|
|
||||||
|
const indices = options.columnIndices;
|
||||||
|
const firstColumnIndex = indices[0];
|
||||||
|
const lastColumnIndex = indices[indices.length - 1];
|
||||||
|
const numFrozen = options.numFrozen;
|
||||||
|
|
||||||
|
// if set has last column in it - don't offer freezing
|
||||||
|
if (lastColumnIndex == options.totalColumnCount - 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isNormalColumn = length === 1 && (firstColumnIndex + 1) > numFrozen;
|
||||||
|
const isFrozenColumn = length === 1 && (firstColumnIndex+ 1) <= numFrozen;
|
||||||
|
const isSet = length > 1;
|
||||||
|
const isLastFrozenSet = isSet && lastColumnIndex + 1 === numFrozen;
|
||||||
|
const isFirstNormalSet = isSet && firstColumnIndex === numFrozen;
|
||||||
|
const isSpanSet = isSet && firstColumnIndex <= numFrozen && lastColumnIndex >= numFrozen;
|
||||||
|
|
||||||
|
let text = '';
|
||||||
|
|
||||||
|
if (!isSet) {
|
||||||
|
if (isNormalColumn) {
|
||||||
|
// text to show depends on what user selected and how far are we from
|
||||||
|
// last frozen column
|
||||||
|
|
||||||
|
// if user clicked the first column or a column just after frozen set
|
||||||
|
if (firstColumnIndex === 0 || firstColumnIndex === numFrozen) {
|
||||||
|
text = 'Freeze this column';
|
||||||
|
} else {
|
||||||
|
// else user clicked any other column that is farther, offer to freeze
|
||||||
|
// proper number of column
|
||||||
|
const properNumber = firstColumnIndex - numFrozen + 1;
|
||||||
|
text = `Freeze ${properNumber} ${numFrozen ? 'more ' : ''}columns`;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
numFrozen : firstColumnIndex + 1
|
||||||
|
};
|
||||||
|
} else if (isFrozenColumn) {
|
||||||
|
// when user clicked last column in frozen set - offer to unfreeze this column
|
||||||
|
if (firstColumnIndex + 1 === numFrozen) {
|
||||||
|
text = `Unfreeze this column`;
|
||||||
|
} else {
|
||||||
|
// else user clicked column that is not the last in a frozen set
|
||||||
|
// offer to unfreeze proper number of columns
|
||||||
|
const properNumber = numFrozen - firstColumnIndex;
|
||||||
|
text = `Unfreeze ${properNumber === numFrozen ? 'all' : properNumber} columns`;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
numFrozen : indices[0]
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (isLastFrozenSet) {
|
||||||
|
text = `Unfreeze ${length} columns`;
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
numFrozen : numFrozen - length
|
||||||
|
};
|
||||||
|
} else if (isFirstNormalSet) {
|
||||||
|
text = `Freeze ${length} columns`;
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
numFrozen : numFrozen + length
|
||||||
|
};
|
||||||
|
} else if (isSpanSet) {
|
||||||
|
const toFreeze = lastColumnIndex + 1 - numFrozen;
|
||||||
|
text = `Freeze ${toFreeze == 1 ? 'one more column' : (`${toFreeze} more columns`)}`;
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
numFrozen : numFrozen + toFreeze
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function freezeMenuItemCmd(options: IMultiColumnContextMenu) {
|
||||||
|
// calculate action available for this options
|
||||||
|
const toggle = freezeAction(options);
|
||||||
|
// if we can't offer freezing - don't create a menu at all
|
||||||
|
// this shouldn't happen - as current design offers some action on every column
|
||||||
|
if (!toggle) { return null; }
|
||||||
|
// create menu item if we have something to offer
|
||||||
|
return menuItemCmd(allCommands.toggleFreeze, toggle.text);
|
||||||
|
}
|
||||||
|
|
||||||
// Returns 'Add to sort' is there are columns in the sort spec but colId is not part of it. Returns
|
// Returns 'Add to sort' is there are columns in the sort spec but colId is not part of it. Returns
|
||||||
// undefined if colId is the only column in the spec. Otherwise returns `Sorted (#N)` where #N is
|
// undefined if colId is the only column in the spec. Otherwise returns `Sorted (#N)` where #N is
|
||||||
// the position (1 based) of colId in the spec.
|
// the position (1 based) of colId in the spec.
|
||||||
|
@ -43,6 +43,8 @@ export const colors = {
|
|||||||
|
|
||||||
cursor: new CustomProp('color-cursor', '#16B378'), // cursor is lightGreen
|
cursor: new CustomProp('color-cursor', '#16B378'), // cursor is lightGreen
|
||||||
selection: new CustomProp('color-selection', 'rgba(22,179,120,0.15)'),
|
selection: new CustomProp('color-selection', 'rgba(22,179,120,0.15)'),
|
||||||
|
selectionOpaque: new CustomProp('color-selection-opaque', '#DCF4EB'),
|
||||||
|
|
||||||
inactiveCursor: new CustomProp('color-inactive-cursor', '#A2E1C9'),
|
inactiveCursor: new CustomProp('color-inactive-cursor', '#A2E1C9'),
|
||||||
|
|
||||||
hover: new CustomProp('color-hover', '#bfbfbf'),
|
hover: new CustomProp('color-hover', '#bfbfbf'),
|
||||||
|
@ -1479,6 +1479,14 @@ export async function addColumn(name: string) {
|
|||||||
await waitForServer();
|
await waitForServer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Select a range of columns, clicking on col1 and dragging to col2.
|
||||||
|
export async function selectColumnRange(col1: string, col2: string) {
|
||||||
|
await getColumnHeader({col: col1}).mouseMove();
|
||||||
|
await driver.mouseDown();
|
||||||
|
await getColumnHeader({col: col2}).mouseMove();
|
||||||
|
await driver.mouseUp();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Changes browser window dimension to FullHd for a test suit.
|
* Changes browser window dimension to FullHd for a test suit.
|
||||||
*/
|
*/
|
||||||
|
Loading…
Reference in New Issue
Block a user